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:
@@ -3,7 +3,7 @@
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"swashbuckle.aspnetcore.cli": {
|
||||
"version": "7.3.2",
|
||||
"version": "9.0.4",
|
||||
"commands": ["swagger"]
|
||||
},
|
||||
"dotnet-ef": {
|
||||
|
||||
24
.github/CODEOWNERS
vendored
24
.github/CODEOWNERS
vendored
@@ -4,17 +4,18 @@
|
||||
#
|
||||
# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
|
||||
|
||||
## Docker files have shared ownership ##
|
||||
**/Dockerfile
|
||||
**/*.Dockerfile
|
||||
**/.dockerignore
|
||||
**/entrypoint.sh
|
||||
## Docker-related files
|
||||
**/Dockerfile @bitwarden/team-appsec @bitwarden/dept-bre
|
||||
**/*.Dockerfile @bitwarden/team-appsec @bitwarden/dept-bre
|
||||
**/*.dockerignore @bitwarden/team-appsec @bitwarden/dept-bre
|
||||
**/docker-compose.yml @bitwarden/team-appsec @bitwarden/dept-bre
|
||||
**/entrypoint.sh @bitwarden/team-appsec @bitwarden/dept-bre
|
||||
|
||||
## BRE team owns these workflows ##
|
||||
.github/workflows/publish.yml @bitwarden/dept-bre
|
||||
|
||||
## These are shared workflows ##
|
||||
.github/workflows/_move_finalization_db_scripts.yml
|
||||
.github/workflows/_move_edd_db_scripts.yml
|
||||
.github/workflows/release.yml
|
||||
|
||||
# Database Operations for database changes
|
||||
@@ -33,6 +34,9 @@ util/SqliteMigrations/** @bitwarden/dept-dbops
|
||||
# Shared util projects
|
||||
util/Setup/** @bitwarden/dept-bre @bitwarden/team-platform-dev
|
||||
|
||||
# UIF
|
||||
src/Core/MailTemplates/Mjml @bitwarden/team-ui-foundation # Teams are expected to own sub-directories of this project
|
||||
|
||||
# Auth team
|
||||
**/Auth @bitwarden/team-auth-dev
|
||||
bitwarden_license/src/Sso @bitwarden/team-auth-dev
|
||||
@@ -47,11 +51,7 @@ src/Core/IdentityServer @bitwarden/team-auth-dev
|
||||
**/Tools @bitwarden/team-tools-dev
|
||||
|
||||
# Dirt (Data Insights & Reporting) team
|
||||
src/Api/Controllers/Dirt @bitwarden/team-data-insights-and-reporting-dev
|
||||
src/Core/Dirt @bitwarden/team-data-insights-and-reporting-dev
|
||||
src/Infrastructure.Dapper/Dirt @bitwarden/team-data-insights-and-reporting-dev
|
||||
test/Api.Test/Dirt @bitwarden/team-data-insights-and-reporting-dev
|
||||
test/Core.Test/Dirt @bitwarden/team-data-insights-and-reporting-dev
|
||||
**/Dirt @bitwarden/team-data-insights-and-reporting-dev
|
||||
|
||||
# Vault team
|
||||
**/Vault @bitwarden/team-vault-dev
|
||||
@@ -93,6 +93,8 @@ src/Admin/Views/Tools @bitwarden/team-billing-dev
|
||||
**/.dockerignore @bitwarden/team-platform-dev
|
||||
**/Dockerfile @bitwarden/team-platform-dev
|
||||
**/entrypoint.sh @bitwarden/team-platform-dev
|
||||
# The PushType enum is expected to be editted by anyone without need for Platform review
|
||||
src/Core/Platform/Push/PushType.cs
|
||||
|
||||
# Multiple owners - DO NOT REMOVE (BRE)
|
||||
**/packages.lock.json
|
||||
|
||||
1
.github/ISSUE_TEMPLATE/bw-unified.yml
vendored
1
.github/ISSUE_TEMPLATE/bw-unified.yml
vendored
@@ -1,4 +1,3 @@
|
||||
name: Bitwarden Unified Bug Report
|
||||
name: Bitwarden Unified Deployment Bug Report
|
||||
description: File a bug report
|
||||
labels: [bug, bw-unified-deploy]
|
||||
|
||||
14
.github/renovate.json5
vendored
14
.github/renovate.json5
vendored
@@ -9,18 +9,6 @@
|
||||
"nuget",
|
||||
],
|
||||
packageRules: [
|
||||
{
|
||||
// Group all release-related workflows for GitHub Actions together for BRE.
|
||||
groupName: "github-action",
|
||||
matchManagers: ["github-actions"],
|
||||
matchFileNames: [
|
||||
".github/workflows/publish.yml",
|
||||
".github/workflows/release.yml"
|
||||
],
|
||||
commitMessagePrefix: "[deps] BRE:",
|
||||
reviewers: ["team:dept-bre"],
|
||||
addLabels: ["hold"],
|
||||
},
|
||||
{
|
||||
groupName: "dockerfile minor",
|
||||
matchManagers: ["dockerfile"],
|
||||
@@ -35,6 +23,7 @@
|
||||
groupName: "github-action minor",
|
||||
matchManagers: ["github-actions"],
|
||||
matchUpdateTypes: ["minor"],
|
||||
addLabels: ["hold"],
|
||||
},
|
||||
{
|
||||
// For any Microsoft.Extensions.* and Microsoft.AspNetCore.* packages, we want to create PRs for patch updates.
|
||||
@@ -95,7 +84,6 @@
|
||||
"Serilog.AspNetCore",
|
||||
"Serilog.Extensions.Logging",
|
||||
"Serilog.Extensions.Logging.File",
|
||||
"Serilog.Sinks.AzureCosmosDB",
|
||||
"Serilog.Sinks.SyslogMessages",
|
||||
"Stripe.net",
|
||||
"Swashbuckle.AspNetCore",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: _move_finalization_db_scripts
|
||||
run-name: Move finalization database scripts
|
||||
name: _move_edd_db_scripts
|
||||
run-name: Move EDD database scripts
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
@@ -12,14 +12,20 @@ jobs:
|
||||
setup:
|
||||
name: Setup
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
outputs:
|
||||
migration_filename_prefix: ${{ steps.prefix.outputs.prefix }}
|
||||
copy_finalization_scripts: ${{ steps.check-finalization-scripts-existence.outputs.copy_finalization_scripts }}
|
||||
copy_edd_scripts: ${{ steps.check-script-existence.outputs.copy_edd_scripts }}
|
||||
|
||||
steps:
|
||||
- name: Log in to Azure
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
uses: bitwarden/gh-actions/azure-login@main
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
|
||||
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
client_id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
|
||||
- name: Retrieve secrets
|
||||
id: retrieve-secrets
|
||||
@@ -28,6 +34,9 @@ jobs:
|
||||
keyvault: "bitwarden-ci"
|
||||
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
|
||||
|
||||
- name: Log out from Azure
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- name: Check out branch
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
@@ -37,22 +46,27 @@ jobs:
|
||||
id: prefix
|
||||
run: echo "prefix=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Check if any files in DB finalization directory
|
||||
id: check-finalization-scripts-existence
|
||||
- name: Check if any files in DB transition or finalization directories
|
||||
id: check-script-existence
|
||||
run: |
|
||||
if [ -f util/Migrator/DbScripts_finalization/* ]; then
|
||||
echo "copy_finalization_scripts=true" >> $GITHUB_OUTPUT
|
||||
if [ -f util/Migrator/DbScripts_transition/* -o -f util/Migrator/DbScripts_finalization/* ]; then
|
||||
echo "copy_edd_scripts=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "copy_finalization_scripts=false" >> $GITHUB_OUTPUT
|
||||
echo "copy_edd_scripts=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
move-finalization-db-scripts:
|
||||
name: Move finalization database scripts
|
||||
move-scripts:
|
||||
name: Move scripts
|
||||
runs-on: ubuntu-22.04
|
||||
needs: setup
|
||||
if: ${{ needs.setup.outputs.copy_finalization_scripts == 'true' }}
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
actions: read
|
||||
if: ${{ needs.setup.outputs.copy_edd_scripts == 'true' }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
@@ -61,23 +75,26 @@ jobs:
|
||||
id: branch_name
|
||||
env:
|
||||
PREFIX: ${{ needs.setup.outputs.migration_filename_prefix }}
|
||||
run: echo "branch_name=move_finalization_db_scripts_$PREFIX" >> $GITHUB_OUTPUT
|
||||
run: echo "branch_name=move_edd_db_scripts_$PREFIX" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: "Create branch"
|
||||
env:
|
||||
BRANCH: ${{ steps.branch_name.outputs.branch_name }}
|
||||
run: git switch -c $BRANCH
|
||||
|
||||
- name: Move DbScripts_finalization
|
||||
- name: Move scripts and finalization database schema
|
||||
id: move-files
|
||||
env:
|
||||
PREFIX: ${{ needs.setup.outputs.migration_filename_prefix }}
|
||||
run: |
|
||||
src_dir="util/Migrator/DbScripts_finalization"
|
||||
# scripts
|
||||
moved_files="Migration scripts moved:\n\n"
|
||||
|
||||
src_dirs="util/Migrator/DbScripts_transition,util/Migrator/DbScripts_finalization"
|
||||
dest_dir="util/Migrator/DbScripts"
|
||||
i=0
|
||||
|
||||
moved_files=""
|
||||
for src_dir in ${src_dirs//,/ }; do
|
||||
for file in "$src_dir"/*; do
|
||||
filenumber=$(printf "%02d" $i)
|
||||
|
||||
@@ -85,17 +102,43 @@ jobs:
|
||||
new_filename="${PREFIX}_${filenumber}_${filename}"
|
||||
dest_file="$dest_dir/$new_filename"
|
||||
|
||||
# Replace any finalization references due to the move
|
||||
sed -i -e 's/dbo_finalization/dbo/g' "$file"
|
||||
|
||||
mv "$file" "$dest_file"
|
||||
moved_files="$moved_files \n $filename -> $new_filename"
|
||||
|
||||
i=$((i+1))
|
||||
done
|
||||
done
|
||||
|
||||
# schema
|
||||
moved_files="$moved_files\n\nFinalization scripts moved:\n\n"
|
||||
|
||||
src_dir="src/Sql/dbo_finalization"
|
||||
dest_dir="src/Sql/dbo"
|
||||
|
||||
# sync finalization schema back to dbo, maintaining structure
|
||||
rsync -r "$src_dir/" "$dest_dir/"
|
||||
rm -rf $src_dir/*
|
||||
|
||||
# Replace any finalization references due to the move
|
||||
find ./src/Sql/dbo -name "*.sql" -type f -exec sed -i \
|
||||
-e 's/\[dbo_finalization\]/[dbo]/g' \
|
||||
-e 's/dbo_finalization\./dbo./g' {} +
|
||||
|
||||
for file in "$src_dir"/**/*; do
|
||||
moved_files="$moved_files \n $file"
|
||||
done
|
||||
|
||||
echo "moved_files=$moved_files" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Log in to Azure - production subscription
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
- name: Log in to Azure
|
||||
uses: bitwarden/gh-actions/azure-login@main
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
|
||||
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
client_id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
|
||||
- name: Retrieve secrets
|
||||
id: retrieve-secrets
|
||||
@@ -106,8 +149,11 @@ jobs:
|
||||
github-gpg-private-key-passphrase,
|
||||
devops-alerts-slack-webhook-url"
|
||||
|
||||
- name: Log out from Azure
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- name: Import GPG keys
|
||||
uses: crazy-max/ghaction-import-gpg@cb9bde2e2525e640591a934b1fd28eef1dcaf5e5 # v6.2.0
|
||||
uses: crazy-max/ghaction-import-gpg@e89d40939c28e39f97cf32126055eeae86ba74ec # v6.3.0
|
||||
with:
|
||||
gpg_private_key: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key }}
|
||||
passphrase: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key-passphrase }}
|
||||
@@ -121,7 +167,7 @@ jobs:
|
||||
git config --local user.name "bitwarden-devops-bot"
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
git add .
|
||||
git commit -m "Move DbScripts_finalization to DbScripts" -a
|
||||
git commit -m "Move EDD database scripts" -a
|
||||
git push -u origin ${{ steps.branch_name.outputs.branch_name }}
|
||||
echo "pr_needed=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
@@ -137,16 +183,16 @@ jobs:
|
||||
BRANCH: ${{ steps.branch_name.outputs.branch_name }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
MOVED_FILES: ${{ steps.move-files.outputs.moved_files }}
|
||||
TITLE: "Move finalization database scripts"
|
||||
TITLE: "Move EDD database scripts"
|
||||
run: |
|
||||
PR_URL=$(gh pr create --title "$TITLE" \
|
||||
--base "main" \
|
||||
--head "$BRANCH" \
|
||||
--label "automated pr" \
|
||||
--body "
|
||||
## Automated movement of DbScripts_finalization to DbScripts
|
||||
Automated movement of EDD database scripts.
|
||||
|
||||
## Files moved:
|
||||
Files moved:
|
||||
$(echo -e "$MOVED_FILES")
|
||||
")
|
||||
echo "pr_url=${PR_URL}" >> $GITHUB_OUTPUT
|
||||
@@ -157,5 +203,5 @@ jobs:
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ steps.retrieve-secrets.outputs.devops-alerts-slack-webhook-url }}
|
||||
with:
|
||||
message: "Created PR for moving DbScripts_finalization to DbScripts: ${{ steps.create-pr.outputs.pr_url }}"
|
||||
message: "Created PR for moving EDD database scripts: ${{ steps.create-pr.outputs.pr_url }}"
|
||||
status: ${{ job.status }}
|
||||
151
.github/workflows/build.yml
vendored
151
.github/workflows/build.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0
|
||||
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
|
||||
|
||||
- name: Verify format
|
||||
run: dotnet format --verify-no-changes
|
||||
@@ -95,10 +95,8 @@ jobs:
|
||||
steps:
|
||||
- name: Check secrets
|
||||
id: check-secrets
|
||||
env:
|
||||
AZURE_KV_CI_SERVICE_PRINCIPAL: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
|
||||
run: |
|
||||
has_secrets=${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL != '' }}
|
||||
has_secrets=${{ secrets.AZURE_CLIENT_ID != '' }}
|
||||
echo "has_secrets=$has_secrets" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Check out repo
|
||||
@@ -119,10 +117,10 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0
|
||||
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
cache: "npm"
|
||||
cache-dependency-path: "**/package-lock.json"
|
||||
@@ -168,25 +166,22 @@ jobs:
|
||||
|
||||
########## Set up Docker ##########
|
||||
- name: Set up QEMU emulators
|
||||
uses: docker/setup-qemu-action@68827325e0b33c7199eb31dd4e31fbe9023e06e3 # v3.0.0
|
||||
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0
|
||||
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
|
||||
|
||||
########## ACRs ##########
|
||||
- name: Log in to Azure - production subscription
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
- name: Log in to Azure
|
||||
uses: bitwarden/gh-actions/azure-login@main
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
|
||||
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
client_id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
|
||||
- name: Log in to ACR - production subscription
|
||||
run: az acr login -n bitwardenprod
|
||||
|
||||
- name: Log in to Azure - CI subscription
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
|
||||
|
||||
- name: Retrieve GitHub PAT secrets
|
||||
id: retrieve-secret-pat
|
||||
uses: bitwarden/gh-actions/get-keyvault-secrets@main
|
||||
@@ -242,7 +237,7 @@ jobs:
|
||||
|
||||
- name: Build Docker image
|
||||
id: build-artifacts
|
||||
uses: docker/build-push-action@67a2d409c0a876cbe6b11854e3e25193efe4e62d # v6.12.0
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
context: .
|
||||
file: ${{ matrix.base_path }}/${{ matrix.project_name }}/Dockerfile
|
||||
@@ -257,7 +252,7 @@ jobs:
|
||||
|
||||
- name: Install Cosign
|
||||
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
||||
uses: sigstore/cosign-installer@dc72c7d5c4d10cd6bcb8cf6e3fd625a9e5e537da # v3.7.0
|
||||
uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3.8.2
|
||||
|
||||
- name: Sign image with Cosign
|
||||
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
||||
@@ -274,7 +269,7 @@ jobs:
|
||||
|
||||
- name: Scan Docker image
|
||||
id: container-scan
|
||||
uses: anchore/scan-action@abae793926ec39a78ab18002bc7fc45bbbd94342 # v6.0.0
|
||||
uses: anchore/scan-action@2c901ab7378897c01b8efaa2d0c9bf519cc64b9e # v6.2.0
|
||||
with:
|
||||
image: ${{ steps.image-tags.outputs.primary_tag }}
|
||||
fail-build: false
|
||||
@@ -287,10 +282,16 @@ jobs:
|
||||
sha: ${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.sha || github.sha }}
|
||||
ref: ${{ contains(github.event_name, 'pull_request') && format('refs/pull/{0}/head', github.event.pull_request.number) || github.ref }}
|
||||
|
||||
- name: Log out from Azure
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
upload:
|
||||
name: Upload
|
||||
runs-on: ubuntu-24.04
|
||||
needs: build-artifacts
|
||||
permissions:
|
||||
id-token: write
|
||||
actions: read
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
@@ -298,12 +299,14 @@ jobs:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0
|
||||
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
|
||||
|
||||
- name: Log in to Azure - production subscription
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
- name: Log in to Azure
|
||||
uses: bitwarden/gh-actions/azure-login@main
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
|
||||
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
client_id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
|
||||
- name: Log in to ACR - production subscription
|
||||
run: az acr login -n $_AZ_REGISTRY --only-show-errors
|
||||
@@ -350,6 +353,9 @@ jobs:
|
||||
cd docker-stub/US; zip -r ../../docker-stub-US.zip *; cd ../..
|
||||
cd docker-stub/EU; zip -r ../../docker-stub-EU.zip *; cd ../..
|
||||
|
||||
- name: Log out from Azure
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- name: Upload Docker stub US artifact
|
||||
if: |
|
||||
github.event_name != 'pull_request'
|
||||
@@ -370,62 +376,23 @@ jobs:
|
||||
path: docker-stub-EU.zip
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Build Public API Swagger
|
||||
- name: Build Swagger files
|
||||
run: |
|
||||
cd ./src/Api
|
||||
echo "Restore tools"
|
||||
dotnet tool restore
|
||||
echo "Publish"
|
||||
dotnet publish -c "Release" -o obj/build-output/publish
|
||||
|
||||
dotnet swagger tofile --output ../../swagger.json --host https://api.bitwarden.com \
|
||||
./obj/build-output/publish/Api.dll public
|
||||
cd ../..
|
||||
env:
|
||||
ASPNETCORE_ENVIRONMENT: Production
|
||||
swaggerGen: "True"
|
||||
DOTNET_ROLL_FORWARD_ON_NO_CANDIDATE_FX: 2
|
||||
GLOBALSETTINGS__SQLSERVER__CONNECTIONSTRING: "placeholder"
|
||||
cd ./dev
|
||||
pwsh ./generate_openapi_files.ps1
|
||||
|
||||
- name: Upload Public API Swagger artifact
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
with:
|
||||
name: swagger.json
|
||||
path: swagger.json
|
||||
path: api.public.json
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Build Internal API Swagger
|
||||
run: |
|
||||
cd ./src/Api
|
||||
echo "Restore API tools"
|
||||
dotnet tool restore
|
||||
echo "Publish API"
|
||||
dotnet publish -c "Release" -o obj/build-output/publish
|
||||
|
||||
dotnet swagger tofile --output ../../internal.json --host https://api.bitwarden.com \
|
||||
./obj/build-output/publish/Api.dll internal
|
||||
|
||||
cd ../Identity
|
||||
|
||||
echo "Restore Identity tools"
|
||||
dotnet tool restore
|
||||
echo "Publish Identity"
|
||||
dotnet publish -c "Release" -o obj/build-output/publish
|
||||
|
||||
dotnet swagger tofile --output ../../identity.json --host https://identity.bitwarden.com \
|
||||
./obj/build-output/publish/Identity.dll v1
|
||||
cd ../..
|
||||
env:
|
||||
ASPNETCORE_ENVIRONMENT: Development
|
||||
swaggerGen: "True"
|
||||
DOTNET_ROLL_FORWARD_ON_NO_CANDIDATE_FX: 2
|
||||
GLOBALSETTINGS__SQLSERVER__CONNECTIONSTRING: "placeholder"
|
||||
|
||||
- name: Upload Internal API Swagger artifact
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
with:
|
||||
name: internal.json
|
||||
path: internal.json
|
||||
path: api.json
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload Identity Swagger artifact
|
||||
@@ -458,7 +425,7 @@ jobs:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0
|
||||
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
|
||||
|
||||
- name: Print environment
|
||||
run: |
|
||||
@@ -496,11 +463,15 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- build-artifacts
|
||||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Log in to Azure - CI subscription
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
- name: Log in to Azure
|
||||
uses: bitwarden/gh-actions/azure-login@main
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
|
||||
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
client_id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
|
||||
- name: Retrieve GitHub PAT secrets
|
||||
id: retrieve-secret-pat
|
||||
@@ -509,8 +480,11 @@ jobs:
|
||||
keyvault: "bitwarden-ci"
|
||||
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
|
||||
|
||||
- name: Log out from Azure
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- name: Trigger self-host build
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
|
||||
script: |
|
||||
@@ -530,11 +504,15 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
needs:
|
||||
- build-artifacts
|
||||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Log in to Azure - CI subscription
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
- name: Log in to Azure
|
||||
uses: bitwarden/gh-actions/azure-login@main
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
|
||||
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
client_id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
|
||||
- name: Retrieve GitHub PAT secrets
|
||||
id: retrieve-secret-pat
|
||||
@@ -543,8 +521,11 @@ jobs:
|
||||
keyvault: "bitwarden-ci"
|
||||
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
|
||||
|
||||
- name: Log out from Azure
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- name: Trigger k8s deploy
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
|
||||
script: |
|
||||
@@ -572,7 +553,9 @@ jobs:
|
||||
project: server
|
||||
pull_request_number: ${{ github.event.number || 0 }}
|
||||
secrets: inherit
|
||||
permissions: read-all
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
check-failures:
|
||||
name: Check for failures
|
||||
@@ -585,6 +568,8 @@ jobs:
|
||||
- build-mssqlmigratorutility
|
||||
- self-host-build
|
||||
- trigger-k8s-deploy
|
||||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Check if any job failed
|
||||
if: |
|
||||
@@ -593,11 +578,12 @@ jobs:
|
||||
&& contains(needs.*.result, 'failure')
|
||||
run: exit 1
|
||||
|
||||
- name: Log in to Azure - CI subscription
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
if: failure()
|
||||
- name: Log in to Azure
|
||||
uses: bitwarden/gh-actions/azure-login@main
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
|
||||
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
client_id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
|
||||
- name: Retrieve secrets
|
||||
id: retrieve-secrets
|
||||
@@ -607,6 +593,9 @@ jobs:
|
||||
keyvault: "bitwarden-ci"
|
||||
secrets: "devops-alerts-slack-webhook-url"
|
||||
|
||||
- name: Log out from Azure
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- name: Notify Slack on failure
|
||||
uses: act10ns/slack@44541246747a30eb3102d87f7a4cc5471b0ffb7d # v2.1.0
|
||||
if: failure()
|
||||
|
||||
8
.github/workflows/build_target.yml
vendored
8
.github/workflows/build_target.yml
vendored
@@ -14,6 +14,8 @@ jobs:
|
||||
check-run:
|
||||
name: Check PR run
|
||||
uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
run-workflow:
|
||||
name: Run Build on PR Target
|
||||
@@ -21,3 +23,9 @@ jobs:
|
||||
if: ${{ github.event.pull_request.head.repo.full_name != github.repository }}
|
||||
uses: ./.github/workflows/build.yml
|
||||
secrets: inherit
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
id-token: write
|
||||
security-events: write
|
||||
|
||||
13
.github/workflows/cleanup-after-pr.yml
vendored
13
.github/workflows/cleanup-after-pr.yml
vendored
@@ -11,11 +11,15 @@ jobs:
|
||||
build-docker:
|
||||
name: Remove branch-specific Docker images
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Log in to Azure - production subscription
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
- name: Log in to Azure
|
||||
uses: bitwarden/gh-actions/azure-login@main
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
|
||||
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
client_id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
|
||||
- name: Log in to Azure ACR
|
||||
run: az acr login -n $_AZ_REGISTRY --only-show-errors
|
||||
@@ -62,3 +66,6 @@ jobs:
|
||||
|
||||
- name: Log out of Docker
|
||||
run: docker logout
|
||||
|
||||
- name: Log out from Azure
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
14
.github/workflows/cleanup-rc-branch.yml
vendored
14
.github/workflows/cleanup-rc-branch.yml
vendored
@@ -9,11 +9,16 @@ jobs:
|
||||
delete-rc:
|
||||
name: Delete RC Branch
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Login to Azure - CI Subscription
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
- name: Log in to Azure
|
||||
uses: bitwarden/gh-actions/azure-login@main
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
|
||||
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
client_id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
|
||||
- name: Retrieve bot secrets
|
||||
id: retrieve-bot-secrets
|
||||
@@ -22,6 +27,9 @@ jobs:
|
||||
keyvault: bitwarden-ci
|
||||
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
|
||||
|
||||
- name: Log out from Azure
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
|
||||
37
.github/workflows/code-references.yml
vendored
37
.github/workflows/code-references.yml
vendored
@@ -7,19 +7,18 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check-ld-secret:
|
||||
name: Check for LD secret
|
||||
check-secret-access:
|
||||
name: Check for secret access
|
||||
runs-on: ubuntu-22.04
|
||||
outputs:
|
||||
available: ${{ steps.check-ld-secret.outputs.available }}
|
||||
permissions:
|
||||
contents: read
|
||||
available: ${{ steps.check-secret-access.outputs.available }}
|
||||
permissions: {}
|
||||
|
||||
steps:
|
||||
- name: Check
|
||||
id: check-ld-secret
|
||||
id: check-secret-access
|
||||
run: |
|
||||
if [ "${{ secrets.LD_ACCESS_TOKEN }}" != '' ]; then
|
||||
if [ "${{ secrets.AZURE_CLIENT_ID }}" != '' ]; then
|
||||
echo "available=true" >> $GITHUB_OUTPUT;
|
||||
else
|
||||
echo "available=false" >> $GITHUB_OUTPUT;
|
||||
@@ -28,21 +27,39 @@ jobs:
|
||||
refs:
|
||||
name: Code reference collection
|
||||
runs-on: ubuntu-22.04
|
||||
needs: check-ld-secret
|
||||
if: ${{ needs.check-ld-secret.outputs.available == 'true' }}
|
||||
needs: check-secret-access
|
||||
if: ${{ needs.check-secret-access.outputs.available == 'true' }}
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Log in to Azure
|
||||
uses: bitwarden/gh-actions/azure-login@main
|
||||
with:
|
||||
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
client_id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
|
||||
- name: Get Azure Key Vault secrets
|
||||
id: get-kv-secrets
|
||||
uses: bitwarden/gh-actions/get-keyvault-secrets@main
|
||||
with:
|
||||
keyvault: gh-server
|
||||
secrets: "LD-ACCESS-TOKEN"
|
||||
|
||||
- name: Log out from Azure
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- name: Collect
|
||||
id: collect
|
||||
uses: launchdarkly/find-code-references@e3e9da201b87ada54eb4c550c14fb783385c5c8a # v2.13.0
|
||||
with:
|
||||
accessToken: ${{ secrets.LD_ACCESS_TOKEN }}
|
||||
accessToken: ${{ steps.get-kv-secrets.outputs.LD-ACCESS-TOKEN }}
|
||||
projKey: default
|
||||
allowTags: true
|
||||
|
||||
|
||||
4
.github/workflows/ephemeral-environment.yml
vendored
4
.github/workflows/ephemeral-environment.yml
vendored
@@ -4,6 +4,10 @@ on:
|
||||
pull_request:
|
||||
types: [labeled]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
setup-ephemeral-environment:
|
||||
name: Setup Ephemeral Environment
|
||||
|
||||
112
.github/workflows/load-test.yml
vendored
Normal file
112
.github/workflows/load-test.yml
vendored
Normal 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 }}
|
||||
19
.github/workflows/publish.yml
vendored
19
.github/workflows/publish.yml
vendored
@@ -26,6 +26,9 @@ jobs:
|
||||
setup:
|
||||
name: Setup
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: read
|
||||
deployments: write
|
||||
outputs:
|
||||
branch-name: ${{ steps.branch.outputs.branch-name }}
|
||||
deployment-id: ${{ steps.deployment.outputs.deployment_id }}
|
||||
@@ -63,6 +66,9 @@ jobs:
|
||||
name: Publish Docker images
|
||||
runs-on: ubuntu-22.04
|
||||
needs: setup
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
env:
|
||||
_RELEASE_VERSION: ${{ needs.setup.outputs.release-version }}
|
||||
_BRANCH_NAME: ${{ needs.setup.outputs.branch-name }}
|
||||
@@ -109,10 +115,12 @@ jobs:
|
||||
echo "project_name=$PROJECT_NAME" >> $GITHUB_OUTPUT
|
||||
|
||||
########## ACR PROD ##########
|
||||
- name: Log in to Azure - production subscription
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
- name: Log in to Azure
|
||||
uses: bitwarden/gh-actions/azure-login@main
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
|
||||
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
client_id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
|
||||
- name: Log in to Azure ACR
|
||||
run: az acr login -n $_AZ_REGISTRY --only-show-errors
|
||||
@@ -152,12 +160,17 @@ jobs:
|
||||
- name: Log out of Docker
|
||||
run: docker logout
|
||||
|
||||
- name: Log out from Azure
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
update-deployment:
|
||||
name: Update Deployment Status
|
||||
runs-on: ubuntu-22.04
|
||||
needs:
|
||||
- setup
|
||||
- publish-docker
|
||||
permissions:
|
||||
deployments: write
|
||||
if: ${{ always() && inputs.publish_type != 'Dry Run' }}
|
||||
steps:
|
||||
- name: Check if any job failed
|
||||
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -86,7 +86,7 @@ jobs:
|
||||
|
||||
- name: Create release
|
||||
if: ${{ inputs.release_type != 'Dry Run' }}
|
||||
uses: ncipollo/release-action@cdcc88a9acf3ca41c16c37bb7d21b9ad48560d87 # v1.15.0
|
||||
uses: ncipollo/release-action@440c8c1cb0ed28b9f43e4d1d670870f059653174 # v1.16.0
|
||||
with:
|
||||
artifacts: "docker-stub-US.zip,
|
||||
docker-stub-EU.zip,
|
||||
|
||||
67
.github/workflows/repository-management.yml
vendored
67
.github/workflows/repository-management.yml
vendored
@@ -22,7 +22,9 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions: {}
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
setup:
|
||||
@@ -54,7 +56,27 @@ jobs:
|
||||
- setup
|
||||
outputs:
|
||||
version: ${{ steps.set-final-version-output.outputs.version }}
|
||||
permissions:
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Log in to Azure
|
||||
uses: bitwarden/gh-actions/azure-login@main
|
||||
with:
|
||||
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
client_id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
|
||||
- name: Get Azure Key Vault secrets
|
||||
id: get-kv-secrets
|
||||
uses: bitwarden/gh-actions/get-keyvault-secrets@main
|
||||
with:
|
||||
keyvault: gh-org-bitwarden
|
||||
secrets: "BW-GHAPP-ID,BW-GHAPP-KEY"
|
||||
|
||||
- name: Log out from Azure
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- name: Validate version input format
|
||||
if: ${{ inputs.version_number_override != '' }}
|
||||
uses: bitwarden/gh-actions/version-check@main
|
||||
@@ -62,11 +84,11 @@ jobs:
|
||||
version: ${{ inputs.version_number_override }}
|
||||
|
||||
- name: Generate GH App token
|
||||
uses: actions/create-github-app-token@c1a285145b9d317df6ced56c09f525b5c2b6f755 # v1.11.1
|
||||
uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.BW_GHAPP_ID }}
|
||||
private-key: ${{ secrets.BW_GHAPP_KEY }}
|
||||
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
|
||||
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
|
||||
|
||||
- name: Check out branch
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
@@ -158,13 +180,33 @@ jobs:
|
||||
- setup
|
||||
- bump_version
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Log in to Azure
|
||||
uses: bitwarden/gh-actions/azure-login@main
|
||||
with:
|
||||
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
client_id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
|
||||
- name: Get Azure Key Vault secrets
|
||||
id: get-kv-secrets
|
||||
uses: bitwarden/gh-actions/get-keyvault-secrets@main
|
||||
with:
|
||||
keyvault: gh-org-bitwarden
|
||||
secrets: "BW-GHAPP-ID,BW-GHAPP-KEY"
|
||||
|
||||
- name: Log out from Azure
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- name: Generate GH App token
|
||||
uses: actions/create-github-app-token@c1a285145b9d317df6ced56c09f525b5c2b6f755 # v1.11.1
|
||||
uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.BW_GHAPP_ID }}
|
||||
private-key: ${{ secrets.BW_GHAPP_KEY }}
|
||||
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
|
||||
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
|
||||
|
||||
- name: Check out target ref
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
@@ -188,8 +230,13 @@ jobs:
|
||||
git switch --quiet --create $BRANCH_NAME
|
||||
git push --quiet --set-upstream origin $BRANCH_NAME
|
||||
|
||||
move_future_db_scripts:
|
||||
name: Move finalization database scripts
|
||||
move_edd_db_scripts:
|
||||
name: Move EDD database scripts
|
||||
needs: cut_branch
|
||||
uses: ./.github/workflows/_move_finalization_db_scripts.yml
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
id-token: write
|
||||
pull-requests: write
|
||||
uses: ./.github/workflows/_move_edd_db_scripts.yml
|
||||
secrets: inherit
|
||||
|
||||
109
.github/workflows/review-code.yml
vendored
Normal file
109
.github/workflows/review-code.yml
vendored
Normal 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:*)"
|
||||
81
.github/workflows/scan.yml
vendored
81
.github/workflows/scan.yml
vendored
@@ -16,83 +16,40 @@ on:
|
||||
branches:
|
||||
- "main"
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
check-run:
|
||||
name: Check PR run
|
||||
uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
sast:
|
||||
name: SAST scan
|
||||
runs-on: ubuntu-22.04
|
||||
name: Checkmarx
|
||||
uses: bitwarden/gh-actions/.github/workflows/_checkmarx.yml@main
|
||||
needs: check-run
|
||||
secrets:
|
||||
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
security-events: write
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Scan with Checkmarx
|
||||
uses: checkmarx/ast-github-action@184bf2f64f55d1c93fd6636d539edf274703e434 # 2.0.41
|
||||
env:
|
||||
INCREMENTAL: "${{ contains(github.event_name, 'pull_request') && '--sast-incremental' || '' }}"
|
||||
with:
|
||||
project_name: ${{ github.repository }}
|
||||
cx_tenant: ${{ secrets.CHECKMARX_TENANT }}
|
||||
base_uri: https://ast.checkmarx.net/
|
||||
cx_client_id: ${{ secrets.CHECKMARX_CLIENT_ID }}
|
||||
cx_client_secret: ${{ secrets.CHECKMARX_SECRET }}
|
||||
additional_params: |
|
||||
--report-format sarif \
|
||||
--filter "state=TO_VERIFY;PROPOSED_NOT_EXPLOITABLE;CONFIRMED;URGENT" \
|
||||
--output-path . ${{ env.INCREMENTAL }}
|
||||
|
||||
- name: Upload Checkmarx results to GitHub
|
||||
uses: github/codeql-action/upload-sarif@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3.28.8
|
||||
with:
|
||||
sarif_file: cx_result.sarif
|
||||
sha: ${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.sha || github.sha }}
|
||||
ref: ${{ contains(github.event_name, 'pull_request') && format('refs/pull/{0}/head', github.event.pull_request.number) || github.ref }}
|
||||
id-token: write
|
||||
|
||||
quality:
|
||||
name: Quality scan
|
||||
runs-on: ubuntu-22.04
|
||||
name: Sonar
|
||||
uses: bitwarden/gh-actions/.github/workflows/_sonar.yml@main
|
||||
needs: check-run
|
||||
secrets:
|
||||
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@7a6d8a8234af8eb26422e24e3006232cccaa061b # v4.6.0
|
||||
id-token: write
|
||||
with:
|
||||
java-version: 17
|
||||
distribution: "zulu"
|
||||
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0
|
||||
|
||||
- name: Install SonarCloud scanner
|
||||
run: dotnet tool install dotnet-sonarscanner -g
|
||||
|
||||
- name: Scan with SonarCloud
|
||||
env:
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
run: |
|
||||
dotnet-sonarscanner begin /k:"${{ github.repository_owner }}_${{ github.event.repository.name }}" \
|
||||
/d:sonar.test.inclusions=test/,bitwarden_license/test/ \
|
||||
/d:sonar.exclusions=test/,bitwarden_license/test/ \
|
||||
/o:"${{ github.repository_owner }}" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" \
|
||||
/d:sonar.host.url="https://sonarcloud.io" ${{ contains(github.event_name, 'pull_request') && format('/d:sonar.pullrequest.key={0}', github.event.pull_request.number) || '' }}
|
||||
dotnet build
|
||||
dotnet-sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}"
|
||||
sonar-config: "dotnet"
|
||||
|
||||
30
.github/workflows/test-database.yml
vendored
30
.github/workflows/test-database.yml
vendored
@@ -47,7 +47,7 @@ jobs:
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0
|
||||
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
|
||||
|
||||
- name: Restore tools
|
||||
run: dotnet tool restore
|
||||
@@ -154,7 +154,7 @@ jobs:
|
||||
run: 'docker logs $(docker ps --quiet --filter "name=mssql")'
|
||||
|
||||
- name: Report test results
|
||||
uses: dorny/test-reporter@6e6a65b7a0bd2c9197df7d0ae36ac5cee784230c # v2.0.0
|
||||
uses: dorny/test-reporter@890a17cecf52a379fc869ab770a71657660be727 # v2.1.0
|
||||
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !cancelled() }}
|
||||
with:
|
||||
name: Test Results
|
||||
@@ -163,7 +163,7 @@ jobs:
|
||||
fail-on-error: true
|
||||
|
||||
- name: Upload to codecov.io
|
||||
uses: codecov/codecov-action@1e68e06f1dbfde0e4cefc87efeba9e4643565303 # v5.1.2
|
||||
uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3
|
||||
|
||||
- name: Docker Compose down
|
||||
if: always()
|
||||
@@ -179,7 +179,7 @@ jobs:
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0
|
||||
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
|
||||
|
||||
- name: Print environment
|
||||
run: |
|
||||
@@ -229,11 +229,27 @@ jobs:
|
||||
- name: Validate XML
|
||||
run: |
|
||||
if grep -q "<Operations>" "report.xml"; then
|
||||
echo
|
||||
echo "Migration files are not in sync with the files in the Sql project. Review to make sure that any stored procedures / other db changes match with the stored procedures in the Sql project."
|
||||
echo "ERROR: Migration files are not in sync with the SQL project"
|
||||
echo ""
|
||||
echo "Check these locations:"
|
||||
echo " - Migration scripts: util/Migrator/DbScripts/"
|
||||
echo " - SQL project files: src/Sql/"
|
||||
echo " - Download 'report.xml' artifact for full details"
|
||||
echo ""
|
||||
|
||||
# Show actual SQL differences - exclude database setup commands
|
||||
if [ -s "diff.sql" ]; then
|
||||
echo "Key SQL differences:"
|
||||
# Show meaningful schema differences, filtering out database setup noise
|
||||
grep -E "^(CREATE|DROP|ALTER)" diff.sql | grep -v "ALTER DATABASE" | grep -v "DatabaseName" | head -5
|
||||
echo ""
|
||||
fi
|
||||
|
||||
echo "Common causes: naming differences (underscores, case), missing objects, or definition mismatches"
|
||||
|
||||
exit 1
|
||||
else
|
||||
echo "Report looks good"
|
||||
echo "SUCCESS: Database validation passed"
|
||||
fi
|
||||
shell: bash
|
||||
|
||||
|
||||
6
.github/workflows/test.yml
vendored
6
.github/workflows/test.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0
|
||||
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
|
||||
|
||||
- name: Print environment
|
||||
run: |
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
run: dotnet test ./bitwarden_license/test --configuration Debug --logger "trx;LogFileName=bw-test-results.trx" /p:CoverletOutputFormatter="cobertura" --collect:"XPlat Code Coverage"
|
||||
|
||||
- name: Report test results
|
||||
uses: dorny/test-reporter@6e6a65b7a0bd2c9197df7d0ae36ac5cee784230c # v2.0.0
|
||||
uses: dorny/test-reporter@890a17cecf52a379fc869ab770a71657660be727 # v2.1.0
|
||||
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !cancelled() }}
|
||||
with:
|
||||
name: Test Results
|
||||
@@ -58,4 +58,4 @@ jobs:
|
||||
fail-on-error: true
|
||||
|
||||
- name: Upload to codecov.io
|
||||
uses: codecov/codecov-action@1e68e06f1dbfde0e4cefc87efeba9e4643565303 # v5.1.2
|
||||
uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3
|
||||
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -214,6 +214,9 @@ bitwarden_license/src/Sso/wwwroot/assets
|
||||
.idea/*
|
||||
**/**.swp
|
||||
.mono
|
||||
src/Core/MailTemplates/Mjml/out
|
||||
NativeMethods.g.cs
|
||||
util/RustSdk/rust/target
|
||||
|
||||
src/Admin/Admin.zip
|
||||
src/Api/Api.zip
|
||||
@@ -225,5 +228,8 @@ src/Notifications/Notifications.zip
|
||||
bitwarden_license/src/Portal/Portal.zip
|
||||
bitwarden_license/src/Sso/Sso.zip
|
||||
**/src/**/flags.json
|
||||
NativeMethods.g.cs
|
||||
util/RustSdk/rust/target
|
||||
|
||||
# Generated swagger specs
|
||||
/identity.json
|
||||
/api.json
|
||||
/api.public.json
|
||||
|
||||
72
CLAUDE.md
Normal file
72
CLAUDE.md
Normal 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)
|
||||
@@ -3,59 +3,37 @@
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
|
||||
<Version>2025.6.2</Version>
|
||||
<Version>2025.10.0</Version>
|
||||
|
||||
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
|
||||
<!-- Treat it as a test project if the project hasn't set their own value and it follows our test project conventions -->
|
||||
|
||||
<IsTestProject Condition="'$(IsTestProject)' == '' and ($(MSBuildProjectName.EndsWith('.Test')) or $(MSBuildProjectName.EndsWith('.IntegrationTest')))">true</IsTestProject>
|
||||
<Nullable Condition="'$(Nullable)' == '' and '$(IsTestProject)' == 'true'">annotations</Nullable>
|
||||
<!-- Uncomment the below line when we are ready to enable nullable repo wide -->
|
||||
<!-- <Nullable Condition="'$(Nullable)' == '' and '$(IsTestProject)' != 'true'">enable</Nullable> -->
|
||||
<Nullable Condition="'$(Nullable)' == '' and '$(IsTestProject)' != 'true'">enable</Nullable>
|
||||
<TreatWarningsAsErrors Condition="'$(TreatWarningsAsErrors)' == ''">true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<!--
|
||||
This section is for packages that we use multiple times throughout the solution
|
||||
It gives us a single place to manage the version to ensure we are using the same version
|
||||
across the solution.
|
||||
-->
|
||||
|
||||
<PropertyGroup>
|
||||
<!--
|
||||
NuGet: https://www.nuget.org/packages/Microsoft.NET.Test.Sdk
|
||||
-->
|
||||
|
||||
<MicrosoftNetTestSdkVersion>17.8.0</MicrosoftNetTestSdkVersion>
|
||||
<!--
|
||||
NuGet: https://www.nuget.org/packages/xunit
|
||||
-->
|
||||
|
||||
<XUnitVersion>2.6.6</XUnitVersion>
|
||||
<!--
|
||||
NuGet: https://www.nuget.org/packages/xunit.runner.visualstudio
|
||||
-->
|
||||
|
||||
<XUnitRunnerVisualStudioVersion>2.5.6</XUnitRunnerVisualStudioVersion>
|
||||
<!--
|
||||
NuGet: https://www.nuget.org/packages/coverlet.collector
|
||||
-->
|
||||
|
||||
<CoverletCollectorVersion>6.0.0</CoverletCollectorVersion>
|
||||
<!--
|
||||
NuGet: https://www.nuget.org/packages/NSubstitute
|
||||
-->
|
||||
|
||||
<NSubstituteVersion>5.1.0</NSubstituteVersion>
|
||||
<!--
|
||||
NuGet: https://www.nuget.org/packages/AutoFixture.Xunit2
|
||||
-->
|
||||
|
||||
<AutoFixtureXUnit2Version>4.18.1</AutoFixtureXUnit2Version>
|
||||
<!--
|
||||
NuGet: https://www.nuget.org/packages/AutoFixture.AutoNSubstitute
|
||||
-->
|
||||
|
||||
<AutoFixtureAutoNSubstituteVersion>4.18.1</AutoFixtureAutoNSubstituteVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<!--
|
||||
This section is for getting & setting the gitHash value, which can easily be accessed
|
||||
via the Core.Utilities.AssemblyHelpers class.
|
||||
-->
|
||||
|
||||
<Target Name="SetSourceRevisionId" BeforeTargets="CoreGenerateAssemblyInfo">
|
||||
<Exec Command="git describe --long --always --dirty --exclude=* --abbrev=8" ConsoleToMSBuild="True" IgnoreExitCode="False">
|
||||
<Output PropertyName="SourceRevisionId" TaskParameter="ConsoleOutput" />
|
||||
|
||||
@@ -134,6 +134,7 @@ EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DbSeederUtility", "util\DbSeederUtility\DbSeederUtility.csproj", "{17A89266-260A-4A03-81AE-C0468C6EE06E}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RustSdk", "util\RustSdk\RustSdk.csproj", "{D1513D90-E4F5-44A9-9121-5E46E3E4A3F7}"
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharedWeb.Test", "test\SharedWeb.Test\SharedWeb.Test.csproj", "{AD59537D-5259-4B7A-948F-0CF58E80B359}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
@@ -343,6 +344,10 @@ Global
|
||||
{D1513D90-E4F5-44A9-9121-5E46E3E4A3F7}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{D1513D90-E4F5-44A9-9121-5E46E3E4A3F7}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{D1513D90-E4F5-44A9-9121-5E46E3E4A3F7}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{AD59537D-5259-4B7A-948F-0CF58E80B359}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{AD59537D-5259-4B7A-948F-0CF58E80B359}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{AD59537D-5259-4B7A-948F-0CF58E80B359}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{AD59537D-5259-4B7A-948F-0CF58E80B359}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@@ -398,6 +403,7 @@ Global
|
||||
{9A612EBA-1C0E-42B8-982B-62F0EE81000A} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E}
|
||||
{17A89266-260A-4A03-81AE-C0468C6EE06E} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E}
|
||||
{D1513D90-E4F5-44A9-9121-5E46E3E4A3F7} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E}
|
||||
{AD59537D-5259-4B7A-948F-0CF58E80B359} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {E01CBF68-2E20-425F-9EDB-E0A6510CA92F}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using Bit.Core;
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
@@ -134,25 +136,13 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
|
||||
Items = [new SubscriptionItemOptions { Price = plan.PasswordManager.StripeSeatPlanId, Quantity = organization.Seats }]
|
||||
};
|
||||
|
||||
var setNonUSBusinessUseToReverseCharge = _featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge);
|
||||
|
||||
if (setNonUSBusinessUseToReverseCharge)
|
||||
{
|
||||
subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true };
|
||||
}
|
||||
else if (customer.HasRecognizedTaxLocation())
|
||||
{
|
||||
subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions
|
||||
{
|
||||
Enabled = customer.Address.Country == "US" ||
|
||||
customer.TaxIds.Any()
|
||||
};
|
||||
}
|
||||
|
||||
var subscription = await _stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
|
||||
|
||||
organization.GatewaySubscriptionId = subscription.Id;
|
||||
organization.Status = OrganizationStatusType.Created;
|
||||
organization.Enabled = true;
|
||||
|
||||
await _providerBillingService.ScaleSeats(provider, organization.PlanType, -organization.Seats ?? 0);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
@@ -9,7 +12,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Organizations;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Billing.Payment.Models;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Providers.Services;
|
||||
using Bit.Core.Context;
|
||||
@@ -87,7 +90,7 @@ public class ProviderService : IProviderService
|
||||
_providerClientOrganizationSignUpCommand = providerClientOrganizationSignUpCommand;
|
||||
}
|
||||
|
||||
public async Task<Provider> CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TaxInfo taxInfo, TokenizedPaymentSource tokenizedPaymentSource = null)
|
||||
public async Task<Provider> CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TokenizedPaymentMethod paymentMethod, BillingAddress billingAddress)
|
||||
{
|
||||
var owner = await _userService.GetUserByIdAsync(ownerUserId);
|
||||
if (owner == null)
|
||||
@@ -112,24 +115,7 @@ public class ProviderService : IProviderService
|
||||
throw new BadRequestException("Invalid owner.");
|
||||
}
|
||||
|
||||
if (taxInfo == null || string.IsNullOrEmpty(taxInfo.BillingAddressCountry) || string.IsNullOrEmpty(taxInfo.BillingAddressPostalCode))
|
||||
{
|
||||
throw new BadRequestException("Both address and postal code are required to set up your provider.");
|
||||
}
|
||||
|
||||
var requireProviderPaymentMethodDuringSetup =
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup);
|
||||
|
||||
if (requireProviderPaymentMethodDuringSetup && tokenizedPaymentSource is not
|
||||
{
|
||||
Type: PaymentMethodType.BankAccount or PaymentMethodType.Card or PaymentMethodType.PayPal,
|
||||
Token: not null and not ""
|
||||
})
|
||||
{
|
||||
throw new BadRequestException("A payment method is required to set up your provider.");
|
||||
}
|
||||
|
||||
var customer = await _providerBillingService.SetupCustomer(provider, taxInfo, tokenizedPaymentSource);
|
||||
var customer = await _providerBillingService.SetupCustomer(provider, paymentMethod, billingAddress);
|
||||
provider.GatewayCustomerId = customer.Id;
|
||||
var subscription = await _providerBillingService.SetupSubscription(provider);
|
||||
provider.GatewaySubscriptionId = subscription.Id;
|
||||
@@ -149,7 +135,15 @@ public class ProviderService : IProviderService
|
||||
throw new ArgumentException("Cannot create provider this way.");
|
||||
}
|
||||
|
||||
var existingProvider = await _providerRepository.GetByIdAsync(provider.Id);
|
||||
var enabledStatusChanged = existingProvider != null && existingProvider.Enabled != provider.Enabled;
|
||||
|
||||
await _providerRepository.ReplaceAsync(provider);
|
||||
|
||||
if (enabledStatusChanged && (provider.Type == ProviderType.Msp || provider.Type == ProviderType.BusinessUnit))
|
||||
{
|
||||
await UpdateClientOrganizationsEnabledStatusAsync(provider.Id, provider.Enabled);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<ProviderUser>> InviteUserAsync(ProviderUserInvite<string> invite)
|
||||
@@ -725,4 +719,20 @@ public class ProviderService : IProviderService
|
||||
throw new BadRequestException($"Unsupported provider type {providerType}.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UpdateClientOrganizationsEnabledStatusAsync(Guid providerId, bool enabled)
|
||||
{
|
||||
var providerOrganizations = await _providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId);
|
||||
|
||||
foreach (var providerOrganization in providerOrganizations)
|
||||
{
|
||||
var organization = await _organizationRepository.GetByIdAsync(providerOrganization.OrganizationId);
|
||||
if (organization != null && organization.Enabled != enabled)
|
||||
{
|
||||
organization.Enabled = enabled;
|
||||
await _organizationRepository.ReplaceAsync(organization);
|
||||
await _applicationCacheService.UpsertOrganizationAbilityAsync(organization);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using System.Globalization;
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using System.Globalization;
|
||||
using Bit.Core.Billing.Providers.Entities;
|
||||
using CsvHelper.Configuration.Attributes;
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
using System.Globalization;
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using System.Globalization;
|
||||
using Bit.Commercial.Core.Billing.Providers.Models;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
@@ -11,6 +14,7 @@ using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Billing.Payment.Models;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Providers.Entities;
|
||||
using Bit.Core.Billing.Providers.Models;
|
||||
@@ -18,10 +22,8 @@ using Bit.Core.Billing.Providers.Repositories;
|
||||
using Bit.Core.Billing.Providers.Services;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Billing.Tax.Models;
|
||||
using Bit.Core.Billing.Tax.Services;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Business;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
@@ -35,10 +37,12 @@ using Subscription = Stripe.Subscription;
|
||||
|
||||
namespace Bit.Commercial.Core.Billing.Providers.Services;
|
||||
|
||||
using static Constants;
|
||||
using static StripeConstants;
|
||||
|
||||
public class ProviderBillingService(
|
||||
IBraintreeGateway braintreeGateway,
|
||||
IEventService eventService,
|
||||
IFeatureService featureService,
|
||||
IGlobalSettings globalSettings,
|
||||
ILogger<ProviderBillingService> logger,
|
||||
IOrganizationRepository organizationRepository,
|
||||
@@ -49,8 +53,7 @@ public class ProviderBillingService(
|
||||
IProviderUserRepository providerUserRepository,
|
||||
ISetupIntentCache setupIntentCache,
|
||||
IStripeAdapter stripeAdapter,
|
||||
ISubscriberService subscriberService,
|
||||
ITaxService taxService)
|
||||
ISubscriberService subscriberService)
|
||||
: IProviderBillingService
|
||||
{
|
||||
public async Task AddExistingOrganization(
|
||||
@@ -59,10 +62,7 @@ public class ProviderBillingService(
|
||||
string key)
|
||||
{
|
||||
await stripeAdapter.SubscriptionUpdateAsync(organization.GatewaySubscriptionId,
|
||||
new SubscriptionUpdateOptions
|
||||
{
|
||||
CancelAtPeriodEnd = false
|
||||
});
|
||||
new SubscriptionUpdateOptions { CancelAtPeriodEnd = false });
|
||||
|
||||
var subscription =
|
||||
await stripeAdapter.SubscriptionCancelAsync(organization.GatewaySubscriptionId,
|
||||
@@ -81,7 +81,7 @@ public class ProviderBillingService(
|
||||
|
||||
var wasTrialing = subscription.TrialEnd.HasValue && subscription.TrialEnd.Value > now;
|
||||
|
||||
if (!wasTrialing && subscription.LatestInvoice.Status == StripeConstants.InvoiceStatus.Draft)
|
||||
if (!wasTrialing && subscription.LatestInvoice.Status == InvoiceStatus.Draft)
|
||||
{
|
||||
await stripeAdapter.InvoiceFinalizeInvoiceAsync(subscription.LatestInvoiceId,
|
||||
new InvoiceFinalizeOptions { AutoAdvance = true });
|
||||
@@ -182,16 +182,8 @@ public class ProviderBillingService(
|
||||
{
|
||||
Items =
|
||||
[
|
||||
new SubscriptionItemOptions
|
||||
{
|
||||
Price = newPriceId,
|
||||
Quantity = oldSubscriptionItem!.Quantity
|
||||
},
|
||||
new SubscriptionItemOptions
|
||||
{
|
||||
Id = oldSubscriptionItem.Id,
|
||||
Deleted = true
|
||||
}
|
||||
new SubscriptionItemOptions { Price = newPriceId, Quantity = oldSubscriptionItem!.Quantity },
|
||||
new SubscriptionItemOptions { Id = oldSubscriptionItem.Id, Deleted = true }
|
||||
]
|
||||
};
|
||||
|
||||
@@ -200,7 +192,8 @@ public class ProviderBillingService(
|
||||
// Refactor later to ?ChangeClientPlanCommand? (ProviderPlanId, ProviderId, OrganizationId)
|
||||
// 1. Retrieve PlanType and PlanName for ProviderPlan
|
||||
// 2. Assign PlanType & PlanName to Organization
|
||||
var providerOrganizations = await providerOrganizationRepository.GetManyDetailsByProviderAsync(providerPlan.ProviderId);
|
||||
var providerOrganizations =
|
||||
await providerOrganizationRepository.GetManyDetailsByProviderAsync(providerPlan.ProviderId);
|
||||
|
||||
var newPlan = await pricingClient.GetPlanOrThrow(newPlanType);
|
||||
|
||||
@@ -211,6 +204,7 @@ public class ProviderBillingService(
|
||||
{
|
||||
throw new ConflictException($"Organization '{providerOrganization.Id}' not found.");
|
||||
}
|
||||
|
||||
organization.PlanType = newPlanType;
|
||||
organization.Plan = newPlan.Name;
|
||||
await organizationRepository.ReplaceAsync(organization);
|
||||
@@ -226,15 +220,15 @@ public class ProviderBillingService(
|
||||
|
||||
if (!string.IsNullOrEmpty(organization.GatewayCustomerId))
|
||||
{
|
||||
logger.LogWarning("Client organization ({ID}) already has a populated {FieldName}", organization.Id, nameof(organization.GatewayCustomerId));
|
||||
logger.LogWarning("Client organization ({ID}) already has a populated {FieldName}", organization.Id,
|
||||
nameof(organization.GatewayCustomerId));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var providerCustomer = await subscriberService.GetCustomerOrThrow(provider, new CustomerGetOptions
|
||||
{
|
||||
Expand = ["tax", "tax_ids"]
|
||||
});
|
||||
var providerCustomer =
|
||||
await subscriberService.GetCustomerOrThrow(provider,
|
||||
new CustomerGetOptions { Expand = ["tax", "tax_ids"] });
|
||||
|
||||
var providerTaxId = providerCustomer.TaxIds.FirstOrDefault();
|
||||
|
||||
@@ -267,25 +261,18 @@ public class ProviderBillingService(
|
||||
}
|
||||
]
|
||||
},
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
{ "region", globalSettings.BaseServiceUri.CloudRegion }
|
||||
},
|
||||
TaxIdData = providerTaxId == null ? null :
|
||||
Metadata = new Dictionary<string, string> { { "region", globalSettings.BaseServiceUri.CloudRegion } },
|
||||
TaxIdData = providerTaxId == null
|
||||
? null
|
||||
:
|
||||
[
|
||||
new CustomerTaxIdDataOptions
|
||||
{
|
||||
Type = providerTaxId.Type,
|
||||
Value = providerTaxId.Value
|
||||
}
|
||||
new CustomerTaxIdDataOptions { Type = providerTaxId.Type, Value = providerTaxId.Value }
|
||||
]
|
||||
};
|
||||
|
||||
var setNonUSBusinessUseToReverseCharge = featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge);
|
||||
|
||||
if (setNonUSBusinessUseToReverseCharge && providerCustomer.Address is not { Country: "US" })
|
||||
if (providerCustomer.Address is not { Country: CountryAbbreviations.UnitedStates })
|
||||
{
|
||||
customerCreateOptions.TaxExempt = StripeConstants.TaxExempt.Reverse;
|
||||
customerCreateOptions.TaxExempt = TaxExempt.Reverse;
|
||||
}
|
||||
|
||||
var customer = await stripeAdapter.CustomerCreateAsync(customerCreateOptions);
|
||||
@@ -347,9 +334,9 @@ public class ProviderBillingService(
|
||||
.Where(pair => pair.subscription is
|
||||
{
|
||||
Status:
|
||||
StripeConstants.SubscriptionStatus.Active or
|
||||
StripeConstants.SubscriptionStatus.Trialing or
|
||||
StripeConstants.SubscriptionStatus.PastDue
|
||||
SubscriptionStatus.Active or
|
||||
SubscriptionStatus.Trialing or
|
||||
SubscriptionStatus.PastDue
|
||||
}).ToList();
|
||||
|
||||
if (active.Count == 0)
|
||||
@@ -474,35 +461,27 @@ public class ProviderBillingService(
|
||||
// Below the limit to above the limit
|
||||
(currentlyAssignedSeatTotal <= seatMinimum && newlyAssignedSeatTotal > seatMinimum) ||
|
||||
// Above the limit to further above the limit
|
||||
(currentlyAssignedSeatTotal > seatMinimum && newlyAssignedSeatTotal > seatMinimum && newlyAssignedSeatTotal > currentlyAssignedSeatTotal);
|
||||
(currentlyAssignedSeatTotal > seatMinimum && newlyAssignedSeatTotal > seatMinimum &&
|
||||
newlyAssignedSeatTotal > currentlyAssignedSeatTotal);
|
||||
}
|
||||
|
||||
public async Task<Customer> SetupCustomer(
|
||||
Provider provider,
|
||||
TaxInfo taxInfo,
|
||||
TokenizedPaymentSource tokenizedPaymentSource = null)
|
||||
TokenizedPaymentMethod paymentMethod,
|
||||
BillingAddress billingAddress)
|
||||
{
|
||||
if (taxInfo is not
|
||||
{
|
||||
BillingAddressCountry: not null and not "",
|
||||
BillingAddressPostalCode: not null and not ""
|
||||
})
|
||||
{
|
||||
logger.LogError("Cannot create customer for provider ({ProviderID}) without both a country and postal code", provider.Id);
|
||||
throw new BillingException();
|
||||
}
|
||||
|
||||
var options = new CustomerCreateOptions
|
||||
{
|
||||
Address = new AddressOptions
|
||||
{
|
||||
Country = taxInfo.BillingAddressCountry,
|
||||
PostalCode = taxInfo.BillingAddressPostalCode,
|
||||
Line1 = taxInfo.BillingAddressLine1,
|
||||
Line2 = taxInfo.BillingAddressLine2,
|
||||
City = taxInfo.BillingAddressCity,
|
||||
State = taxInfo.BillingAddressState
|
||||
Country = billingAddress.Country,
|
||||
PostalCode = billingAddress.PostalCode,
|
||||
Line1 = billingAddress.Line1,
|
||||
Line2 = billingAddress.Line2,
|
||||
City = billingAddress.City,
|
||||
State = billingAddress.State
|
||||
},
|
||||
Coupon = !string.IsNullOrEmpty(provider.DiscountId) ? provider.DiscountId : null,
|
||||
Description = provider.DisplayBusinessName(),
|
||||
Email = provider.BillingEmail,
|
||||
InvoiceSettings = new CustomerInvoiceSettingsOptions
|
||||
@@ -518,112 +497,71 @@ public class ProviderBillingService(
|
||||
}
|
||||
]
|
||||
},
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
{ "region", globalSettings.BaseServiceUri.CloudRegion }
|
||||
}
|
||||
Metadata = new Dictionary<string, string> { { "region", globalSettings.BaseServiceUri.CloudRegion } },
|
||||
TaxExempt = billingAddress.Country != CountryAbbreviations.UnitedStates ? TaxExempt.Reverse : TaxExempt.None
|
||||
};
|
||||
|
||||
var setNonUSBusinessUseToReverseCharge = featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge);
|
||||
|
||||
if (setNonUSBusinessUseToReverseCharge && taxInfo.BillingAddressCountry != "US")
|
||||
if (billingAddress.TaxId != null)
|
||||
{
|
||||
options.TaxExempt = StripeConstants.TaxExempt.Reverse;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(taxInfo.TaxIdNumber))
|
||||
{
|
||||
var taxIdType = taxService.GetStripeTaxCode(
|
||||
taxInfo.BillingAddressCountry,
|
||||
taxInfo.TaxIdNumber);
|
||||
|
||||
if (taxIdType == null)
|
||||
{
|
||||
logger.LogWarning("Could not infer tax ID type in country '{Country}' with tax ID '{TaxID}'.",
|
||||
taxInfo.BillingAddressCountry,
|
||||
taxInfo.TaxIdNumber);
|
||||
|
||||
throw new BadRequestException("billingTaxIdTypeInferenceError");
|
||||
}
|
||||
|
||||
options.TaxIdData =
|
||||
[
|
||||
new CustomerTaxIdDataOptions { Type = taxIdType, Value = taxInfo.TaxIdNumber }
|
||||
new CustomerTaxIdDataOptions { Type = billingAddress.TaxId.Code, Value = billingAddress.TaxId.Value }
|
||||
];
|
||||
|
||||
if (taxIdType == StripeConstants.TaxIdType.SpanishNIF)
|
||||
if (billingAddress.TaxId.Code == TaxIdType.SpanishNIF)
|
||||
{
|
||||
options.TaxIdData.Add(new CustomerTaxIdDataOptions
|
||||
{
|
||||
Type = StripeConstants.TaxIdType.EUVAT,
|
||||
Value = $"ES{taxInfo.TaxIdNumber}"
|
||||
Type = TaxIdType.EUVAT,
|
||||
Value = $"ES{billingAddress.TaxId.Value}"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(provider.DiscountId))
|
||||
{
|
||||
options.Coupon = provider.DiscountId;
|
||||
}
|
||||
|
||||
var requireProviderPaymentMethodDuringSetup =
|
||||
featureService.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup);
|
||||
|
||||
var braintreeCustomerId = "";
|
||||
|
||||
if (requireProviderPaymentMethodDuringSetup)
|
||||
{
|
||||
if (tokenizedPaymentSource is not
|
||||
{
|
||||
Type: PaymentMethodType.BankAccount or PaymentMethodType.Card or PaymentMethodType.PayPal,
|
||||
Token: not null and not ""
|
||||
})
|
||||
{
|
||||
logger.LogError("Cannot create customer for provider ({ProviderID}) without a payment method", provider.Id);
|
||||
throw new BillingException();
|
||||
}
|
||||
|
||||
var (type, token) = tokenizedPaymentSource;
|
||||
|
||||
// ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault
|
||||
switch (type)
|
||||
switch (paymentMethod.Type)
|
||||
{
|
||||
case PaymentMethodType.BankAccount:
|
||||
case TokenizablePaymentMethodType.BankAccount:
|
||||
{
|
||||
var setupIntent =
|
||||
(await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = token }))
|
||||
(await stripeAdapter.SetupIntentList(new SetupIntentListOptions
|
||||
{
|
||||
PaymentMethod = paymentMethod.Token
|
||||
}))
|
||||
.FirstOrDefault();
|
||||
|
||||
if (setupIntent == null)
|
||||
{
|
||||
logger.LogError("Cannot create customer for provider ({ProviderID}) without a setup intent for their bank account", provider.Id);
|
||||
logger.LogError(
|
||||
"Cannot create customer for provider ({ProviderID}) without a setup intent for their bank account",
|
||||
provider.Id);
|
||||
throw new BillingException();
|
||||
}
|
||||
|
||||
await setupIntentCache.Set(provider.Id, setupIntent.Id);
|
||||
break;
|
||||
}
|
||||
case PaymentMethodType.Card:
|
||||
case TokenizablePaymentMethodType.Card:
|
||||
{
|
||||
options.PaymentMethod = token;
|
||||
options.InvoiceSettings.DefaultPaymentMethod = token;
|
||||
options.PaymentMethod = paymentMethod.Token;
|
||||
options.InvoiceSettings.DefaultPaymentMethod = paymentMethod.Token;
|
||||
break;
|
||||
}
|
||||
case PaymentMethodType.PayPal:
|
||||
case TokenizablePaymentMethodType.PayPal:
|
||||
{
|
||||
braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(provider, token);
|
||||
braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(provider, paymentMethod.Token);
|
||||
options.Metadata[BraintreeCustomerIdKey] = braintreeCustomerId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await stripeAdapter.CustomerCreateAsync(options);
|
||||
}
|
||||
catch (StripeException stripeException) when (stripeException.StripeError?.Code ==
|
||||
StripeConstants.ErrorCodes.TaxIdInvalid)
|
||||
catch (StripeException stripeException) when (stripeException.StripeError?.Code == ErrorCodes.TaxIdInvalid)
|
||||
{
|
||||
await Revert();
|
||||
throw new BadRequestException(
|
||||
@@ -636,21 +574,19 @@ public class ProviderBillingService(
|
||||
}
|
||||
|
||||
async Task Revert()
|
||||
{
|
||||
if (requireProviderPaymentMethodDuringSetup && tokenizedPaymentSource != null)
|
||||
{
|
||||
// ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault
|
||||
switch (tokenizedPaymentSource.Type)
|
||||
switch (paymentMethod.Type)
|
||||
{
|
||||
case PaymentMethodType.BankAccount:
|
||||
case TokenizablePaymentMethodType.BankAccount:
|
||||
{
|
||||
var setupIntentId = await setupIntentCache.Get(provider.Id);
|
||||
var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(provider.Id);
|
||||
await stripeAdapter.SetupIntentCancel(setupIntentId,
|
||||
new SetupIntentCancelOptions { CancellationReason = "abandoned" });
|
||||
await setupIntentCache.Remove(provider.Id);
|
||||
await setupIntentCache.RemoveSetupIntentForSubscriber(provider.Id);
|
||||
break;
|
||||
}
|
||||
case PaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId):
|
||||
case TokenizablePaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId):
|
||||
{
|
||||
await braintreeGateway.Customer.DeleteAsync(braintreeCustomerId);
|
||||
break;
|
||||
@@ -658,7 +594,6 @@ public class ProviderBillingService(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Subscription> SetupSubscription(
|
||||
Provider provider)
|
||||
@@ -670,9 +605,10 @@ public class ProviderBillingService(
|
||||
|
||||
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
|
||||
|
||||
if (providerPlans == null || providerPlans.Count == 0)
|
||||
if (providerPlans.Count == 0)
|
||||
{
|
||||
logger.LogError("Cannot start subscription for provider ({ProviderID}) that has no configured plans", provider.Id);
|
||||
logger.LogError("Cannot start subscription for provider ({ProviderID}) that has no configured plans",
|
||||
provider.Id);
|
||||
|
||||
throw new BillingException();
|
||||
}
|
||||
@@ -685,7 +621,9 @@ public class ProviderBillingService(
|
||||
|
||||
if (!providerPlan.IsConfigured())
|
||||
{
|
||||
logger.LogError("Cannot start subscription for provider ({ProviderID}) that has no configured {ProviderName} plan", provider.Id, plan.Name);
|
||||
logger.LogError(
|
||||
"Cannot start subscription for provider ({ProviderID}) that has no configured {ProviderName} plan",
|
||||
provider.Id, plan.Name);
|
||||
throw new BillingException();
|
||||
}
|
||||
|
||||
@@ -698,23 +636,17 @@ public class ProviderBillingService(
|
||||
});
|
||||
}
|
||||
|
||||
var requireProviderPaymentMethodDuringSetup =
|
||||
featureService.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup);
|
||||
|
||||
var setupIntentId = await setupIntentCache.Get(provider.Id);
|
||||
var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(provider.Id);
|
||||
|
||||
var setupIntent = !string.IsNullOrEmpty(setupIntentId)
|
||||
? await stripeAdapter.SetupIntentGet(setupIntentId, new SetupIntentGetOptions
|
||||
{
|
||||
Expand = ["payment_method"]
|
||||
})
|
||||
? await stripeAdapter.SetupIntentGet(setupIntentId,
|
||||
new SetupIntentGetOptions { Expand = ["payment_method"] })
|
||||
: null;
|
||||
|
||||
var usePaymentMethod =
|
||||
requireProviderPaymentMethodDuringSetup &&
|
||||
(!string.IsNullOrEmpty(customer.InvoiceSettings.DefaultPaymentMethodId) ||
|
||||
customer.Metadata.ContainsKey(BraintreeCustomerIdKey) ||
|
||||
setupIntent.IsUnverifiedBankAccount());
|
||||
!string.IsNullOrEmpty(customer.InvoiceSettings?.DefaultPaymentMethodId) ||
|
||||
customer.Metadata?.ContainsKey(BraintreeCustomerIdKey) == true ||
|
||||
setupIntent?.IsUnverifiedBankAccount() == true;
|
||||
|
||||
int? trialPeriodDays = provider.Type switch
|
||||
{
|
||||
@@ -725,35 +657,20 @@ public class ProviderBillingService(
|
||||
|
||||
var subscriptionCreateOptions = new SubscriptionCreateOptions
|
||||
{
|
||||
CollectionMethod = usePaymentMethod ?
|
||||
StripeConstants.CollectionMethod.ChargeAutomatically : StripeConstants.CollectionMethod.SendInvoice,
|
||||
CollectionMethod =
|
||||
usePaymentMethod
|
||||
? CollectionMethod.ChargeAutomatically
|
||||
: CollectionMethod.SendInvoice,
|
||||
Customer = customer.Id,
|
||||
DaysUntilDue = usePaymentMethod ? null : 30,
|
||||
Items = subscriptionItemOptionsList,
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
{ "providerId", provider.Id.ToString() }
|
||||
},
|
||||
Metadata = new Dictionary<string, string> { { "providerId", provider.Id.ToString() } },
|
||||
OffSession = true,
|
||||
ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations,
|
||||
TrialPeriodDays = trialPeriodDays
|
||||
ProrationBehavior = ProrationBehavior.CreateProrations,
|
||||
TrialPeriodDays = trialPeriodDays,
|
||||
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
|
||||
};
|
||||
|
||||
var setNonUSBusinessUseToReverseCharge =
|
||||
featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge);
|
||||
|
||||
if (setNonUSBusinessUseToReverseCharge)
|
||||
{
|
||||
subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true };
|
||||
}
|
||||
else if (customer.HasRecognizedTaxLocation())
|
||||
{
|
||||
subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions
|
||||
{
|
||||
Enabled = customer.Address.Country == "US" ||
|
||||
customer.TaxIds.Any()
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
@@ -761,7 +678,7 @@ public class ProviderBillingService(
|
||||
|
||||
if (subscription is
|
||||
{
|
||||
Status: StripeConstants.SubscriptionStatus.Active or StripeConstants.SubscriptionStatus.Trialing
|
||||
Status: SubscriptionStatus.Active or SubscriptionStatus.Trialing
|
||||
})
|
||||
{
|
||||
return subscription;
|
||||
@@ -775,9 +692,11 @@ public class ProviderBillingService(
|
||||
|
||||
throw new BillingException();
|
||||
}
|
||||
catch (StripeException stripeException) when (stripeException.StripeError?.Code == StripeConstants.ErrorCodes.CustomerTaxLocationInvalid)
|
||||
catch (StripeException stripeException) when (stripeException.StripeError?.Code ==
|
||||
ErrorCodes.CustomerTaxLocationInvalid)
|
||||
{
|
||||
throw new BadRequestException("Your location wasn't recognized. Please ensure your country and postal code are valid.");
|
||||
throw new BadRequestException(
|
||||
"Your location wasn't recognized. Please ensure your country and postal code are valid.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -791,7 +710,7 @@ public class ProviderBillingService(
|
||||
subscriberService.UpdateTaxInformation(provider, taxInformation));
|
||||
|
||||
await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId,
|
||||
new SubscriptionUpdateOptions { CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically });
|
||||
new SubscriptionUpdateOptions { CollectionMethod = CollectionMethod.ChargeAutomatically });
|
||||
}
|
||||
|
||||
public async Task UpdateSeatMinimums(UpdateProviderSeatMinimumsCommand command)
|
||||
@@ -891,13 +810,9 @@ public class ProviderBillingService(
|
||||
|
||||
await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId, new SubscriptionUpdateOptions
|
||||
{
|
||||
Items = [
|
||||
new SubscriptionItemOptions
|
||||
{
|
||||
Id = item.Id,
|
||||
Price = priceId,
|
||||
Quantity = newlySubscribedSeats
|
||||
}
|
||||
Items =
|
||||
[
|
||||
new SubscriptionItemOptions { Id = item.Id, Price = priceId, Quantity = newlySubscribedSeats }
|
||||
]
|
||||
});
|
||||
|
||||
@@ -920,7 +835,8 @@ public class ProviderBillingService(
|
||||
var plan = await pricingClient.GetPlanOrThrow(planType);
|
||||
|
||||
return providerOrganizations
|
||||
.Where(providerOrganization => providerOrganization.Plan == plan.Name && providerOrganization.Status == OrganizationStatusType.Managed)
|
||||
.Where(providerOrganization => providerOrganization.Plan == plan.Name &&
|
||||
providerOrganization.Status == OrganizationStatusType.Managed)
|
||||
.Sum(providerOrganization => providerOrganization.Seats ?? 0);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CsvHelper" Version="33.0.1" />
|
||||
<PackageReference Include="CsvHelper" Version="33.1.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using Bit.Core.SecretsManager.Commands.Porting;
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using Bit.Core.SecretsManager.Commands.Porting;
|
||||
using Bit.Core.SecretsManager.Commands.Porting.Interfaces;
|
||||
using Bit.Core.SecretsManager.Entities;
|
||||
using Bit.Core.SecretsManager.Repositories;
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
using Bit.Core.Context;
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using Bit.Core.Auth.Identity;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Identity;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.SecretsManager.Commands.Projects.Interfaces;
|
||||
using Bit.Core.SecretsManager.Entities;
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
using Bit.Core.Repositories;
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.SecretsManager.Commands.ServiceAccounts.Interfaces;
|
||||
using Bit.Core.SecretsManager.Entities;
|
||||
using Bit.Core.SecretsManager.Repositories;
|
||||
using Bit.Core.Services;
|
||||
|
||||
namespace Bit.Commercial.Core.SecretsManager.Commands.ServiceAccounts;
|
||||
|
||||
@@ -10,15 +16,21 @@ public class CreateServiceAccountCommand : ICreateServiceAccountCommand
|
||||
private readonly IAccessPolicyRepository _accessPolicyRepository;
|
||||
private readonly IOrganizationUserRepository _organizationUserRepository;
|
||||
private readonly IServiceAccountRepository _serviceAccountRepository;
|
||||
private readonly IEventService _eventService;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
|
||||
public CreateServiceAccountCommand(
|
||||
IAccessPolicyRepository accessPolicyRepository,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IServiceAccountRepository serviceAccountRepository)
|
||||
IServiceAccountRepository serviceAccountRepository,
|
||||
IEventService eventService,
|
||||
ICurrentContext currentContext)
|
||||
{
|
||||
_accessPolicyRepository = accessPolicyRepository;
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
_serviceAccountRepository = serviceAccountRepository;
|
||||
_eventService = eventService;
|
||||
_currentContext = currentContext;
|
||||
}
|
||||
|
||||
public async Task<ServiceAccount> CreateAsync(ServiceAccount serviceAccount, Guid userId)
|
||||
@@ -35,6 +47,7 @@ public class CreateServiceAccountCommand : ICreateServiceAccountCommand
|
||||
Write = true,
|
||||
};
|
||||
await _accessPolicyRepository.CreateManyAsync(new List<BaseAccessPolicy> { accessPolicy });
|
||||
await _eventService.LogServiceAccountPeopleEventAsync(user.Id, accessPolicy, EventType.ServiceAccount_UserAdded, _currentContext.IdentityClientType);
|
||||
return createdServiceAccount;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using System.Security.Claims;
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using System.Security.Claims;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.SecretsManager.Queries.Interfaces;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
using Bit.Commercial.Core.AdminConsole.Providers;
|
||||
using Bit.Commercial.Core.AdminConsole.Services;
|
||||
using Bit.Commercial.Core.Billing.Providers.Queries;
|
||||
using Bit.Commercial.Core.Billing.Providers.Services;
|
||||
using Bit.Core.AdminConsole.Providers.Interfaces;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Billing.Providers.Queries;
|
||||
using Bit.Core.Billing.Providers.Services;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
@@ -17,5 +19,6 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<IRemoveOrganizationFromProviderCommand, RemoveOrganizationFromProviderCommand>();
|
||||
services.AddTransient<IProviderBillingService, ProviderBillingService>();
|
||||
services.AddTransient<IBusinessUnitConverter, BusinessUnitConverter>();
|
||||
services.AddTransient<IGetProviderWarningsQuery, GetProviderWarningsQuery>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,10 @@ public class ProjectRepository : Repository<Core.SecretsManager.Entities.Project
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<ProjectPermissionDetails>> GetManyByOrganizationIdAsync(Guid organizationId, Guid userId, AccessClientType accessType)
|
||||
public async Task<IEnumerable<ProjectPermissionDetails>> GetManyByOrganizationIdAsync(
|
||||
Guid organizationId,
|
||||
Guid userId,
|
||||
AccessClientType accessType)
|
||||
{
|
||||
using var scope = ServiceScopeFactory.CreateScope();
|
||||
var dbContext = GetDatabaseContext(scope);
|
||||
|
||||
@@ -45,6 +45,19 @@ public class SecretRepository : Repository<Core.SecretsManager.Entities.Secret,
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Core.SecretsManager.Entities.Secret>> GetManyTrashedSecretsByIds(IEnumerable<Guid> ids)
|
||||
{
|
||||
using (var scope = ServiceScopeFactory.CreateScope())
|
||||
{
|
||||
var dbContext = GetDatabaseContext(scope);
|
||||
var secrets = await dbContext.Secret
|
||||
.Where(c => ids.Contains(c.Id) && c.DeletedDate != null)
|
||||
.Include(c => c.Projects)
|
||||
.ToListAsync();
|
||||
return Mapper.Map<List<Core.SecretsManager.Entities.Secret>>(secrets);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Core.SecretsManager.Entities.Secret>> GetManyByOrganizationIdAsync(
|
||||
Guid organizationId, Guid userId, AccessClientType accessType)
|
||||
{
|
||||
@@ -66,10 +79,14 @@ public class SecretRepository : Repository<Core.SecretsManager.Entities.Secret,
|
||||
return Mapper.Map<List<Core.SecretsManager.Entities.Secret>>(secrets);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<SecretPermissionDetails>> GetManyDetailsByOrganizationIdAsync(Guid organizationId, Guid userId, AccessClientType accessType)
|
||||
public async Task<IEnumerable<SecretPermissionDetails>> GetManyDetailsByOrganizationIdAsync(
|
||||
Guid organizationId,
|
||||
Guid userId,
|
||||
AccessClientType accessType)
|
||||
{
|
||||
using var scope = ServiceScopeFactory.CreateScope();
|
||||
var dbContext = GetDatabaseContext(scope);
|
||||
|
||||
var query = dbContext.Secret
|
||||
.Include(c => c.Projects)
|
||||
.Where(c => c.OrganizationId == organizationId && c.DeletedDate == null)
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.Models.OrganizationConnectionConfigs;
|
||||
using Bit.Core.Enums;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces;
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Scim.Models;
|
||||
using Bit.Scim.Users.Interfaces;
|
||||
using Bit.Scim.Utilities;
|
||||
@@ -19,29 +21,28 @@ namespace Bit.Scim.Controllers.v2;
|
||||
public class UsersController : Controller
|
||||
{
|
||||
private readonly IOrganizationUserRepository _organizationUserRepository;
|
||||
private readonly IOrganizationService _organizationService;
|
||||
private readonly IGetUsersListQuery _getUsersListQuery;
|
||||
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
|
||||
private readonly IPatchUserCommand _patchUserCommand;
|
||||
private readonly IPostUserCommand _postUserCommand;
|
||||
private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand;
|
||||
private readonly IRevokeOrganizationUserCommand _revokeOrganizationUserCommand;
|
||||
|
||||
public UsersController(
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IOrganizationService organizationService,
|
||||
public UsersController(IOrganizationUserRepository organizationUserRepository,
|
||||
IGetUsersListQuery getUsersListQuery,
|
||||
IRemoveOrganizationUserCommand removeOrganizationUserCommand,
|
||||
IPatchUserCommand patchUserCommand,
|
||||
IPostUserCommand postUserCommand,
|
||||
IRestoreOrganizationUserCommand restoreOrganizationUserCommand)
|
||||
IRestoreOrganizationUserCommand restoreOrganizationUserCommand,
|
||||
IRevokeOrganizationUserCommand revokeOrganizationUserCommand)
|
||||
{
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
_organizationService = organizationService;
|
||||
_getUsersListQuery = getUsersListQuery;
|
||||
_removeOrganizationUserCommand = removeOrganizationUserCommand;
|
||||
_patchUserCommand = patchUserCommand;
|
||||
_postUserCommand = postUserCommand;
|
||||
_restoreOrganizationUserCommand = restoreOrganizationUserCommand;
|
||||
_revokeOrganizationUserCommand = revokeOrganizationUserCommand;
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
@@ -98,7 +99,7 @@ public class UsersController : Controller
|
||||
}
|
||||
else if (!model.Active && orgUser.Status != OrganizationUserStatusType.Revoked)
|
||||
{
|
||||
await _organizationService.RevokeUserAsync(orgUser, EventSystemUser.SCIM);
|
||||
await _revokeOrganizationUserCommand.RevokeUserAsync(orgUser, EventSystemUser.SCIM);
|
||||
}
|
||||
|
||||
// Have to get full details object for response model
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
###############################################
|
||||
# Build stage #
|
||||
###############################################
|
||||
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
||||
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0-alpine3.21 AS build
|
||||
|
||||
# Docker buildx supplies the value for this arg
|
||||
ARG TARGETPLATFORM
|
||||
@@ -9,11 +9,11 @@ ARG TARGETPLATFORM
|
||||
# Determine proper runtime value for .NET
|
||||
# We put the value in a file to be read by later layers.
|
||||
RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \
|
||||
RID=linux-x64 ; \
|
||||
RID=linux-musl-x64 ; \
|
||||
elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \
|
||||
RID=linux-arm64 ; \
|
||||
RID=linux-musl-arm64 ; \
|
||||
elif [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then \
|
||||
RID=linux-arm ; \
|
||||
RID=linux-musl-arm ; \
|
||||
fi \
|
||||
&& echo "RID=$RID" > /tmp/rid.txt
|
||||
|
||||
@@ -37,21 +37,21 @@ RUN . /tmp/rid.txt && dotnet publish \
|
||||
###############################################
|
||||
# App stage #
|
||||
###############################################
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:8.0
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine3.21
|
||||
|
||||
ARG TARGETPLATFORM
|
||||
LABEL com.bitwarden.product="bitwarden"
|
||||
ENV ASPNETCORE_ENVIRONMENT=Production
|
||||
ENV ASPNETCORE_URLS=http://+:5000
|
||||
ENV SSL_CERT_DIR=/etc/bitwarden/ca-certificates
|
||||
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
|
||||
EXPOSE 5000
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
gosu \
|
||||
curl \
|
||||
krb5-user \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN apk add --no-cache curl \
|
||||
krb5 \
|
||||
icu-libs \
|
||||
shadow \
|
||||
&& apk add --no-cache --repository=http://dl-cdn.alpinelinux.org/alpine/edge/community gosu
|
||||
|
||||
# Copy app from the build stage
|
||||
WORKDIR /app
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Scim.Groups.Interfaces;
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using System.Text.Json;
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using System.Text.Json;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Scim.Models;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.Models.Business;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Business;
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Core.Utilities;
|
||||
using OrganizationUserInvite = Bit.Core.Models.Business.OrganizationUserInvite;
|
||||
|
||||
namespace Bit.Scim.Models;
|
||||
|
||||
@@ -44,7 +47,7 @@ public class ScimUserRequestModel : BaseScimUserModel
|
||||
return new InviteOrganizationUsersRequest(
|
||||
invites:
|
||||
[
|
||||
new Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models.OrganizationUserInvite(
|
||||
new OrganizationUserInviteCommandModel(
|
||||
email: email,
|
||||
externalId: ExternalIdForInvite()
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ using Bit.Core.Utilities;
|
||||
using Bit.Scim.Context;
|
||||
using Bit.Scim.Utilities;
|
||||
using Bit.SharedWeb.Utilities;
|
||||
using IdentityModel;
|
||||
using Duende.IdentityModel;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Stripe;
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Scim.Users.Interfaces;
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||
using Bit.Scim.Models;
|
||||
|
||||
namespace Bit.Scim.Users.Interfaces;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Scim.Models;
|
||||
using Bit.Scim.Users.Interfaces;
|
||||
|
||||
@@ -11,20 +11,19 @@ namespace Bit.Scim.Users;
|
||||
public class PatchUserCommand : IPatchUserCommand
|
||||
{
|
||||
private readonly IOrganizationUserRepository _organizationUserRepository;
|
||||
private readonly IOrganizationService _organizationService;
|
||||
private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand;
|
||||
private readonly ILogger<PatchUserCommand> _logger;
|
||||
private readonly IRevokeOrganizationUserCommand _revokeOrganizationUserCommand;
|
||||
|
||||
public PatchUserCommand(
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IOrganizationService organizationService,
|
||||
public PatchUserCommand(IOrganizationUserRepository organizationUserRepository,
|
||||
IRestoreOrganizationUserCommand restoreOrganizationUserCommand,
|
||||
ILogger<PatchUserCommand> logger)
|
||||
ILogger<PatchUserCommand> logger,
|
||||
IRevokeOrganizationUserCommand revokeOrganizationUserCommand)
|
||||
{
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
_organizationService = organizationService;
|
||||
_restoreOrganizationUserCommand = restoreOrganizationUserCommand;
|
||||
_logger = logger;
|
||||
_revokeOrganizationUserCommand = revokeOrganizationUserCommand;
|
||||
}
|
||||
|
||||
public async Task PatchUserAsync(Guid organizationId, Guid id, ScimPatchModel model)
|
||||
@@ -80,7 +79,7 @@ public class PatchUserCommand : IPatchUserCommand
|
||||
}
|
||||
else if (!active && orgUser.Status != OrganizationUserStatusType.Revoked)
|
||||
{
|
||||
await _organizationService.RevokeUserAsync(orgUser, EventSystemUser.SCIM);
|
||||
await _revokeOrganizationUserCommand.RevokeUserAsync(orgUser, EventSystemUser.SCIM);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
||||
@@ -3,7 +3,7 @@ using System.Text.Encodings.Web;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Scim.Context;
|
||||
using IdentityModel;
|
||||
using Duende.IdentityModel;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
#!/bin/sh
|
||||
|
||||
# Setup
|
||||
|
||||
@@ -37,7 +37,7 @@ then
|
||||
mkdir -p /etc/bitwarden/ca-certificates
|
||||
chown -R $USERNAME:$GROUPNAME /etc/bitwarden
|
||||
|
||||
if [[ -f "/etc/bitwarden/kerberos/bitwarden.keytab" && -f "/etc/bitwarden/kerberos/krb5.conf" ]]; then
|
||||
if [ -f "/etc/bitwarden/kerberos/bitwarden.keytab" ] && [ -f "/etc/bitwarden/kerberos/krb5.conf" ]; then
|
||||
chown -R $USERNAME:$GROUPNAME /etc/bitwarden/kerberos
|
||||
fi
|
||||
|
||||
@@ -46,13 +46,13 @@ else
|
||||
gosu_cmd=""
|
||||
fi
|
||||
|
||||
if [[ -f "/etc/bitwarden/kerberos/bitwarden.keytab" && -f "/etc/bitwarden/kerberos/krb5.conf" ]]; then
|
||||
if [ -f "/etc/bitwarden/kerberos/bitwarden.keytab" ] && [ -f "/etc/bitwarden/kerberos/krb5.conf" ]; then
|
||||
cp -f /etc/bitwarden/kerberos/krb5.conf /etc/krb5.conf
|
||||
$gosu_cmd kinit $globalSettings__kerberosUser -k -t /etc/bitwarden/kerberos/bitwarden.keytab
|
||||
fi
|
||||
|
||||
if [[ $globalSettings__selfHosted == "true" ]]; then
|
||||
if [[ -z $globalSettings__identityServer__certificateLocation ]]; then
|
||||
if [ "$globalSettings__selfHosted" = "true" ]; then
|
||||
if [ -z "$globalSettings__identityServer__certificateLocation" ]; then
|
||||
export globalSettings__identityServer__certificateLocation=/etc/bitwarden/identity/identity.pfx
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
using System.Security.Claims;
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using System.Security.Claims;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Auth.Entities;
|
||||
@@ -19,10 +23,10 @@ using Bit.Core.Tokens;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Sso.Models;
|
||||
using Bit.Sso.Utilities;
|
||||
using Duende.IdentityModel;
|
||||
using Duende.IdentityServer;
|
||||
using Duende.IdentityServer.Services;
|
||||
using Duende.IdentityServer.Stores;
|
||||
using IdentityModel;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -104,36 +108,32 @@ public class AccountController : Controller
|
||||
// Validate domain_hint provided
|
||||
if (string.IsNullOrWhiteSpace(domainHint))
|
||||
{
|
||||
return InvalidJson("NoOrganizationIdentifierProvidedError");
|
||||
_logger.LogError(new ArgumentException("domainHint is required."), "domainHint not specified.");
|
||||
return InvalidJson("SsoInvalidIdentifierError");
|
||||
}
|
||||
|
||||
// Validate organization exists from domain_hint
|
||||
var organization = await _organizationRepository.GetByIdentifierAsync(domainHint);
|
||||
if (organization == null)
|
||||
if (organization is not { UseSso: true })
|
||||
{
|
||||
return InvalidJson("OrganizationNotFoundByIdentifierError");
|
||||
}
|
||||
if (!organization.UseSso)
|
||||
{
|
||||
return InvalidJson("SsoNotAllowedForOrganizationError");
|
||||
_logger.LogError("Organization not configured to use SSO.");
|
||||
return InvalidJson("SsoInvalidIdentifierError");
|
||||
}
|
||||
|
||||
// Validate SsoConfig exists and is Enabled
|
||||
var ssoConfig = await _ssoConfigRepository.GetByIdentifierAsync(domainHint);
|
||||
if (ssoConfig == null)
|
||||
if (ssoConfig is not { Enabled: true })
|
||||
{
|
||||
return InvalidJson("SsoConfigurationNotFoundForOrganizationError");
|
||||
}
|
||||
if (!ssoConfig.Enabled)
|
||||
{
|
||||
return InvalidJson("SsoNotEnabledForOrganizationError");
|
||||
_logger.LogError("SsoConfig not enabled.");
|
||||
return InvalidJson("SsoInvalidIdentifierError");
|
||||
}
|
||||
|
||||
// Validate Authentication Scheme exists and is loaded (cache)
|
||||
var scheme = await _schemeProvider.GetSchemeAsync(organization.Id.ToString());
|
||||
if (scheme == null || !(scheme is IDynamicAuthenticationScheme dynamicScheme))
|
||||
if (scheme is not IDynamicAuthenticationScheme dynamicScheme)
|
||||
{
|
||||
return InvalidJson("NoSchemeOrHandlerForSsoConfigurationFoundError");
|
||||
_logger.LogError("Invalid authentication scheme for organization.");
|
||||
return InvalidJson("SsoInvalidIdentifierError");
|
||||
}
|
||||
|
||||
// Run scheme validation
|
||||
@@ -143,13 +143,8 @@ public class AccountController : Controller
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var translatedException = _i18nService.GetLocalizedHtmlString(ex.Message);
|
||||
var errorKey = "InvalidSchemeConfigurationError";
|
||||
if (!translatedException.ResourceNotFound)
|
||||
{
|
||||
errorKey = ex.Message;
|
||||
}
|
||||
return InvalidJson(errorKey, translatedException.ResourceNotFound ? ex : null);
|
||||
_logger.LogError(ex, "An error occurred while validating SSO dynamic scheme.");
|
||||
return InvalidJson("SsoInvalidIdentifierError");
|
||||
}
|
||||
|
||||
var tokenable = new SsoTokenable(organization, _globalSettings.Sso.SsoTokenLifetimeInSeconds);
|
||||
@@ -159,7 +154,8 @@ public class AccountController : Controller
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return InvalidJson("PreValidationError", ex);
|
||||
_logger.LogError(ex, "An error occurred during SSO prevalidation.");
|
||||
return InvalidJson("SsoInvalidIdentifierError");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,18 +247,23 @@ public class AccountController : Controller
|
||||
var externalClaims = result.Principal.Claims.Select(c => $"{c.Type}: {c.Value}");
|
||||
_logger.LogDebug("External claims: {@claims}", externalClaims);
|
||||
|
||||
// Lookup our user and external provider info
|
||||
// See if the user has logged in with this SSO provider before and has already been provisioned.
|
||||
// This is signified by the user existing in the User table and the SSOUser table for the SSO provider they're using.
|
||||
var (user, provider, providerUserId, claims, ssoConfigData) = await FindUserFromExternalProviderAsync(result);
|
||||
|
||||
// The user has not authenticated with this SSO provider before.
|
||||
// They could have an existing Bitwarden account in the User table though.
|
||||
if (user == null)
|
||||
{
|
||||
// This might be where you might initiate a custom workflow for user registration
|
||||
// in this sample we don't show how that would be done, as our sample implementation
|
||||
// simply auto-provisions new external user
|
||||
// If we're manually linking to SSO, the user's external identifier will be passed as query string parameter.
|
||||
var userIdentifier = result.Properties.Items.Keys.Contains("user_identifier") ?
|
||||
result.Properties.Items["user_identifier"] : null;
|
||||
user = await AutoProvisionUserAsync(provider, providerUserId, claims, userIdentifier, ssoConfigData);
|
||||
}
|
||||
|
||||
// Either the user already authenticated with the SSO provider, or we've just provisioned them.
|
||||
// Either way, we have associated the SSO login with a Bitwarden user.
|
||||
// We will now sign the Bitwarden user in.
|
||||
if (user != null)
|
||||
{
|
||||
// This allows us to collect any additional claims or properties
|
||||
@@ -342,6 +343,10 @@ public class AccountController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to map the external identity to a Bitwarden user, through the SsoUser table, which holds the `externalId`.
|
||||
/// The claims on the external identity are used to determine an `externalId`, and that is used to find the appropriate `SsoUser` and `User` records.
|
||||
/// </summary>
|
||||
private async Task<(User user, string provider, string providerUserId, IEnumerable<Claim> claims, SsoConfigurationData config)>
|
||||
FindUserFromExternalProviderAsync(AuthenticateResult result)
|
||||
{
|
||||
@@ -399,6 +404,23 @@ public class AccountController : Controller
|
||||
return (user, provider, providerUserId, claims, ssoConfigData);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provision an SSO-linked Bitwarden user.
|
||||
/// This handles three different scenarios:
|
||||
/// 1. Creating an SsoUser link for an existing User and OrganizationUser
|
||||
/// - User is a member of the organization, but hasn't authenticated with the org's SSO provider before.
|
||||
/// 2. Creating a new User and a new OrganizationUser, then establishing an SsoUser link
|
||||
/// - User is joining the organization through JIT provisioning, without a pending invitation
|
||||
/// 3. Creating a new User for an existing OrganizationUser (created by invitation), then establishing an SsoUser link
|
||||
/// - User is signing in with a pending invitation.
|
||||
/// </summary>
|
||||
/// <param name="provider">The external identity provider.</param>
|
||||
/// <param name="providerUserId">The external identity provider's user identifier.</param>
|
||||
/// <param name="claims">The claims from the external IdP.</param>
|
||||
/// <param name="userIdentifier">The user identifier used for manual SSO linking.</param>
|
||||
/// <param name="config">The SSO configuration for the organization.</param>
|
||||
/// <returns>The User to sign in.</returns>
|
||||
/// <exception cref="Exception">An exception if the user cannot be provisioned as requested.</exception>
|
||||
private async Task<User> AutoProvisionUserAsync(string provider, string providerUserId,
|
||||
IEnumerable<Claim> claims, string userIdentifier, SsoConfigurationData config)
|
||||
{
|
||||
@@ -426,50 +448,15 @@ public class AccountController : Controller
|
||||
}
|
||||
else
|
||||
{
|
||||
var split = userIdentifier.Split(",");
|
||||
if (split.Length < 2)
|
||||
{
|
||||
throw new Exception(_i18nService.T("InvalidUserIdentifier"));
|
||||
}
|
||||
var userId = split[0];
|
||||
var token = split[1];
|
||||
|
||||
var tokenOptions = new TokenOptions();
|
||||
|
||||
var claimedUser = await _userService.GetUserByIdAsync(userId);
|
||||
if (claimedUser != null)
|
||||
{
|
||||
var tokenIsValid = await _userManager.VerifyUserTokenAsync(
|
||||
claimedUser, tokenOptions.PasswordResetTokenProvider, TokenPurposes.LinkSso, token);
|
||||
if (tokenIsValid)
|
||||
{
|
||||
existingUser = claimedUser;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new Exception(_i18nService.T("UserIdAndTokenMismatch"));
|
||||
}
|
||||
}
|
||||
existingUser = await GetUserFromManualLinkingData(userIdentifier);
|
||||
}
|
||||
|
||||
OrganizationUser orgUser = null;
|
||||
var organization = await _organizationRepository.GetByIdAsync(orgId);
|
||||
if (organization == null)
|
||||
{
|
||||
throw new Exception(_i18nService.T("CouldNotFindOrganization", orgId));
|
||||
}
|
||||
// Try to find the OrganizationUser if it exists.
|
||||
var (organization, orgUser) = await FindOrganizationUser(existingUser, email, orgId);
|
||||
|
||||
// Try to find OrgUser via existing User Id (accepted/confirmed user)
|
||||
if (existingUser != null)
|
||||
{
|
||||
var orgUsersByUserId = await _organizationUserRepository.GetManyByUserAsync(existingUser.Id);
|
||||
orgUser = orgUsersByUserId.SingleOrDefault(u => u.OrganizationId == orgId);
|
||||
}
|
||||
|
||||
// If no Org User found by Existing User Id - search all organization users via email
|
||||
orgUser ??= await _organizationUserRepository.GetByOrganizationEmailAsync(orgId, email);
|
||||
|
||||
// All Existing User flows handled below
|
||||
//----------------------------------------------------
|
||||
// Scenario 1: We've found the user in the User table
|
||||
//----------------------------------------------------
|
||||
if (existingUser != null)
|
||||
{
|
||||
if (existingUser.UsesKeyConnector &&
|
||||
@@ -478,20 +465,22 @@ public class AccountController : Controller
|
||||
throw new Exception(_i18nService.T("UserAlreadyExistsKeyConnector"));
|
||||
}
|
||||
|
||||
// If the user already exists in Bitwarden, we require that the user already be in the org,
|
||||
// and that they are either Accepted or Confirmed.
|
||||
if (orgUser == null)
|
||||
{
|
||||
// Org User is not created - no invite has been sent
|
||||
throw new Exception(_i18nService.T("UserAlreadyExistsInviteProcess"));
|
||||
}
|
||||
|
||||
if (orgUser.Status == OrganizationUserStatusType.Invited)
|
||||
{
|
||||
// Org User is invited - they must manually accept the invite via email and authenticate with MP
|
||||
// This allows us to enroll them in MP reset if required
|
||||
throw new Exception(_i18nService.T("AcceptInviteBeforeUsingSSO", organization.DisplayName()));
|
||||
}
|
||||
EnsureOrgUserStatusAllowed(orgUser.Status, organization.DisplayName(),
|
||||
allowedStatuses: [OrganizationUserStatusType.Accepted, OrganizationUserStatusType.Confirmed]);
|
||||
|
||||
// Accepted or Confirmed - create SSO link and return;
|
||||
|
||||
// Since we're in the auto-provisioning logic, this means that the user exists, but they have not
|
||||
// authenticated with the org's SSO provider before now (otherwise we wouldn't be auto-provisioning them).
|
||||
// We've verified that the user is Accepted or Confnirmed, so we can create an SsoUser link and proceed
|
||||
// with authentication.
|
||||
await CreateSsoUserRecord(providerUserId, existingUser.Id, orgId, orgUser);
|
||||
return existingUser;
|
||||
}
|
||||
@@ -534,7 +523,9 @@ public class AccountController : Controller
|
||||
emailVerified = organizationDomain?.VerifiedDate.HasValue ?? false;
|
||||
}
|
||||
|
||||
// Create user record - all existing user flows are handled above
|
||||
//--------------------------------------------------
|
||||
// Scenarios 2 and 3: We need to register a new user
|
||||
//--------------------------------------------------
|
||||
var user = new User
|
||||
{
|
||||
Name = name,
|
||||
@@ -560,7 +551,11 @@ public class AccountController : Controller
|
||||
await _userService.UpdateTwoFactorProviderAsync(user, TwoFactorProviderType.Email);
|
||||
}
|
||||
|
||||
// Create Org User if null or else update existing Org User
|
||||
//-----------------------------------------------------------------
|
||||
// Scenario 2: We also need to create an OrganizationUser
|
||||
// This means that an invitation was not sent for this user and we
|
||||
// need to establish their invited status now.
|
||||
//-----------------------------------------------------------------
|
||||
if (orgUser == null)
|
||||
{
|
||||
orgUser = new OrganizationUser
|
||||
@@ -572,18 +567,107 @@ public class AccountController : Controller
|
||||
};
|
||||
await _organizationUserRepository.CreateAsync(orgUser);
|
||||
}
|
||||
//-----------------------------------------------------------------
|
||||
// Scenario 3: There is already an existing OrganizationUser
|
||||
// That was established through an invitation. We just need to
|
||||
// update the UserId now that we have created a User record.
|
||||
//-----------------------------------------------------------------
|
||||
else
|
||||
{
|
||||
orgUser.UserId = user.Id;
|
||||
await _organizationUserRepository.ReplaceAsync(orgUser);
|
||||
}
|
||||
|
||||
// Create sso user record
|
||||
// Create the SsoUser record to link the user to the SSO provider.
|
||||
await CreateSsoUserRecord(providerUserId, user.Id, orgId, orgUser);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
private async Task<User> GetUserFromManualLinkingData(string userIdentifier)
|
||||
{
|
||||
User user = null;
|
||||
var split = userIdentifier.Split(",");
|
||||
if (split.Length < 2)
|
||||
{
|
||||
throw new Exception(_i18nService.T("InvalidUserIdentifier"));
|
||||
}
|
||||
var userId = split[0];
|
||||
var token = split[1];
|
||||
|
||||
var tokenOptions = new TokenOptions();
|
||||
|
||||
var claimedUser = await _userService.GetUserByIdAsync(userId);
|
||||
if (claimedUser != null)
|
||||
{
|
||||
var tokenIsValid = await _userManager.VerifyUserTokenAsync(
|
||||
claimedUser, tokenOptions.PasswordResetTokenProvider, TokenPurposes.LinkSso, token);
|
||||
if (tokenIsValid)
|
||||
{
|
||||
user = claimedUser;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new Exception(_i18nService.T("UserIdAndTokenMismatch"));
|
||||
}
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
private async Task<(Organization, OrganizationUser)> FindOrganizationUser(User existingUser, string email, Guid orgId)
|
||||
{
|
||||
OrganizationUser orgUser = null;
|
||||
var organization = await _organizationRepository.GetByIdAsync(orgId);
|
||||
if (organization == null)
|
||||
{
|
||||
throw new Exception(_i18nService.T("CouldNotFindOrganization", orgId));
|
||||
}
|
||||
|
||||
// Try to find OrgUser via existing User Id.
|
||||
// This covers any OrganizationUser state after they have accepted an invite.
|
||||
if (existingUser != null)
|
||||
{
|
||||
var orgUsersByUserId = await _organizationUserRepository.GetManyByUserAsync(existingUser.Id);
|
||||
orgUser = orgUsersByUserId.SingleOrDefault(u => u.OrganizationId == orgId);
|
||||
}
|
||||
|
||||
// If no Org User found by Existing User Id - search all the organization's users via email.
|
||||
// This covers users who are Invited but haven't accepted their invite yet.
|
||||
orgUser ??= await _organizationUserRepository.GetByOrganizationEmailAsync(orgId, email);
|
||||
|
||||
return (organization, orgUser);
|
||||
}
|
||||
|
||||
private void EnsureOrgUserStatusAllowed(
|
||||
OrganizationUserStatusType status,
|
||||
string organizationDisplayName,
|
||||
params OrganizationUserStatusType[] allowedStatuses)
|
||||
{
|
||||
// if this status is one of the allowed ones, just return
|
||||
if (allowedStatuses.Contains(status))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// otherwise throw the appropriate exception
|
||||
switch (status)
|
||||
{
|
||||
case OrganizationUserStatusType.Invited:
|
||||
// Org User is invited – must accept via email first
|
||||
throw new Exception(
|
||||
_i18nService.T("AcceptInviteBeforeUsingSSO", organizationDisplayName));
|
||||
case OrganizationUserStatusType.Revoked:
|
||||
// Revoked users may not be (auto)‑provisioned
|
||||
throw new Exception(
|
||||
_i18nService.T("OrganizationUserAccessRevoked", organizationDisplayName));
|
||||
default:
|
||||
// anything else is “unknown”
|
||||
throw new Exception(
|
||||
_i18nService.T("OrganizationUserUnknownStatus", organizationDisplayName));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private IActionResult InvalidJson(string errorMessageKey, Exception ex = null)
|
||||
{
|
||||
Response.StatusCode = ex == null ? 400 : 500;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using System.Diagnostics;
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using System.Diagnostics;
|
||||
using Bit.Sso.Models;
|
||||
using Duende.IdentityServer.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
###############################################
|
||||
# Build stage #
|
||||
###############################################
|
||||
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
||||
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0-alpine3.21 AS build
|
||||
|
||||
# Docker buildx supplies the value for this arg
|
||||
ARG TARGETPLATFORM
|
||||
@@ -9,11 +9,11 @@ ARG TARGETPLATFORM
|
||||
# Determine proper runtime value for .NET
|
||||
# We put the value in a file to be read by later layers.
|
||||
RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \
|
||||
RID=linux-x64 ; \
|
||||
RID=linux-musl-x64 ; \
|
||||
elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \
|
||||
RID=linux-arm64 ; \
|
||||
RID=linux-musl-arm64 ; \
|
||||
elif [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then \
|
||||
RID=linux-arm ; \
|
||||
RID=linux-musl-arm ; \
|
||||
fi \
|
||||
&& echo "RID=$RID" > /tmp/rid.txt
|
||||
|
||||
@@ -37,21 +37,21 @@ RUN . /tmp/rid.txt && dotnet publish \
|
||||
###############################################
|
||||
# App stage #
|
||||
###############################################
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:8.0
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine3.21
|
||||
|
||||
ARG TARGETPLATFORM
|
||||
LABEL com.bitwarden.product="bitwarden"
|
||||
ENV ASPNETCORE_ENVIRONMENT=Production
|
||||
ENV ASPNETCORE_URLS=http://+:5000
|
||||
ENV SSL_CERT_DIR=/etc/bitwarden/ca-certificates
|
||||
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
|
||||
EXPOSE 5000
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
gosu \
|
||||
curl \
|
||||
krb5-user \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN apk add --no-cache curl \
|
||||
krb5 \
|
||||
icu-libs \
|
||||
shadow \
|
||||
&& apk add --no-cache --repository=http://dl-cdn.alpinelinux.org/alpine/edge/community gosu
|
||||
|
||||
# Copy app from the build stage
|
||||
WORKDIR /app
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<!-- This is a transitive dependency to Sustainsys.Saml2.AspNetCore2 -->
|
||||
<PackageReference Include="Microsoft.AspNetCore.Http" Version="2.2.2" />
|
||||
|
||||
<PackageReference Include="Sustainsys.Saml2.AspNetCore2" Version="2.10.0" />
|
||||
<PackageReference Include="Sustainsys.Saml2.AspNetCore2" Version="2.11.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using System.Security.Claims;
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using System.Security.Claims;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Bit.Sso.Utilities;
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Bit.Core.Auth.Entities;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.IdentityServer;
|
||||
using Bit.Core.Auth.Models.Data;
|
||||
using Bit.Core.Auth.Repositories;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Sso.Models;
|
||||
using Bit.Sso.Utilities;
|
||||
using Duende.IdentityModel;
|
||||
using Duende.IdentityServer;
|
||||
using Duende.IdentityServer.Infrastructure;
|
||||
using IdentityModel;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
|
||||
using Microsoft.Extensions.Options;
|
||||
@@ -413,7 +417,7 @@ public class DynamicAuthenticationSchemeProvider : AuthenticationSchemeProvider
|
||||
SPOptions = spOptions,
|
||||
SignInScheme = AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme,
|
||||
SignOutScheme = IdentityServerConstants.DefaultCookieAuthenticationScheme,
|
||||
CookieManager = new IdentityServer.DistributedCacheCookieManager(),
|
||||
CookieManager = new DistributedCacheCookieManager(),
|
||||
};
|
||||
options.IdentityProviders.Add(idp);
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using System.Collections.Concurrent;
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Bit.Sso.Utilities;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
|
||||
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
||||
|
||||
namespace Bit.Sso.Utilities;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using System.IO.Compression;
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using System.Xml;
|
||||
using Sustainsys.Saml2;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using Bit.Core.Business.Sso;
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using Bit.Core.Business.Sso;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.SharedWeb.Utilities;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
#!/bin/sh
|
||||
|
||||
# Setup
|
||||
|
||||
@@ -37,7 +37,7 @@ then
|
||||
mkdir -p /etc/bitwarden/ca-certificates
|
||||
chown -R $USERNAME:$GROUPNAME /etc/bitwarden
|
||||
|
||||
if [[ -f "/etc/bitwarden/kerberos/bitwarden.keytab" && -f "/etc/bitwarden/kerberos/krb5.conf" ]]; then
|
||||
if [ -f "/etc/bitwarden/kerberos/bitwarden.keytab" ] && [ -f "/etc/bitwarden/kerberos/krb5.conf" ]; then
|
||||
chown -R $USERNAME:$GROUPNAME /etc/bitwarden/kerberos
|
||||
fi
|
||||
|
||||
@@ -46,13 +46,13 @@ else
|
||||
gosu_cmd=""
|
||||
fi
|
||||
|
||||
if [[ -f "/etc/bitwarden/kerberos/bitwarden.keytab" && -f "/etc/bitwarden/kerberos/krb5.conf" ]]; then
|
||||
if [ -f "/etc/bitwarden/kerberos/bitwarden.keytab" ] && [ -f "/etc/bitwarden/kerberos/krb5.conf" ]; then
|
||||
cp -f /etc/bitwarden/kerberos/krb5.conf /etc/krb5.conf
|
||||
$gosu_cmd kinit $globalSettings__kerberosUser -k -t /etc/bitwarden/kerberos/bitwarden.keytab
|
||||
fi
|
||||
|
||||
if [[ $globalSettings__selfHosted == "true" ]]; then
|
||||
if [[ -z $globalSettings__identityServer__certificateLocation ]]; then
|
||||
if [ "$globalSettings__selfHosted" = "true" ]; then
|
||||
if [ -z "$globalSettings__identityServer__certificateLocation" ]; then
|
||||
export globalSettings__identityServer__certificateLocation=/etc/bitwarden/identity/identity.pfx
|
||||
fi
|
||||
fi
|
||||
|
||||
182
bitwarden_license/src/Sso/package-lock.json
generated
182
bitwarden_license/src/Sso/package-lock.json
generated
@@ -17,9 +17,9 @@
|
||||
"css-loader": "7.1.2",
|
||||
"expose-loader": "5.0.1",
|
||||
"mini-css-extract-plugin": "2.9.2",
|
||||
"sass": "1.88.0",
|
||||
"sass": "1.91.0",
|
||||
"sass-loader": "16.0.5",
|
||||
"webpack": "5.99.8",
|
||||
"webpack": "5.101.3",
|
||||
"webpack-cli": "5.1.4"
|
||||
}
|
||||
},
|
||||
@@ -34,18 +34,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.8",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz",
|
||||
"integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==",
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/set-array": "^1.2.1",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.10",
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/resolve-uri": {
|
||||
@@ -58,20 +54,10 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/set-array": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz",
|
||||
"integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/source-map": {
|
||||
"version": "0.3.6",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz",
|
||||
"integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==",
|
||||
"version": "0.3.11",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz",
|
||||
"integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -80,16 +66,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
|
||||
"integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==",
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.25",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz",
|
||||
"integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==",
|
||||
"version": "0.3.30",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz",
|
||||
"integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -441,9 +427,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
|
||||
"integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==",
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -455,13 +441,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.15.21",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.21.tgz",
|
||||
"integrity": "sha512-EV/37Td6c+MgKAbkcLG6vqZ2zEYHD7bvSrzqqs2RIhbA6w3x+Dqz8MZM3sP6kGTeLrdoOgKZe+Xja7tUB2DNkQ==",
|
||||
"version": "24.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.1.tgz",
|
||||
"integrity": "sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
"undici-types": "~7.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@webassemblyjs/ast": {
|
||||
@@ -687,9 +673,9 @@
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.14.1",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
|
||||
"integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==",
|
||||
"version": "8.15.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
@@ -699,6 +685,19 @@
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn-import-phases": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz",
|
||||
"integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"acorn": "^8.14.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "8.17.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||
@@ -781,9 +780,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.24.5",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.5.tgz",
|
||||
"integrity": "sha512-FDToo4Wo82hIdgc1CQ+NQD0hEhmpPjrZ3hiUgwgOG6IuTdlpr8jdjyG24P6cNP1yJpTLzS5OcGgSw0xmDU1/Tw==",
|
||||
"version": "4.25.4",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.4.tgz",
|
||||
"integrity": "sha512-4jYpcjabC606xJ3kw2QwGEZKX0Aw7sgQdZCvIK9dhVSPh76BKo+C+btT1RRofH7B+8iNpEbgGNVWiLki5q93yg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -801,8 +800,8 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"caniuse-lite": "^1.0.30001716",
|
||||
"electron-to-chromium": "^1.5.149",
|
||||
"caniuse-lite": "^1.0.30001737",
|
||||
"electron-to-chromium": "^1.5.211",
|
||||
"node-releases": "^2.0.19",
|
||||
"update-browserslist-db": "^1.1.3"
|
||||
},
|
||||
@@ -821,9 +820,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001718",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001718.tgz",
|
||||
"integrity": "sha512-AflseV1ahcSunK53NfEs9gFWgOEmzr0f+kaMFA4xiLZlr9Hzt7HxcSpIFcnNCUkz6R6dWKa54rUz3HUmI3nVcw==",
|
||||
"version": "1.0.30001741",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001741.tgz",
|
||||
"integrity": "sha512-QGUGitqsc8ARjLdgAfxETDhRbJ0REsP6O3I96TAth/mVjh2cYzN2u+3AzPP3aVSm2FehEItaJw1xd+IGBXWeSw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -975,16 +974,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.155",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.155.tgz",
|
||||
"integrity": "sha512-ps5KcGGmwL8VaeJlvlDlu4fORQpv3+GIcF5I3f9tUKUlJ/wsysh6HU8P5L1XWRYeXfA0oJd4PyM8ds8zTFf6Ng==",
|
||||
"version": "1.5.215",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.215.tgz",
|
||||
"integrity": "sha512-TIvGp57UpeNetj/wV/xpFNpWGb0b/ROw372lHPx5Aafx02gjTBtWnEEcaSX3W2dLM3OSdGGyHX/cHl01JQsLaQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.18.1",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz",
|
||||
"integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==",
|
||||
"version": "5.18.3",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
|
||||
"integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1107,9 +1106,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz",
|
||||
"integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==",
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
|
||||
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -1241,9 +1240,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/immutable": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.2.tgz",
|
||||
"integrity": "sha512-qHKXW1q6liAk1Oys6umoaZbDRqjcjgSrbnrifHsfsttza7zcvRAsL7mMV6xWcyhwQy7Xj5v4hhbr6b+iDYwlmQ==",
|
||||
"version": "5.1.3",
|
||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.3.tgz",
|
||||
"integrity": "sha512-+chQdDfvscSF1SJqv2gn4SRO2ZyS3xL3r7IW/wWEEzrzLisnOlKiQu5ytC/BVNcS15C39WT2Hg/bjKjDMcu+zg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -1528,9 +1527,9 @@
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.19",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
|
||||
"integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==",
|
||||
"version": "2.0.20",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.20.tgz",
|
||||
"integrity": "sha512-7gK6zSXEH6neM212JgfYFXe+GmZQM+fia5SsusuBIUgnPheLFBmIPhtFoAQRj8/7wASYQnbDlHPVwY0BefoFgA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -1635,9 +1634,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.3",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz",
|
||||
"integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==",
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -1655,7 +1654,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.8",
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
@@ -1860,9 +1859,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sass": {
|
||||
"version": "1.88.0",
|
||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.88.0.tgz",
|
||||
"integrity": "sha512-sF6TWQqjFvr4JILXzG4ucGOLELkESHL+I5QJhh7CNaE+Yge0SI+ehCatsXhJ7ymU1hAFcIS3/PBpjdIbXoyVbg==",
|
||||
"version": "1.91.0",
|
||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.91.0.tgz",
|
||||
"integrity": "sha512-aFOZHGf+ur+bp1bCHZ+u8otKGh77ZtmFyXDo4tlYvT7PWql41Kwd8wdkPqhhT+h2879IVblcHFglIMofsFd1EA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2061,24 +2060,28 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.2.tgz",
|
||||
"integrity": "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==",
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.3.tgz",
|
||||
"integrity": "sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/terser": {
|
||||
"version": "5.39.2",
|
||||
"resolved": "https://registry.npmjs.org/terser/-/terser-5.39.2.tgz",
|
||||
"integrity": "sha512-yEPUmWve+VA78bI71BW70Dh0TuV4HHd+I5SHOAfS1+QBOmvmCiiffgjR8ryyEd3KIfvPGFqoADt8LdQ6XpXIvg==",
|
||||
"version": "5.44.0",
|
||||
"resolved": "https://registry.npmjs.org/terser/-/terser-5.44.0.tgz",
|
||||
"integrity": "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"@jridgewell/source-map": "^0.3.3",
|
||||
"acorn": "^8.14.0",
|
||||
"acorn": "^8.15.0",
|
||||
"commander": "^2.20.0",
|
||||
"source-map-support": "~0.5.20"
|
||||
},
|
||||
@@ -2139,9 +2142,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"version": "7.10.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz",
|
||||
"integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -2198,22 +2201,23 @@
|
||||
}
|
||||
},
|
||||
"node_modules/webpack": {
|
||||
"version": "5.99.8",
|
||||
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.99.8.tgz",
|
||||
"integrity": "sha512-lQ3CPiSTpfOnrEGeXDwoq5hIGzSjmwD72GdfVzF7CQAI7t47rJG9eDWvcEkEn3CUQymAElVvDg3YNTlCYj+qUQ==",
|
||||
"version": "5.101.3",
|
||||
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.101.3.tgz",
|
||||
"integrity": "sha512-7b0dTKR3Ed//AD/6kkx/o7duS8H3f1a4w3BYpIriX4BzIhjkn4teo05cptsxvLesHFKK5KObnadmCHBwGc+51A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/eslint-scope": "^3.7.7",
|
||||
"@types/estree": "^1.0.6",
|
||||
"@types/estree": "^1.0.8",
|
||||
"@types/json-schema": "^7.0.15",
|
||||
"@webassemblyjs/ast": "^1.14.1",
|
||||
"@webassemblyjs/wasm-edit": "^1.14.1",
|
||||
"@webassemblyjs/wasm-parser": "^1.14.1",
|
||||
"acorn": "^8.14.0",
|
||||
"acorn": "^8.15.0",
|
||||
"acorn-import-phases": "^1.0.3",
|
||||
"browserslist": "^4.24.0",
|
||||
"chrome-trace-event": "^1.0.2",
|
||||
"enhanced-resolve": "^5.17.1",
|
||||
"enhanced-resolve": "^5.17.3",
|
||||
"es-module-lexer": "^1.2.1",
|
||||
"eslint-scope": "5.1.1",
|
||||
"events": "^3.2.0",
|
||||
@@ -2227,7 +2231,7 @@
|
||||
"tapable": "^2.1.1",
|
||||
"terser-webpack-plugin": "^5.3.11",
|
||||
"watchpack": "^2.4.1",
|
||||
"webpack-sources": "^3.2.3"
|
||||
"webpack-sources": "^3.3.3"
|
||||
},
|
||||
"bin": {
|
||||
"webpack": "bin/webpack.js"
|
||||
@@ -2317,9 +2321,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/webpack-sources": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz",
|
||||
"integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==",
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.3.tgz",
|
||||
"integrity": "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
@@ -16,9 +16,9 @@
|
||||
"css-loader": "7.1.2",
|
||||
"expose-loader": "5.0.1",
|
||||
"mini-css-extract-plugin": "2.9.2",
|
||||
"sass": "1.88.0",
|
||||
"sass": "1.91.0",
|
||||
"sass-loader": "16.0.5",
|
||||
"webpack": "5.99.8",
|
||||
"webpack": "5.101.3",
|
||||
"webpack-cli": "5.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using Bit.Commercial.Core.AdminConsole.Providers;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
@@ -263,7 +262,8 @@ public class RemoveOrganizationFromProviderCommandTests
|
||||
org =>
|
||||
org.BillingEmail == "a@example.com" &&
|
||||
org.GatewaySubscriptionId == "subscription_id" &&
|
||||
org.Status == OrganizationStatusType.Created));
|
||||
org.Status == OrganizationStatusType.Created &&
|
||||
org.Enabled == true)); // Verify organization is enabled when new subscription is created
|
||||
|
||||
await sutProvider.GetDependency<IProviderOrganizationRepository>().Received(1)
|
||||
.DeleteAsync(providerOrganization);
|
||||
@@ -331,9 +331,6 @@ public class RemoveOrganizationFromProviderCommandTests
|
||||
Id = "subscription_id"
|
||||
});
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge).Returns(true);
|
||||
|
||||
await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization);
|
||||
|
||||
await stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(options =>
|
||||
@@ -354,7 +351,8 @@ public class RemoveOrganizationFromProviderCommandTests
|
||||
org =>
|
||||
org.BillingEmail == "a@example.com" &&
|
||||
org.GatewaySubscriptionId == "subscription_id" &&
|
||||
org.Status == OrganizationStatusType.Created));
|
||||
org.Status == OrganizationStatusType.Created &&
|
||||
org.Enabled == true)); // Verify organization is enabled when new subscription is created
|
||||
|
||||
await sutProvider.GetDependency<IProviderOrganizationRepository>().Received(1)
|
||||
.DeleteAsync(providerOrganization);
|
||||
@@ -390,4 +388,62 @@ public class RemoveOrganizationFromProviderCommandTests
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task RemoveOrganizationFromProvider_DisabledOrganization_ConsolidatedBilling_EnablesOrganization(
|
||||
Provider provider,
|
||||
ProviderOrganization providerOrganization,
|
||||
Organization organization,
|
||||
SutProvider<RemoveOrganizationFromProviderCommand> sutProvider)
|
||||
{
|
||||
// Arrange: Set up a disabled organization that meets the criteria for consolidated billing
|
||||
provider.Status = ProviderStatusType.Billable;
|
||||
providerOrganization.ProviderId = provider.Id;
|
||||
organization.Status = OrganizationStatusType.Managed;
|
||||
organization.PlanType = PlanType.TeamsMonthly;
|
||||
organization.Enabled = false; // Start with a disabled organization
|
||||
|
||||
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
|
||||
|
||||
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(PlanType.TeamsMonthly).Returns(teamsMonthlyPlan);
|
||||
|
||||
sutProvider.GetDependency<IHasConfirmedOwnersExceptQuery>().HasConfirmedOwnersExceptAsync(
|
||||
providerOrganization.OrganizationId,
|
||||
[],
|
||||
includeProvider: false)
|
||||
.Returns(true);
|
||||
|
||||
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
|
||||
|
||||
organizationRepository.GetOwnerEmailAddressesById(organization.Id).Returns([
|
||||
"owner@example.com"
|
||||
]);
|
||||
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
|
||||
stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, Arg.Any<CustomerUpdateOptions>())
|
||||
.Returns(new Customer
|
||||
{
|
||||
Id = "customer_id",
|
||||
Address = new Address
|
||||
{
|
||||
Country = "US"
|
||||
}
|
||||
});
|
||||
|
||||
stripeAdapter.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(new Subscription
|
||||
{
|
||||
Id = "new_subscription_id"
|
||||
});
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization);
|
||||
|
||||
// Assert: Verify the disabled organization is now enabled
|
||||
await organizationRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(
|
||||
org =>
|
||||
org.Enabled == true && // The previously disabled organization should now be enabled
|
||||
org.Status == OrganizationStatusType.Created &&
|
||||
org.GatewaySubscriptionId == "new_subscription_id"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
using Bit.Commercial.Core.AdminConsole.Services;
|
||||
using Bit.Commercial.Core.Test.AdminConsole.AutoFixture;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.Models.Business.Provider;
|
||||
using Bit.Core.AdminConsole.Models.Business.Tokenables;
|
||||
using Bit.Core.AdminConsole.Models.Data.Provider;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Billing.Payment.Models;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Providers.Services;
|
||||
using Bit.Core.Context;
|
||||
@@ -41,7 +41,7 @@ public class ProviderServiceTests
|
||||
public async Task CompleteSetupAsync_UserIdIsInvalid_Throws(SutProvider<ProviderService> sutProvider)
|
||||
{
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
() => sutProvider.Sut.CompleteSetupAsync(default, default, default, default, null));
|
||||
() => sutProvider.Sut.CompleteSetupAsync(default, default, default, default, null, null));
|
||||
Assert.Contains("Invalid owner.", exception.Message);
|
||||
}
|
||||
|
||||
@@ -53,85 +53,12 @@ public class ProviderServiceTests
|
||||
userService.GetUserByIdAsync(user.Id).Returns(user);
|
||||
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
() => sutProvider.Sut.CompleteSetupAsync(provider, user.Id, default, default, null));
|
||||
() => sutProvider.Sut.CompleteSetupAsync(provider, user.Id, default, default, null, null));
|
||||
Assert.Contains("Invalid token.", exception.Message);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task CompleteSetupAsync_InvalidTaxInfo_ThrowsBadRequestException(
|
||||
User user,
|
||||
Provider provider,
|
||||
string key,
|
||||
TaxInfo taxInfo,
|
||||
TokenizedPaymentSource tokenizedPaymentSource,
|
||||
[ProviderUser] ProviderUser providerUser,
|
||||
SutProvider<ProviderService> sutProvider)
|
||||
{
|
||||
providerUser.ProviderId = provider.Id;
|
||||
providerUser.UserId = user.Id;
|
||||
var userService = sutProvider.GetDependency<IUserService>();
|
||||
userService.GetUserByIdAsync(user.Id).Returns(user);
|
||||
|
||||
var providerUserRepository = sutProvider.GetDependency<IProviderUserRepository>();
|
||||
providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id).Returns(providerUser);
|
||||
|
||||
var dataProtectionProvider = DataProtectionProvider.Create("ApplicationName");
|
||||
var protector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector");
|
||||
sutProvider.GetDependency<IDataProtectionProvider>().CreateProtector("ProviderServiceDataProtector")
|
||||
.Returns(protector);
|
||||
|
||||
sutProvider.Create();
|
||||
|
||||
var token = protector.Protect($"ProviderSetupInvite {provider.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}");
|
||||
|
||||
taxInfo.BillingAddressCountry = null;
|
||||
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
|
||||
sutProvider.Sut.CompleteSetupAsync(provider, user.Id, token, key, taxInfo, tokenizedPaymentSource));
|
||||
|
||||
Assert.Equal("Both address and postal code are required to set up your provider.", exception.Message);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task CompleteSetupAsync_InvalidTokenizedPaymentSource_ThrowsBadRequestException(
|
||||
User user,
|
||||
Provider provider,
|
||||
string key,
|
||||
TaxInfo taxInfo,
|
||||
TokenizedPaymentSource tokenizedPaymentSource,
|
||||
[ProviderUser] ProviderUser providerUser,
|
||||
SutProvider<ProviderService> sutProvider)
|
||||
{
|
||||
providerUser.ProviderId = provider.Id;
|
||||
providerUser.UserId = user.Id;
|
||||
var userService = sutProvider.GetDependency<IUserService>();
|
||||
userService.GetUserByIdAsync(user.Id).Returns(user);
|
||||
|
||||
var providerUserRepository = sutProvider.GetDependency<IProviderUserRepository>();
|
||||
providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id).Returns(providerUser);
|
||||
|
||||
var dataProtectionProvider = DataProtectionProvider.Create("ApplicationName");
|
||||
var protector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector");
|
||||
sutProvider.GetDependency<IDataProtectionProvider>().CreateProtector("ProviderServiceDataProtector")
|
||||
.Returns(protector);
|
||||
|
||||
sutProvider.Create();
|
||||
|
||||
var token = protector.Protect($"ProviderSetupInvite {provider.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}");
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
|
||||
|
||||
tokenizedPaymentSource = tokenizedPaymentSource with { Type = PaymentMethodType.BitPay };
|
||||
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
|
||||
sutProvider.Sut.CompleteSetupAsync(provider, user.Id, token, key, taxInfo, tokenizedPaymentSource));
|
||||
|
||||
Assert.Equal("A payment method is required to set up your provider.", exception.Message);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task CompleteSetupAsync_Success(User user, Provider provider, string key, TaxInfo taxInfo, TokenizedPaymentSource tokenizedPaymentSource,
|
||||
public async Task CompleteSetupAsync_Success(User user, Provider provider, string key, TokenizedPaymentMethod tokenizedPaymentMethod, BillingAddress billingAddress,
|
||||
[ProviderUser] ProviderUser providerUser,
|
||||
SutProvider<ProviderService> sutProvider)
|
||||
{
|
||||
@@ -151,7 +78,7 @@ public class ProviderServiceTests
|
||||
var providerBillingService = sutProvider.GetDependency<IProviderBillingService>();
|
||||
|
||||
var customer = new Customer { Id = "customer_id" };
|
||||
providerBillingService.SetupCustomer(provider, taxInfo, tokenizedPaymentSource).Returns(customer);
|
||||
providerBillingService.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress).Returns(customer);
|
||||
|
||||
var subscription = new Subscription { Id = "subscription_id" };
|
||||
providerBillingService.SetupSubscription(provider).Returns(subscription);
|
||||
@@ -160,7 +87,7 @@ public class ProviderServiceTests
|
||||
|
||||
var token = protector.Protect($"ProviderSetupInvite {provider.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}");
|
||||
|
||||
await sutProvider.Sut.CompleteSetupAsync(provider, user.Id, token, key, taxInfo, tokenizedPaymentSource);
|
||||
await sutProvider.Sut.CompleteSetupAsync(provider, user.Id, token, key, tokenizedPaymentMethod, billingAddress);
|
||||
|
||||
await sutProvider.GetDependency<IProviderRepository>().Received().UpsertAsync(Arg.Is<Provider>(
|
||||
p =>
|
||||
@@ -188,6 +115,262 @@ public class ProviderServiceTests
|
||||
await sutProvider.Sut.UpdateAsync(provider);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateAsync_ExistingProviderIsNull_DoesNotCallUpdateClientOrganizationsEnabledStatus(
|
||||
Provider provider, SutProvider<ProviderService> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
var providerRepository = sutProvider.GetDependency<IProviderRepository>();
|
||||
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
|
||||
|
||||
providerRepository.GetByIdAsync(provider.Id).Returns((Provider)null);
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.UpdateAsync(provider);
|
||||
|
||||
// Assert
|
||||
await providerRepository.Received(1).ReplaceAsync(provider);
|
||||
await providerOrganizationRepository.DidNotReceive().GetManyDetailsByProviderAsync(Arg.Any<Guid>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateAsync_EnabledStatusNotChanged_DoesNotCallUpdateClientOrganizationsEnabledStatus(
|
||||
Provider provider, Provider existingProvider, SutProvider<ProviderService> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
var providerRepository = sutProvider.GetDependency<IProviderRepository>();
|
||||
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
|
||||
|
||||
existingProvider.Id = provider.Id;
|
||||
existingProvider.Enabled = provider.Enabled; // Same enabled status
|
||||
provider.Type = ProviderType.Msp; // Set to a type that would trigger update if status changed
|
||||
|
||||
providerRepository.GetByIdAsync(provider.Id).Returns(existingProvider);
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.UpdateAsync(provider);
|
||||
|
||||
// Assert
|
||||
await providerRepository.Received(1).ReplaceAsync(provider);
|
||||
await providerOrganizationRepository.DidNotReceive().GetManyDetailsByProviderAsync(Arg.Any<Guid>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateAsync_EnabledStatusChangedButProviderTypeIsReseller_DoesNotCallUpdateClientOrganizationsEnabledStatus(
|
||||
Provider provider, Provider existingProvider, SutProvider<ProviderService> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
var providerRepository = sutProvider.GetDependency<IProviderRepository>();
|
||||
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
|
||||
|
||||
existingProvider.Id = provider.Id;
|
||||
existingProvider.Enabled = !provider.Enabled; // Different enabled status
|
||||
provider.Type = ProviderType.Reseller; // Type that should not trigger update
|
||||
|
||||
providerRepository.GetByIdAsync(provider.Id).Returns(existingProvider);
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.UpdateAsync(provider);
|
||||
|
||||
// Assert
|
||||
await providerRepository.Received(1).ReplaceAsync(provider);
|
||||
await providerOrganizationRepository.DidNotReceive().GetManyDetailsByProviderAsync(Arg.Any<Guid>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateAsync_EnabledStatusChangedAndProviderTypeIsMsp_CallsUpdateClientOrganizationsEnabledStatus(
|
||||
Provider provider, Provider existingProvider, SutProvider<ProviderService> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
var providerRepository = sutProvider.GetDependency<IProviderRepository>();
|
||||
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
|
||||
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
|
||||
var applicationCacheService = sutProvider.GetDependency<IApplicationCacheService>();
|
||||
|
||||
existingProvider.Id = provider.Id;
|
||||
existingProvider.Enabled = !provider.Enabled; // Different enabled status
|
||||
provider.Type = ProviderType.Msp; // Type that should trigger update
|
||||
|
||||
// Create test provider organization details
|
||||
var providerOrganizationDetails = new List<ProviderOrganizationOrganizationDetails>
|
||||
{
|
||||
new ProviderOrganizationOrganizationDetails { Id = Guid.NewGuid(), ProviderId = provider.Id, OrganizationId = Guid.NewGuid() },
|
||||
new ProviderOrganizationOrganizationDetails { Id = Guid.NewGuid(), ProviderId = provider.Id, OrganizationId = Guid.NewGuid() }
|
||||
};
|
||||
|
||||
// Create test organizations with different enabled status than what we're setting
|
||||
var organizations = providerOrganizationDetails.Select(po =>
|
||||
{
|
||||
var org = new Organization { Id = po.OrganizationId, Enabled = !provider.Enabled };
|
||||
return org;
|
||||
}).ToList();
|
||||
|
||||
providerRepository.GetByIdAsync(provider.Id).Returns(existingProvider);
|
||||
providerOrganizationRepository.GetManyDetailsByProviderAsync(provider.Id).Returns(providerOrganizationDetails);
|
||||
|
||||
foreach (var org in organizations)
|
||||
{
|
||||
organizationRepository.GetByIdAsync(org.Id).Returns(org);
|
||||
}
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.UpdateAsync(provider);
|
||||
|
||||
// Assert
|
||||
await providerRepository.Received(1).ReplaceAsync(provider);
|
||||
await providerOrganizationRepository.Received(1).GetManyDetailsByProviderAsync(provider.Id);
|
||||
|
||||
foreach (var org in organizations)
|
||||
{
|
||||
await organizationRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(o =>
|
||||
o.Id == org.Id && o.Enabled == provider.Enabled));
|
||||
await applicationCacheService.Received(1).UpsertOrganizationAbilityAsync(Arg.Is<Organization>(o =>
|
||||
o.Id == org.Id && o.Enabled == provider.Enabled));
|
||||
}
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateAsync_EnabledStatusChangedAndProviderTypeIsBusinessUnit_CallsUpdateClientOrganizationsEnabledStatus(
|
||||
Provider provider, Provider existingProvider, SutProvider<ProviderService> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
var providerRepository = sutProvider.GetDependency<IProviderRepository>();
|
||||
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
|
||||
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
|
||||
var applicationCacheService = sutProvider.GetDependency<IApplicationCacheService>();
|
||||
|
||||
existingProvider.Id = provider.Id;
|
||||
existingProvider.Enabled = !provider.Enabled; // Different enabled status
|
||||
provider.Type = ProviderType.BusinessUnit; // Type that should trigger update
|
||||
|
||||
// Create test provider organization details
|
||||
var providerOrganizationDetails = new List<ProviderOrganizationOrganizationDetails>
|
||||
{
|
||||
new ProviderOrganizationOrganizationDetails { Id = Guid.NewGuid(), ProviderId = provider.Id, OrganizationId = Guid.NewGuid() },
|
||||
new ProviderOrganizationOrganizationDetails { Id = Guid.NewGuid(), ProviderId = provider.Id, OrganizationId = Guid.NewGuid() }
|
||||
};
|
||||
|
||||
// Create test organizations with different enabled status than what we're setting
|
||||
var organizations = providerOrganizationDetails.Select(po =>
|
||||
{
|
||||
var org = new Organization { Id = po.OrganizationId, Enabled = !provider.Enabled };
|
||||
return org;
|
||||
}).ToList();
|
||||
|
||||
providerRepository.GetByIdAsync(provider.Id).Returns(existingProvider);
|
||||
providerOrganizationRepository.GetManyDetailsByProviderAsync(provider.Id).Returns(providerOrganizationDetails);
|
||||
|
||||
foreach (var org in organizations)
|
||||
{
|
||||
organizationRepository.GetByIdAsync(org.Id).Returns(org);
|
||||
}
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.UpdateAsync(provider);
|
||||
|
||||
// Assert
|
||||
await providerRepository.Received(1).ReplaceAsync(provider);
|
||||
await providerOrganizationRepository.Received(1).GetManyDetailsByProviderAsync(provider.Id);
|
||||
|
||||
foreach (var org in organizations)
|
||||
{
|
||||
await organizationRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(o =>
|
||||
o.Id == org.Id && o.Enabled == provider.Enabled));
|
||||
await applicationCacheService.Received(1).UpsertOrganizationAbilityAsync(Arg.Is<Organization>(o =>
|
||||
o.Id == org.Id && o.Enabled == provider.Enabled));
|
||||
}
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateAsync_OrganizationEnabledStatusAlreadyMatches_DoesNotUpdateOrganization(
|
||||
Provider provider, Provider existingProvider, SutProvider<ProviderService> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
var providerRepository = sutProvider.GetDependency<IProviderRepository>();
|
||||
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
|
||||
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
|
||||
var applicationCacheService = sutProvider.GetDependency<IApplicationCacheService>();
|
||||
|
||||
existingProvider.Id = provider.Id;
|
||||
existingProvider.Enabled = !provider.Enabled; // Different enabled status
|
||||
provider.Type = ProviderType.Msp; // Type that should trigger update
|
||||
|
||||
// Create test provider organization details
|
||||
var providerOrganizationDetails = new List<ProviderOrganizationOrganizationDetails>
|
||||
{
|
||||
new ProviderOrganizationOrganizationDetails { Id = Guid.NewGuid(), ProviderId = provider.Id, OrganizationId = Guid.NewGuid() },
|
||||
new ProviderOrganizationOrganizationDetails { Id = Guid.NewGuid(), ProviderId = provider.Id, OrganizationId = Guid.NewGuid() }
|
||||
};
|
||||
|
||||
// Create test organizations with SAME enabled status as what we're setting
|
||||
var organizations = providerOrganizationDetails.Select(po =>
|
||||
{
|
||||
var org = new Organization { Id = po.OrganizationId, Enabled = provider.Enabled };
|
||||
return org;
|
||||
}).ToList();
|
||||
|
||||
providerRepository.GetByIdAsync(provider.Id).Returns(existingProvider);
|
||||
providerOrganizationRepository.GetManyDetailsByProviderAsync(provider.Id).Returns(providerOrganizationDetails);
|
||||
|
||||
foreach (var org in organizations)
|
||||
{
|
||||
organizationRepository.GetByIdAsync(org.Id).Returns(org);
|
||||
}
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.UpdateAsync(provider);
|
||||
|
||||
// Assert
|
||||
await providerRepository.Received(1).ReplaceAsync(provider);
|
||||
await providerOrganizationRepository.Received(1).GetManyDetailsByProviderAsync(provider.Id);
|
||||
|
||||
// Organizations should not be updated since their enabled status already matches
|
||||
foreach (var org in organizations)
|
||||
{
|
||||
await organizationRepository.DidNotReceive().ReplaceAsync(Arg.Any<Organization>());
|
||||
await applicationCacheService.DidNotReceive().UpsertOrganizationAbilityAsync(Arg.Any<Organization>());
|
||||
}
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateAsync_OrganizationIsNull_SkipsNullOrganization(
|
||||
Provider provider, Provider existingProvider, SutProvider<ProviderService> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
var providerRepository = sutProvider.GetDependency<IProviderRepository>();
|
||||
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
|
||||
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
|
||||
var applicationCacheService = sutProvider.GetDependency<IApplicationCacheService>();
|
||||
|
||||
existingProvider.Id = provider.Id;
|
||||
existingProvider.Enabled = !provider.Enabled; // Different enabled status
|
||||
provider.Type = ProviderType.Msp; // Type that should trigger update
|
||||
|
||||
// Create test provider organization details
|
||||
var providerOrganizationDetails = new List<ProviderOrganizationOrganizationDetails>
|
||||
{
|
||||
new ProviderOrganizationOrganizationDetails { Id = Guid.NewGuid(), ProviderId = provider.Id, OrganizationId = Guid.NewGuid() },
|
||||
new ProviderOrganizationOrganizationDetails { Id = Guid.NewGuid(), ProviderId = provider.Id, OrganizationId = Guid.NewGuid() }
|
||||
};
|
||||
|
||||
providerRepository.GetByIdAsync(provider.Id).Returns(existingProvider);
|
||||
providerOrganizationRepository.GetManyDetailsByProviderAsync(provider.Id).Returns(providerOrganizationDetails);
|
||||
|
||||
// Return null for all organizations
|
||||
organizationRepository.GetByIdAsync(Arg.Any<Guid>()).Returns((Organization)null);
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.UpdateAsync(provider);
|
||||
|
||||
// Assert
|
||||
await providerRepository.Received(1).ReplaceAsync(provider);
|
||||
await providerOrganizationRepository.Received(1).GetManyDetailsByProviderAsync(provider.Id);
|
||||
|
||||
// No organizations should be updated since they're all null
|
||||
await organizationRepository.DidNotReceive().ReplaceAsync(Arg.Any<Organization>());
|
||||
await applicationCacheService.DidNotReceive().UpsertOrganizationAbilityAsync(Arg.Any<Organization>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task InviteUserAsync_ProviderIdIsInvalid_Throws(ProviderUserInvite<string> invite, SutProvider<ProviderService> sutProvider)
|
||||
{
|
||||
@@ -937,7 +1120,7 @@ public class ProviderServiceTests
|
||||
private static SubscriptionUpdateOptions SubscriptionUpdateRequest(string expectedPlanId, Subscription subscriptionItem) =>
|
||||
new()
|
||||
{
|
||||
Items = new List<Stripe.SubscriptionItemOptions>
|
||||
Items = new List<SubscriptionItemOptions>
|
||||
{
|
||||
new() { Id = subscriptionItem.Id, Price = expectedPlanId },
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Providers.Entities;
|
||||
using Bit.Core.Billing.Providers.Repositories;
|
||||
using Bit.Core.Billing.Providers.Services;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
@@ -1,8 +1,6 @@
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using Bit.Commercial.Core.Billing.Providers.Models;
|
||||
using Bit.Commercial.Core.Billing.Providers.Services;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
@@ -11,17 +9,16 @@ using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Caches;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Billing.Payment.Models;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Providers.Entities;
|
||||
using Bit.Core.Billing.Providers.Models;
|
||||
using Bit.Core.Billing.Providers.Repositories;
|
||||
using Bit.Core.Billing.Providers.Services;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Billing.Tax.Services;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Business;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
@@ -352,9 +349,6 @@ public class ProviderBillingServiceTests
|
||||
CloudRegion = "US"
|
||||
});
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge).Returns(true);
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>().CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(
|
||||
options =>
|
||||
options.Address.Country == providerCustomer.Address.Country &&
|
||||
@@ -898,208 +892,97 @@ public class ProviderBillingServiceTests
|
||||
#region SetupCustomer
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task SetupCustomer_MissingCountry_ContactSupport(
|
||||
public async Task SetupCustomer_NullPaymentMethod_ThrowsNullReferenceException(
|
||||
SutProvider<ProviderBillingService> sutProvider,
|
||||
Provider provider,
|
||||
TaxInfo taxInfo)
|
||||
BillingAddress billingAddress)
|
||||
{
|
||||
taxInfo.BillingAddressCountry = null;
|
||||
|
||||
await ThrowsBillingExceptionAsync(() => sutProvider.Sut.SetupCustomer(provider, taxInfo));
|
||||
|
||||
await sutProvider.GetDependency<IStripeAdapter>()
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
.CustomerGetAsync(Arg.Any<string>(), Arg.Any<CustomerGetOptions>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task SetupCustomer_MissingPostalCode_ContactSupport(
|
||||
SutProvider<ProviderBillingService> sutProvider,
|
||||
Provider provider,
|
||||
TaxInfo taxInfo)
|
||||
{
|
||||
taxInfo.BillingAddressCountry = null;
|
||||
|
||||
await ThrowsBillingExceptionAsync(() => sutProvider.Sut.SetupCustomer(provider, taxInfo));
|
||||
|
||||
await sutProvider.GetDependency<IStripeAdapter>()
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
.CustomerGetAsync(Arg.Any<string>(), Arg.Any<CustomerGetOptions>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task SetupCustomer_NoPaymentMethod_Success(
|
||||
SutProvider<ProviderBillingService> sutProvider,
|
||||
Provider provider,
|
||||
TaxInfo taxInfo)
|
||||
{
|
||||
provider.Name = "MSP";
|
||||
|
||||
sutProvider.GetDependency<ITaxService>()
|
||||
.GetStripeTaxCode(Arg.Is<string>(
|
||||
p => p == taxInfo.BillingAddressCountry),
|
||||
Arg.Is<string>(p => p == taxInfo.TaxIdNumber))
|
||||
.Returns(taxInfo.TaxIdType);
|
||||
|
||||
taxInfo.BillingAddressCountry = "AD";
|
||||
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
|
||||
var expected = new Customer
|
||||
{
|
||||
Id = "customer_id",
|
||||
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
|
||||
};
|
||||
|
||||
stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o =>
|
||||
o.Address.Country == taxInfo.BillingAddressCountry &&
|
||||
o.Address.PostalCode == taxInfo.BillingAddressPostalCode &&
|
||||
o.Address.Line1 == taxInfo.BillingAddressLine1 &&
|
||||
o.Address.Line2 == taxInfo.BillingAddressLine2 &&
|
||||
o.Address.City == taxInfo.BillingAddressCity &&
|
||||
o.Address.State == taxInfo.BillingAddressState &&
|
||||
o.Description == WebUtility.HtmlDecode(provider.BusinessName) &&
|
||||
o.Email == provider.BillingEmail &&
|
||||
o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" &&
|
||||
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" &&
|
||||
o.Metadata["region"] == "" &&
|
||||
o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType &&
|
||||
o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber))
|
||||
.Returns(expected);
|
||||
|
||||
var actual = await sutProvider.Sut.SetupCustomer(provider, taxInfo);
|
||||
|
||||
Assert.Equivalent(expected, actual);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task SetupCustomer_InvalidRequiredPaymentMethod_ThrowsBillingException(
|
||||
SutProvider<ProviderBillingService> sutProvider,
|
||||
Provider provider,
|
||||
TaxInfo taxInfo,
|
||||
TokenizedPaymentSource tokenizedPaymentSource)
|
||||
{
|
||||
provider.Name = "MSP";
|
||||
|
||||
sutProvider.GetDependency<ITaxService>()
|
||||
.GetStripeTaxCode(Arg.Is<string>(
|
||||
p => p == taxInfo.BillingAddressCountry),
|
||||
Arg.Is<string>(p => p == taxInfo.TaxIdNumber))
|
||||
.Returns(taxInfo.TaxIdType);
|
||||
|
||||
taxInfo.BillingAddressCountry = "AD";
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
|
||||
|
||||
tokenizedPaymentSource = tokenizedPaymentSource with { Type = PaymentMethodType.BitPay };
|
||||
|
||||
await ThrowsBillingExceptionAsync(() =>
|
||||
sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource));
|
||||
await Assert.ThrowsAsync<NullReferenceException>(() =>
|
||||
sutProvider.Sut.SetupCustomer(provider, null, billingAddress));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task SetupCustomer_WithBankAccount_Error_Reverts(
|
||||
SutProvider<ProviderBillingService> sutProvider,
|
||||
Provider provider,
|
||||
TaxInfo taxInfo)
|
||||
BillingAddress billingAddress)
|
||||
{
|
||||
provider.Name = "MSP";
|
||||
|
||||
sutProvider.GetDependency<ITaxService>()
|
||||
.GetStripeTaxCode(Arg.Is<string>(
|
||||
p => p == taxInfo.BillingAddressCountry),
|
||||
Arg.Is<string>(p => p == taxInfo.TaxIdNumber))
|
||||
.Returns(taxInfo.TaxIdType);
|
||||
|
||||
taxInfo.BillingAddressCountry = "AD";
|
||||
billingAddress.Country = "AD";
|
||||
billingAddress.TaxId = new TaxID("es_nif", "12345678Z");
|
||||
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
|
||||
var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.BankAccount, "token");
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
|
||||
var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.BankAccount, Token = "token" };
|
||||
|
||||
stripeAdapter.SetupIntentList(Arg.Is<SetupIntentListOptions>(options =>
|
||||
options.PaymentMethod == tokenizedPaymentSource.Token)).Returns([
|
||||
options.PaymentMethod == tokenizedPaymentMethod.Token)).Returns([
|
||||
new SetupIntent { Id = "setup_intent_id" }
|
||||
]);
|
||||
|
||||
stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o =>
|
||||
o.Address.Country == taxInfo.BillingAddressCountry &&
|
||||
o.Address.PostalCode == taxInfo.BillingAddressPostalCode &&
|
||||
o.Address.Line1 == taxInfo.BillingAddressLine1 &&
|
||||
o.Address.Line2 == taxInfo.BillingAddressLine2 &&
|
||||
o.Address.City == taxInfo.BillingAddressCity &&
|
||||
o.Address.State == taxInfo.BillingAddressState &&
|
||||
o.Description == WebUtility.HtmlDecode(provider.BusinessName) &&
|
||||
o.Address.Country == billingAddress.Country &&
|
||||
o.Address.PostalCode == billingAddress.PostalCode &&
|
||||
o.Address.Line1 == billingAddress.Line1 &&
|
||||
o.Address.Line2 == billingAddress.Line2 &&
|
||||
o.Address.City == billingAddress.City &&
|
||||
o.Address.State == billingAddress.State &&
|
||||
o.Description == provider.DisplayBusinessName() &&
|
||||
o.Email == provider.BillingEmail &&
|
||||
o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" &&
|
||||
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" &&
|
||||
o.InvoiceSettings.CustomFields.FirstOrDefault().Name == provider.SubscriberType() &&
|
||||
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == provider.DisplayName() &&
|
||||
o.Metadata["region"] == "" &&
|
||||
o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType &&
|
||||
o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber))
|
||||
o.TaxIdData.FirstOrDefault().Type == billingAddress.TaxId.Code &&
|
||||
o.TaxIdData.FirstOrDefault().Value == billingAddress.TaxId.Value))
|
||||
.Throws<StripeException>();
|
||||
|
||||
sutProvider.GetDependency<ISetupIntentCache>().Get(provider.Id).Returns("setup_intent_id");
|
||||
sutProvider.GetDependency<ISetupIntentCache>().GetSetupIntentIdForSubscriber(provider.Id).Returns("setup_intent_id");
|
||||
|
||||
await Assert.ThrowsAsync<StripeException>(() =>
|
||||
sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource));
|
||||
sutProvider.Sut.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress));
|
||||
|
||||
await sutProvider.GetDependency<ISetupIntentCache>().Received(1).Set(provider.Id, "setup_intent_id");
|
||||
|
||||
await stripeAdapter.Received(1).SetupIntentCancel("setup_intent_id", Arg.Is<SetupIntentCancelOptions>(options =>
|
||||
options.CancellationReason == "abandoned"));
|
||||
|
||||
await sutProvider.GetDependency<ISetupIntentCache>().Received(1).Remove(provider.Id);
|
||||
await sutProvider.GetDependency<ISetupIntentCache>().Received(1).RemoveSetupIntentForSubscriber(provider.Id);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task SetupCustomer_WithPayPal_Error_Reverts(
|
||||
SutProvider<ProviderBillingService> sutProvider,
|
||||
Provider provider,
|
||||
TaxInfo taxInfo)
|
||||
BillingAddress billingAddress)
|
||||
{
|
||||
provider.Name = "MSP";
|
||||
|
||||
sutProvider.GetDependency<ITaxService>()
|
||||
.GetStripeTaxCode(Arg.Is<string>(
|
||||
p => p == taxInfo.BillingAddressCountry),
|
||||
Arg.Is<string>(p => p == taxInfo.TaxIdNumber))
|
||||
.Returns(taxInfo.TaxIdType);
|
||||
|
||||
taxInfo.BillingAddressCountry = "AD";
|
||||
billingAddress.Country = "AD";
|
||||
billingAddress.TaxId = new TaxID("es_nif", "12345678Z");
|
||||
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.PayPal, Token = "token" };
|
||||
|
||||
var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.PayPal, "token");
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
|
||||
|
||||
sutProvider.GetDependency<ISubscriberService>().CreateBraintreeCustomer(provider, tokenizedPaymentSource.Token)
|
||||
sutProvider.GetDependency<ISubscriberService>().CreateBraintreeCustomer(provider, tokenizedPaymentMethod.Token)
|
||||
.Returns("braintree_customer_id");
|
||||
|
||||
stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o =>
|
||||
o.Address.Country == taxInfo.BillingAddressCountry &&
|
||||
o.Address.PostalCode == taxInfo.BillingAddressPostalCode &&
|
||||
o.Address.Line1 == taxInfo.BillingAddressLine1 &&
|
||||
o.Address.Line2 == taxInfo.BillingAddressLine2 &&
|
||||
o.Address.City == taxInfo.BillingAddressCity &&
|
||||
o.Address.State == taxInfo.BillingAddressState &&
|
||||
o.Description == WebUtility.HtmlDecode(provider.BusinessName) &&
|
||||
o.Address.Country == billingAddress.Country &&
|
||||
o.Address.PostalCode == billingAddress.PostalCode &&
|
||||
o.Address.Line1 == billingAddress.Line1 &&
|
||||
o.Address.Line2 == billingAddress.Line2 &&
|
||||
o.Address.City == billingAddress.City &&
|
||||
o.Address.State == billingAddress.State &&
|
||||
o.Description == provider.DisplayBusinessName() &&
|
||||
o.Email == provider.BillingEmail &&
|
||||
o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" &&
|
||||
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" &&
|
||||
o.InvoiceSettings.CustomFields.FirstOrDefault().Name == provider.SubscriberType() &&
|
||||
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == provider.DisplayName() &&
|
||||
o.Metadata["region"] == "" &&
|
||||
o.Metadata["btCustomerId"] == "braintree_customer_id" &&
|
||||
o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType &&
|
||||
o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber))
|
||||
o.TaxIdData.FirstOrDefault().Type == billingAddress.TaxId.Code &&
|
||||
o.TaxIdData.FirstOrDefault().Value == billingAddress.TaxId.Value))
|
||||
.Throws<StripeException>();
|
||||
|
||||
await Assert.ThrowsAsync<StripeException>(() =>
|
||||
sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource));
|
||||
sutProvider.Sut.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress));
|
||||
|
||||
await sutProvider.GetDependency<IBraintreeGateway>().Customer.Received(1).DeleteAsync("braintree_customer_id");
|
||||
}
|
||||
@@ -1108,17 +991,11 @@ public class ProviderBillingServiceTests
|
||||
public async Task SetupCustomer_WithBankAccount_Success(
|
||||
SutProvider<ProviderBillingService> sutProvider,
|
||||
Provider provider,
|
||||
TaxInfo taxInfo)
|
||||
BillingAddress billingAddress)
|
||||
{
|
||||
provider.Name = "MSP";
|
||||
|
||||
sutProvider.GetDependency<ITaxService>()
|
||||
.GetStripeTaxCode(Arg.Is<string>(
|
||||
p => p == taxInfo.BillingAddressCountry),
|
||||
Arg.Is<string>(p => p == taxInfo.TaxIdNumber))
|
||||
.Returns(taxInfo.TaxIdType);
|
||||
|
||||
taxInfo.BillingAddressCountry = "AD";
|
||||
billingAddress.Country = "AD";
|
||||
billingAddress.TaxId = new TaxID("es_nif", "12345678Z");
|
||||
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
|
||||
@@ -1128,33 +1005,30 @@ public class ProviderBillingServiceTests
|
||||
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
|
||||
};
|
||||
|
||||
var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.BankAccount, "token");
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
|
||||
var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.BankAccount, Token = "token" };
|
||||
|
||||
stripeAdapter.SetupIntentList(Arg.Is<SetupIntentListOptions>(options =>
|
||||
options.PaymentMethod == tokenizedPaymentSource.Token)).Returns([
|
||||
options.PaymentMethod == tokenizedPaymentMethod.Token)).Returns([
|
||||
new SetupIntent { Id = "setup_intent_id" }
|
||||
]);
|
||||
|
||||
stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o =>
|
||||
o.Address.Country == taxInfo.BillingAddressCountry &&
|
||||
o.Address.PostalCode == taxInfo.BillingAddressPostalCode &&
|
||||
o.Address.Line1 == taxInfo.BillingAddressLine1 &&
|
||||
o.Address.Line2 == taxInfo.BillingAddressLine2 &&
|
||||
o.Address.City == taxInfo.BillingAddressCity &&
|
||||
o.Address.State == taxInfo.BillingAddressState &&
|
||||
o.Description == WebUtility.HtmlDecode(provider.BusinessName) &&
|
||||
o.Address.Country == billingAddress.Country &&
|
||||
o.Address.PostalCode == billingAddress.PostalCode &&
|
||||
o.Address.Line1 == billingAddress.Line1 &&
|
||||
o.Address.Line2 == billingAddress.Line2 &&
|
||||
o.Address.City == billingAddress.City &&
|
||||
o.Address.State == billingAddress.State &&
|
||||
o.Description == provider.DisplayBusinessName() &&
|
||||
o.Email == provider.BillingEmail &&
|
||||
o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" &&
|
||||
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" &&
|
||||
o.InvoiceSettings.CustomFields.FirstOrDefault().Name == provider.SubscriberType() &&
|
||||
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == provider.DisplayName() &&
|
||||
o.Metadata["region"] == "" &&
|
||||
o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType &&
|
||||
o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber))
|
||||
o.TaxIdData.FirstOrDefault().Type == billingAddress.TaxId.Code &&
|
||||
o.TaxIdData.FirstOrDefault().Value == billingAddress.TaxId.Value))
|
||||
.Returns(expected);
|
||||
|
||||
var actual = await sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource);
|
||||
var actual = await sutProvider.Sut.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress);
|
||||
|
||||
Assert.Equivalent(expected, actual);
|
||||
|
||||
@@ -1165,17 +1039,11 @@ public class ProviderBillingServiceTests
|
||||
public async Task SetupCustomer_WithPayPal_Success(
|
||||
SutProvider<ProviderBillingService> sutProvider,
|
||||
Provider provider,
|
||||
TaxInfo taxInfo)
|
||||
BillingAddress billingAddress)
|
||||
{
|
||||
provider.Name = "MSP";
|
||||
|
||||
sutProvider.GetDependency<ITaxService>()
|
||||
.GetStripeTaxCode(Arg.Is<string>(
|
||||
p => p == taxInfo.BillingAddressCountry),
|
||||
Arg.Is<string>(p => p == taxInfo.TaxIdNumber))
|
||||
.Returns(taxInfo.TaxIdType);
|
||||
|
||||
taxInfo.BillingAddressCountry = "AD";
|
||||
billingAddress.Country = "AD";
|
||||
billingAddress.TaxId = new TaxID("es_nif", "12345678Z");
|
||||
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
|
||||
@@ -1185,32 +1053,29 @@ public class ProviderBillingServiceTests
|
||||
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
|
||||
};
|
||||
|
||||
var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.PayPal, "token");
|
||||
var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.PayPal, Token = "token" };
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
|
||||
|
||||
sutProvider.GetDependency<ISubscriberService>().CreateBraintreeCustomer(provider, tokenizedPaymentSource.Token)
|
||||
sutProvider.GetDependency<ISubscriberService>().CreateBraintreeCustomer(provider, tokenizedPaymentMethod.Token)
|
||||
.Returns("braintree_customer_id");
|
||||
|
||||
stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o =>
|
||||
o.Address.Country == taxInfo.BillingAddressCountry &&
|
||||
o.Address.PostalCode == taxInfo.BillingAddressPostalCode &&
|
||||
o.Address.Line1 == taxInfo.BillingAddressLine1 &&
|
||||
o.Address.Line2 == taxInfo.BillingAddressLine2 &&
|
||||
o.Address.City == taxInfo.BillingAddressCity &&
|
||||
o.Address.State == taxInfo.BillingAddressState &&
|
||||
o.Description == WebUtility.HtmlDecode(provider.BusinessName) &&
|
||||
o.Address.Country == billingAddress.Country &&
|
||||
o.Address.PostalCode == billingAddress.PostalCode &&
|
||||
o.Address.Line1 == billingAddress.Line1 &&
|
||||
o.Address.Line2 == billingAddress.Line2 &&
|
||||
o.Address.City == billingAddress.City &&
|
||||
o.Address.State == billingAddress.State &&
|
||||
o.Description == provider.DisplayBusinessName() &&
|
||||
o.Email == provider.BillingEmail &&
|
||||
o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" &&
|
||||
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" &&
|
||||
o.InvoiceSettings.CustomFields.FirstOrDefault().Name == provider.SubscriberType() &&
|
||||
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == provider.DisplayName() &&
|
||||
o.Metadata["region"] == "" &&
|
||||
o.Metadata["btCustomerId"] == "braintree_customer_id" &&
|
||||
o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType &&
|
||||
o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber))
|
||||
o.TaxIdData.FirstOrDefault().Type == billingAddress.TaxId.Code &&
|
||||
o.TaxIdData.FirstOrDefault().Value == billingAddress.TaxId.Value))
|
||||
.Returns(expected);
|
||||
|
||||
var actual = await sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource);
|
||||
var actual = await sutProvider.Sut.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress);
|
||||
|
||||
Assert.Equivalent(expected, actual);
|
||||
}
|
||||
@@ -1219,17 +1084,11 @@ public class ProviderBillingServiceTests
|
||||
public async Task SetupCustomer_WithCard_Success(
|
||||
SutProvider<ProviderBillingService> sutProvider,
|
||||
Provider provider,
|
||||
TaxInfo taxInfo)
|
||||
BillingAddress billingAddress)
|
||||
{
|
||||
provider.Name = "MSP";
|
||||
|
||||
sutProvider.GetDependency<ITaxService>()
|
||||
.GetStripeTaxCode(Arg.Is<string>(
|
||||
p => p == taxInfo.BillingAddressCountry),
|
||||
Arg.Is<string>(p => p == taxInfo.TaxIdNumber))
|
||||
.Returns(taxInfo.TaxIdType);
|
||||
|
||||
taxInfo.BillingAddressCountry = "AD";
|
||||
billingAddress.Country = "AD";
|
||||
billingAddress.TaxId = new TaxID("es_nif", "12345678Z");
|
||||
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
|
||||
@@ -1239,30 +1098,26 @@ public class ProviderBillingServiceTests
|
||||
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
|
||||
};
|
||||
|
||||
var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.Card, "token");
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
|
||||
var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.Card, Token = "token" };
|
||||
|
||||
stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o =>
|
||||
o.Address.Country == taxInfo.BillingAddressCountry &&
|
||||
o.Address.PostalCode == taxInfo.BillingAddressPostalCode &&
|
||||
o.Address.Line1 == taxInfo.BillingAddressLine1 &&
|
||||
o.Address.Line2 == taxInfo.BillingAddressLine2 &&
|
||||
o.Address.City == taxInfo.BillingAddressCity &&
|
||||
o.Address.State == taxInfo.BillingAddressState &&
|
||||
o.Description == WebUtility.HtmlDecode(provider.BusinessName) &&
|
||||
o.Address.Country == billingAddress.Country &&
|
||||
o.Address.PostalCode == billingAddress.PostalCode &&
|
||||
o.Address.Line1 == billingAddress.Line1 &&
|
||||
o.Address.Line2 == billingAddress.Line2 &&
|
||||
o.Address.City == billingAddress.City &&
|
||||
o.Address.State == billingAddress.State &&
|
||||
o.Description == provider.DisplayBusinessName() &&
|
||||
o.Email == provider.BillingEmail &&
|
||||
o.PaymentMethod == tokenizedPaymentSource.Token &&
|
||||
o.InvoiceSettings.DefaultPaymentMethod == tokenizedPaymentSource.Token &&
|
||||
o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" &&
|
||||
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" &&
|
||||
o.InvoiceSettings.DefaultPaymentMethod == tokenizedPaymentMethod.Token &&
|
||||
o.InvoiceSettings.CustomFields.FirstOrDefault().Name == provider.SubscriberType() &&
|
||||
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == provider.DisplayName() &&
|
||||
o.Metadata["region"] == "" &&
|
||||
o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType &&
|
||||
o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber))
|
||||
o.TaxIdData.FirstOrDefault().Type == billingAddress.TaxId.Code &&
|
||||
o.TaxIdData.FirstOrDefault().Value == billingAddress.TaxId.Value))
|
||||
.Returns(expected);
|
||||
|
||||
var actual = await sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource);
|
||||
var actual = await sutProvider.Sut.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress);
|
||||
|
||||
Assert.Equivalent(expected, actual);
|
||||
}
|
||||
@@ -1271,17 +1126,11 @@ public class ProviderBillingServiceTests
|
||||
public async Task SetupCustomer_WithCard_ReverseCharge_Success(
|
||||
SutProvider<ProviderBillingService> sutProvider,
|
||||
Provider provider,
|
||||
TaxInfo taxInfo)
|
||||
BillingAddress billingAddress)
|
||||
{
|
||||
provider.Name = "MSP";
|
||||
|
||||
sutProvider.GetDependency<ITaxService>()
|
||||
.GetStripeTaxCode(Arg.Is<string>(
|
||||
p => p == taxInfo.BillingAddressCountry),
|
||||
Arg.Is<string>(p => p == taxInfo.TaxIdNumber))
|
||||
.Returns(taxInfo.TaxIdType);
|
||||
|
||||
taxInfo.BillingAddressCountry = "AD";
|
||||
billingAddress.Country = "FR"; // Non-US country to trigger reverse charge
|
||||
billingAddress.TaxId = new TaxID("fr_siren", "123456789");
|
||||
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
|
||||
@@ -1291,59 +1140,51 @@ public class ProviderBillingServiceTests
|
||||
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
|
||||
};
|
||||
|
||||
var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.Card, "token");
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge).Returns(true);
|
||||
var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.Card, Token = "token" };
|
||||
|
||||
stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o =>
|
||||
o.Address.Country == taxInfo.BillingAddressCountry &&
|
||||
o.Address.PostalCode == taxInfo.BillingAddressPostalCode &&
|
||||
o.Address.Line1 == taxInfo.BillingAddressLine1 &&
|
||||
o.Address.Line2 == taxInfo.BillingAddressLine2 &&
|
||||
o.Address.City == taxInfo.BillingAddressCity &&
|
||||
o.Address.State == taxInfo.BillingAddressState &&
|
||||
o.Description == WebUtility.HtmlDecode(provider.BusinessName) &&
|
||||
o.Address.Country == billingAddress.Country &&
|
||||
o.Address.PostalCode == billingAddress.PostalCode &&
|
||||
o.Address.Line1 == billingAddress.Line1 &&
|
||||
o.Address.Line2 == billingAddress.Line2 &&
|
||||
o.Address.City == billingAddress.City &&
|
||||
o.Address.State == billingAddress.State &&
|
||||
o.Description == provider.DisplayBusinessName() &&
|
||||
o.Email == provider.BillingEmail &&
|
||||
o.PaymentMethod == tokenizedPaymentSource.Token &&
|
||||
o.InvoiceSettings.DefaultPaymentMethod == tokenizedPaymentSource.Token &&
|
||||
o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" &&
|
||||
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" &&
|
||||
o.InvoiceSettings.DefaultPaymentMethod == tokenizedPaymentMethod.Token &&
|
||||
o.InvoiceSettings.CustomFields.FirstOrDefault().Name == provider.SubscriberType() &&
|
||||
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == provider.DisplayName() &&
|
||||
o.Metadata["region"] == "" &&
|
||||
o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType &&
|
||||
o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber &&
|
||||
o.TaxIdData.FirstOrDefault().Type == billingAddress.TaxId.Code &&
|
||||
o.TaxIdData.FirstOrDefault().Value == billingAddress.TaxId.Value &&
|
||||
o.TaxExempt == StripeConstants.TaxExempt.Reverse))
|
||||
.Returns(expected);
|
||||
|
||||
var actual = await sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource);
|
||||
var actual = await sutProvider.Sut.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress);
|
||||
|
||||
Assert.Equivalent(expected, actual);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task SetupCustomer_Throws_BadRequestException_WhenTaxIdIsInvalid(
|
||||
public async Task SetupCustomer_WithInvalidTaxId_ThrowsBadRequestException(
|
||||
SutProvider<ProviderBillingService> sutProvider,
|
||||
Provider provider,
|
||||
TaxInfo taxInfo)
|
||||
BillingAddress billingAddress)
|
||||
{
|
||||
provider.Name = "MSP";
|
||||
billingAddress.Country = "AD";
|
||||
billingAddress.TaxId = new TaxID("es_nif", "invalid_tax_id");
|
||||
|
||||
taxInfo.BillingAddressCountry = "AD";
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.Card, Token = "token" };
|
||||
|
||||
sutProvider.GetDependency<ITaxService>()
|
||||
.GetStripeTaxCode(Arg.Is<string>(
|
||||
p => p == taxInfo.BillingAddressCountry),
|
||||
Arg.Is<string>(p => p == taxInfo.TaxIdNumber))
|
||||
.Returns((string)null);
|
||||
stripeAdapter.CustomerCreateAsync(Arg.Any<CustomerCreateOptions>())
|
||||
.Throws(new StripeException("Invalid tax ID") { StripeError = new StripeError { Code = "tax_id_invalid" } });
|
||||
|
||||
var actual = await Assert.ThrowsAsync<BadRequestException>(async () =>
|
||||
await sutProvider.Sut.SetupCustomer(provider, taxInfo));
|
||||
await sutProvider.Sut.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress));
|
||||
|
||||
Assert.IsType<BadRequestException>(actual);
|
||||
Assert.Equal("billingTaxIdTypeInferenceError", actual.Message);
|
||||
Assert.Equal("Your tax ID wasn't recognized for your selected country. Please ensure your country and tax ID are valid.", actual.Message);
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -1616,8 +1457,6 @@ public class ProviderBillingServiceTests
|
||||
|
||||
var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active };
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>().SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(
|
||||
sub =>
|
||||
@@ -1694,12 +1533,10 @@ public class ProviderBillingServiceTests
|
||||
|
||||
var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active };
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
|
||||
|
||||
const string setupIntentId = "seti_123";
|
||||
|
||||
sutProvider.GetDependency<ISetupIntentCache>().Get(provider.Id).Returns(setupIntentId);
|
||||
sutProvider.GetDependency<ISetupIntentCache>().GetSetupIntentIdForSubscriber(provider.Id).Returns(setupIntentId);
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>().SetupIntentGet(setupIntentId, Arg.Is<SetupIntentGetOptions>(options =>
|
||||
options.Expand.Contains("payment_method"))).Returns(new SetupIntent
|
||||
@@ -1797,8 +1634,6 @@ public class ProviderBillingServiceTests
|
||||
|
||||
var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active };
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>().SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(
|
||||
sub =>
|
||||
@@ -1877,11 +1712,6 @@ public class ProviderBillingServiceTests
|
||||
|
||||
var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active };
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge).Returns(true);
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>().SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(
|
||||
sub =>
|
||||
@@ -1,7 +1,7 @@
|
||||
using Bit.Commercial.Core.Billing.Providers.Services;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Providers.Services;
|
||||
using Stripe;
|
||||
using Xunit;
|
||||
|
||||
@@ -295,7 +295,7 @@ public class ProjectServiceAccountsAccessPoliciesAuthorizationHandlerTests
|
||||
{
|
||||
sutProvider.GetDependency<ICurrentContext>().AccessSecretsManager(resource.OrganizationId)
|
||||
.Returns(true);
|
||||
sutProvider.GetDependency<IAccessClientQuery>().GetAccessClientAsync(default, resource.OrganizationId)
|
||||
sutProvider.GetDependency<IAccessClientQuery>().GetAccessClientAsync(default!, resource.OrganizationId)
|
||||
.ReturnsForAnyArgs((accessClientType, userId));
|
||||
}
|
||||
|
||||
|
||||
@@ -247,7 +247,7 @@ public class ServiceAccountGrantedPoliciesAuthorizationHandlerTests
|
||||
{
|
||||
sutProvider.GetDependency<ICurrentContext>().AccessSecretsManager(resource.OrganizationId)
|
||||
.Returns(true);
|
||||
sutProvider.GetDependency<IAccessClientQuery>().GetAccessClientAsync(default, resource.OrganizationId)
|
||||
sutProvider.GetDependency<IAccessClientQuery>().GetAccessClientAsync(default!, resource.OrganizationId)
|
||||
.ReturnsForAnyArgs((accessClientType, userId));
|
||||
}
|
||||
|
||||
|
||||
@@ -207,7 +207,7 @@ public class BulkSecretAuthorizationHandlerTests
|
||||
{
|
||||
sutProvider.GetDependency<ICurrentContext>().AccessSecretsManager(organizationId)
|
||||
.Returns(true);
|
||||
sutProvider.GetDependency<IAccessClientQuery>().GetAccessClientAsync(default, organizationId)
|
||||
sutProvider.GetDependency<IAccessClientQuery>().GetAccessClientAsync(default!, organizationId)
|
||||
.ReturnsForAnyArgs((accessClientType, userId));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
using System.Text.Json;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Scim.Models;
|
||||
using Bit.Scim.Users;
|
||||
using Bit.Scim.Utilities;
|
||||
@@ -101,7 +101,7 @@ public class PatchUserCommandTests
|
||||
|
||||
await sutProvider.Sut.PatchUserAsync(organizationUser.OrganizationId, organizationUser.Id, scimPatchModel);
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationService>().Received(1).RevokeUserAsync(organizationUser, EventSystemUser.SCIM);
|
||||
await sutProvider.GetDependency<IRevokeOrganizationUserCommand>().Received(1).RevokeUserAsync(organizationUser, EventSystemUser.SCIM);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -129,7 +129,7 @@ public class PatchUserCommandTests
|
||||
|
||||
await sutProvider.Sut.PatchUserAsync(organizationUser.OrganizationId, organizationUser.Id, scimPatchModel);
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationService>().Received(1).RevokeUserAsync(organizationUser, EventSystemUser.SCIM);
|
||||
await sutProvider.GetDependency<IRevokeOrganizationUserCommand>().Received(1).RevokeUserAsync(organizationUser, EventSystemUser.SCIM);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -149,7 +149,7 @@ public class PatchUserCommandTests
|
||||
await sutProvider.Sut.PatchUserAsync(organizationUser.OrganizationId, organizationUser.Id, scimPatchModel);
|
||||
|
||||
await sutProvider.GetDependency<IRestoreOrganizationUserCommand>().DidNotReceiveWithAnyArgs().RestoreUserAsync(default, EventSystemUser.SCIM);
|
||||
await sutProvider.GetDependency<IOrganizationService>().DidNotReceiveWithAnyArgs().RevokeUserAsync(default, EventSystemUser.SCIM);
|
||||
await sutProvider.GetDependency<IRevokeOrganizationUserCommand>().DidNotReceiveWithAnyArgs().RevokeUserAsync(default, EventSystemUser.SCIM);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
|
||||
@@ -53,6 +53,7 @@ services:
|
||||
- ./.data/postgres/log:/var/log/postgresql
|
||||
profiles:
|
||||
- postgres
|
||||
- ef
|
||||
|
||||
mysql:
|
||||
image: mysql:8.0
|
||||
@@ -69,6 +70,7 @@ services:
|
||||
- mysql_dev_data:/var/lib/mysql
|
||||
profiles:
|
||||
- mysql
|
||||
- ef
|
||||
|
||||
mariadb:
|
||||
image: mariadb:10
|
||||
@@ -76,13 +78,13 @@ services:
|
||||
- 4306:3306
|
||||
environment:
|
||||
MARIADB_USER: maria
|
||||
MARIADB_PASSWORD: ${MARIADB_ROOT_PASSWORD}
|
||||
MARIADB_DATABASE: vault_dev
|
||||
MARIADB_RANDOM_ROOT_PASSWORD: "true"
|
||||
volumes:
|
||||
- mariadb_dev_data:/var/lib/mysql
|
||||
profiles:
|
||||
- mariadb
|
||||
- ef
|
||||
|
||||
idp:
|
||||
image: kenchan0130/simplesamlphp:1.19.8
|
||||
@@ -99,7 +101,7 @@ services:
|
||||
- idp
|
||||
|
||||
rabbitmq:
|
||||
image: rabbitmq:4.1.0-management
|
||||
image: rabbitmq:4.1.3-management
|
||||
container_name: rabbitmq
|
||||
ports:
|
||||
- "5672:5672"
|
||||
@@ -153,5 +155,6 @@ volumes:
|
||||
mssql_dev_data:
|
||||
postgres_dev_data:
|
||||
mysql_dev_data:
|
||||
mariadb_dev_data:
|
||||
rabbitmq_data:
|
||||
redis_data:
|
||||
|
||||
28
dev/generate_openapi_files.ps1
Normal file
28
dev/generate_openapi_files.ps1
Normal 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
|
||||
}
|
||||
@@ -70,7 +70,7 @@ Foreach ($item in @(
|
||||
@($mysql, "MySQL", "MySqlMigrations", "mySql", 2),
|
||||
# MariaDB shares the MySQL connection string in the server config so they are mutually exclusive in that context.
|
||||
# However they can still be run independently for integration tests.
|
||||
@($mariadb, "MariaDB", "MySqlMigrations", "mySql", 3)
|
||||
@($mariadb, "MariaDB", "MySqlMigrations", "mySql", 4)
|
||||
)) {
|
||||
if (!$item[0] -and !$all) {
|
||||
continue
|
||||
|
||||
@@ -33,6 +33,8 @@
|
||||
"id": "<your Installation Id>",
|
||||
"key": "<your Installation Key>"
|
||||
},
|
||||
"licenseDirectory": "<full path to license directory>"
|
||||
"licenseDirectory": "<full path to license directory>",
|
||||
"enableNewDeviceVerification": true,
|
||||
"enableEmailVerification": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,12 @@
|
||||
},
|
||||
{
|
||||
"Name": "events-webhook-subscription"
|
||||
},
|
||||
{
|
||||
"Name": "events-hec-subscription"
|
||||
},
|
||||
{
|
||||
"Name": "events-datadog-subscription"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -64,6 +70,34 @@
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "integration-hec-subscription",
|
||||
"Rules": [
|
||||
{
|
||||
"Name": "hec-integration-filter",
|
||||
"Properties": {
|
||||
"FilterType": "Correlation",
|
||||
"CorrelationFilter": {
|
||||
"Label": "hec"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "integration-datadog-subscription",
|
||||
"Rules": [
|
||||
{
|
||||
"Name": "datadog-integration-filter",
|
||||
"Properties": {
|
||||
"FilterType": "Correlation",
|
||||
"CorrelationFilter": {
|
||||
"Label": "datadog"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ public class StaticClientStoreTests
|
||||
[Benchmark]
|
||||
public Client? TryGetValue()
|
||||
{
|
||||
return _store.ApiClients.TryGetValue(ClientId, out var client)
|
||||
return _store.Clients.TryGetValue(ClientId, out var client)
|
||||
? client
|
||||
: null;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BenchmarkDotNet" Version="0.14.0" />
|
||||
<PackageReference Include="BenchmarkDotNet" Version="0.15.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -9,12 +9,6 @@ const AUTH_USERNAME = __ENV.AUTH_USER_EMAIL;
|
||||
const AUTH_PASSWORD = __ENV.AUTH_USER_PASSWORD_HASH;
|
||||
|
||||
export const options = {
|
||||
ext: {
|
||||
loadimpact: {
|
||||
projectID: 3639465,
|
||||
name: "Config",
|
||||
},
|
||||
},
|
||||
scenarios: {
|
||||
constant_load: {
|
||||
executor: "constant-arrival-rate",
|
||||
|
||||
@@ -10,12 +10,6 @@ const AUTH_CLIENT_ID = __ENV.AUTH_CLIENT_ID;
|
||||
const AUTH_CLIENT_SECRET = __ENV.AUTH_CLIENT_SECRET;
|
||||
|
||||
export const options = {
|
||||
ext: {
|
||||
loadimpact: {
|
||||
projectID: 3639465,
|
||||
name: "Groups",
|
||||
},
|
||||
},
|
||||
scenarios: {
|
||||
constant_load: {
|
||||
executor: "constant-arrival-rate",
|
||||
|
||||
@@ -6,12 +6,6 @@ const AUTH_USERNAME = __ENV.AUTH_USER_EMAIL;
|
||||
const AUTH_PASSWORD = __ENV.AUTH_USER_PASSWORD_HASH;
|
||||
|
||||
export const options = {
|
||||
ext: {
|
||||
loadimpact: {
|
||||
projectID: 3639465,
|
||||
name: "Login",
|
||||
},
|
||||
},
|
||||
scenarios: {
|
||||
constant_load: {
|
||||
executor: "constant-arrival-rate",
|
||||
|
||||
@@ -9,12 +9,6 @@ const AUTH_USERNAME = __ENV.AUTH_USER_EMAIL;
|
||||
const AUTH_PASSWORD = __ENV.AUTH_USER_PASSWORD_HASH;
|
||||
|
||||
export const options = {
|
||||
ext: {
|
||||
loadimpact: {
|
||||
projectID: 3639465,
|
||||
name: "Sync",
|
||||
},
|
||||
},
|
||||
scenarios: {
|
||||
constant_load: {
|
||||
executor: "constant-arrival-rate",
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using System.Net;
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using System.Net;
|
||||
using Bit.Admin.AdminConsole.Models;
|
||||
using Bit.Admin.Enums;
|
||||
using Bit.Admin.Services;
|
||||
@@ -6,6 +9,7 @@ using Bit.Admin.Utilities;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
|
||||
using Bit.Core.AdminConsole.Providers.Interfaces;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Enums;
|
||||
@@ -29,7 +33,6 @@ namespace Bit.Admin.AdminConsole.Controllers;
|
||||
[Authorize]
|
||||
public class OrganizationsController : Controller
|
||||
{
|
||||
private readonly IOrganizationService _organizationService;
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly IOrganizationUserRepository _organizationUserRepository;
|
||||
private readonly IOrganizationConnectionRepository _organizationConnectionRepository;
|
||||
@@ -52,9 +55,9 @@ public class OrganizationsController : Controller
|
||||
private readonly IProviderBillingService _providerBillingService;
|
||||
private readonly IOrganizationInitiateDeleteCommand _organizationInitiateDeleteCommand;
|
||||
private readonly IPricingClient _pricingClient;
|
||||
private readonly IResendOrganizationInviteCommand _resendOrganizationInviteCommand;
|
||||
|
||||
public OrganizationsController(
|
||||
IOrganizationService organizationService,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IOrganizationConnectionRepository organizationConnectionRepository,
|
||||
@@ -76,9 +79,9 @@ public class OrganizationsController : Controller
|
||||
IRemoveOrganizationFromProviderCommand removeOrganizationFromProviderCommand,
|
||||
IProviderBillingService providerBillingService,
|
||||
IOrganizationInitiateDeleteCommand organizationInitiateDeleteCommand,
|
||||
IPricingClient pricingClient)
|
||||
IPricingClient pricingClient,
|
||||
IResendOrganizationInviteCommand resendOrganizationInviteCommand)
|
||||
{
|
||||
_organizationService = organizationService;
|
||||
_organizationRepository = organizationRepository;
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
_organizationConnectionRepository = organizationConnectionRepository;
|
||||
@@ -101,6 +104,7 @@ public class OrganizationsController : Controller
|
||||
_providerBillingService = providerBillingService;
|
||||
_organizationInitiateDeleteCommand = organizationInitiateDeleteCommand;
|
||||
_pricingClient = pricingClient;
|
||||
_resendOrganizationInviteCommand = resendOrganizationInviteCommand;
|
||||
}
|
||||
|
||||
[RequirePermission(Permission.Org_List_View)]
|
||||
@@ -392,7 +396,7 @@ public class OrganizationsController : Controller
|
||||
var organizationUsers = await _organizationUserRepository.GetManyByOrganizationAsync(id, OrganizationUserType.Owner);
|
||||
foreach (var organizationUser in organizationUsers)
|
||||
{
|
||||
await _organizationService.ResendInviteAsync(id, null, organizationUser.Id, true);
|
||||
await _resendOrganizationInviteCommand.ResendInviteAsync(id, null, organizationUser.Id, true);
|
||||
}
|
||||
|
||||
return Json(null);
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Net;
|
||||
using Bit.Admin.AdminConsole.Models;
|
||||
using Bit.Admin.Enums;
|
||||
using Bit.Admin.Services;
|
||||
using Bit.Admin.Utilities;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations;
|
||||
@@ -18,6 +21,7 @@ using Bit.Core.Billing.Providers.Entities;
|
||||
using Bit.Core.Billing.Providers.Models;
|
||||
using Bit.Core.Billing.Providers.Repositories;
|
||||
using Bit.Core.Billing.Providers.Services;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
@@ -34,26 +38,26 @@ namespace Bit.Admin.AdminConsole.Controllers;
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public class ProvidersController : Controller
|
||||
{
|
||||
private readonly string _stripeUrl;
|
||||
private readonly string _braintreeMerchantUrl;
|
||||
private readonly string _braintreeMerchantId;
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly IResellerClientOrganizationSignUpCommand _resellerClientOrganizationSignUpCommand;
|
||||
private readonly IProviderRepository _providerRepository;
|
||||
private readonly IProviderUserRepository _providerUserRepository;
|
||||
private readonly IProviderOrganizationRepository _providerOrganizationRepository;
|
||||
private readonly IProviderService _providerService;
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
private readonly IApplicationCacheService _applicationCacheService;
|
||||
private readonly IProviderService _providerService;
|
||||
private readonly ICreateProviderCommand _createProviderCommand;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IProviderPlanRepository _providerPlanRepository;
|
||||
private readonly IProviderBillingService _providerBillingService;
|
||||
private readonly IPricingClient _pricingClient;
|
||||
private readonly IStripeAdapter _stripeAdapter;
|
||||
private readonly string _stripeUrl;
|
||||
private readonly string _braintreeMerchantUrl;
|
||||
private readonly string _braintreeMerchantId;
|
||||
private readonly IAccessControlService _accessControlService;
|
||||
private readonly ISubscriberService _subscriberService;
|
||||
|
||||
public ProvidersController(
|
||||
IOrganizationRepository organizationRepository,
|
||||
public ProvidersController(IOrganizationRepository organizationRepository,
|
||||
IResellerClientOrganizationSignUpCommand resellerClientOrganizationSignUpCommand,
|
||||
IProviderRepository providerRepository,
|
||||
IProviderUserRepository providerUserRepository,
|
||||
@@ -62,12 +66,13 @@ public class ProvidersController : Controller
|
||||
GlobalSettings globalSettings,
|
||||
IApplicationCacheService applicationCacheService,
|
||||
ICreateProviderCommand createProviderCommand,
|
||||
IFeatureService featureService,
|
||||
IProviderPlanRepository providerPlanRepository,
|
||||
IProviderBillingService providerBillingService,
|
||||
IWebHostEnvironment webHostEnvironment,
|
||||
IPricingClient pricingClient,
|
||||
IStripeAdapter stripeAdapter)
|
||||
IStripeAdapter stripeAdapter,
|
||||
IAccessControlService accessControlService,
|
||||
ISubscriberService subscriberService)
|
||||
{
|
||||
_organizationRepository = organizationRepository;
|
||||
_resellerClientOrganizationSignUpCommand = resellerClientOrganizationSignUpCommand;
|
||||
@@ -78,14 +83,15 @@ public class ProvidersController : Controller
|
||||
_globalSettings = globalSettings;
|
||||
_applicationCacheService = applicationCacheService;
|
||||
_createProviderCommand = createProviderCommand;
|
||||
_featureService = featureService;
|
||||
_providerPlanRepository = providerPlanRepository;
|
||||
_providerBillingService = providerBillingService;
|
||||
_pricingClient = pricingClient;
|
||||
_stripeAdapter = stripeAdapter;
|
||||
_accessControlService = accessControlService;
|
||||
_stripeUrl = webHostEnvironment.GetStripeUrl();
|
||||
_braintreeMerchantUrl = webHostEnvironment.GetBraintreeMerchantUrl();
|
||||
_braintreeMerchantId = globalSettings.Braintree.MerchantId;
|
||||
_subscriberService = subscriberService;
|
||||
}
|
||||
|
||||
[RequirePermission(Permission.Provider_List_View)]
|
||||
@@ -288,9 +294,31 @@ public class ProvidersController : Controller
|
||||
return View(oldModel);
|
||||
}
|
||||
|
||||
var originalProviderStatus = provider.Enabled;
|
||||
|
||||
model.ToProvider(provider);
|
||||
|
||||
await _providerRepository.ReplaceAsync(provider);
|
||||
// validate the stripe ids to prevent saving a bad one
|
||||
if (provider.IsBillable())
|
||||
{
|
||||
if (!await _subscriberService.IsValidGatewayCustomerIdAsync(provider))
|
||||
{
|
||||
var oldModel = await GetEditModel(id);
|
||||
ModelState.AddModelError(nameof(model.GatewayCustomerId), $"Invalid Gateway Customer Id: {model.GatewayCustomerId}");
|
||||
return View(oldModel);
|
||||
}
|
||||
if (!await _subscriberService.IsValidGatewaySubscriptionIdAsync(provider))
|
||||
{
|
||||
var oldModel = await GetEditModel(id);
|
||||
ModelState.AddModelError(nameof(model.GatewaySubscriptionId), $"Invalid Gateway Subscription Id: {model.GatewaySubscriptionId}");
|
||||
return View(oldModel);
|
||||
}
|
||||
}
|
||||
|
||||
provider.Enabled = _accessControlService.UserHasPermission(Permission.Provider_CheckEnabledBox)
|
||||
? model.Enabled : originalProviderStatus;
|
||||
|
||||
await _providerService.UpdateAsync(provider);
|
||||
await _applicationCacheService.UpsertProviderAbilityAsync(provider);
|
||||
|
||||
if (!provider.IsBillable())
|
||||
@@ -311,10 +339,7 @@ public class ProvidersController : Controller
|
||||
]);
|
||||
await _providerBillingService.UpdateSeatMinimums(updateMspSeatMinimumsCommand);
|
||||
|
||||
if (_featureService.IsEnabled(FeatureFlagKeys.PM199566_UpdateMSPToChargeAutomatically))
|
||||
{
|
||||
var customer = await _stripeAdapter.CustomerGetAsync(provider.GatewayCustomerId);
|
||||
|
||||
if (model.PayByInvoice != customer.ApprovedToPayByInvoice())
|
||||
{
|
||||
var approvedToPayByInvoice = model.PayByInvoice ? "1" : "0";
|
||||
@@ -326,7 +351,6 @@ public class ProvidersController : Controller
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
case ProviderType.BusinessUnit:
|
||||
{
|
||||
@@ -370,10 +394,7 @@ public class ProvidersController : Controller
|
||||
}
|
||||
|
||||
var providerPlans = await _providerPlanRepository.GetByProviderId(id);
|
||||
|
||||
var payByInvoice =
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM199566_UpdateMSPToChargeAutomatically) &&
|
||||
(await _stripeAdapter.CustomerGetAsync(provider.GatewayCustomerId)).ApprovedToPayByInvoice();
|
||||
var payByInvoice = ((await _subscriberService.GetCustomer(provider))?.ApprovedToPayByInvoice() ?? false);
|
||||
|
||||
return new ProviderEditModel(
|
||||
provider, users, providerOrganizations,
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.Billing.Enums;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.SharedWeb.Utilities;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.SharedWeb.Utilities;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Net;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user