Compare commits

..

1 Commits

2412 changed files with 23303 additions and 284545 deletions

View File

@ -1,77 +0,0 @@
# Bitwarden Server - Claude Code Configuration
## Project Context Files
**Read these files before reviewing to ensure that you fully understand the project and contributing guidelines**
1. @README.md
2. @CONTRIBUTING.md
3. @.github/PULL_REQUEST_TEMPLATE.md
## Critical Rules
- **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 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`
## Development Workflow
- Security impact assessed
- xUnit tests added / updated
- Performance impact considered
- Error handling implemented
- Breaking changes documented
- CI passes: build, test, lint
- Feature flags considered for new features
- CODEOWNERS file respected
### Key Architectural Decisions
- Use .NET nullable reference types (ADR 0024)
- TryAdd dependency injection pattern (ADR 0026)
- Authorization patterns (ADR 0022)
- OpenTelemetry for observability (ADR 0020)
- Log to standard output (ADR 0021)
## References
- [Server architecture](https://contributing.bitwarden.com/architecture/server/)
- [Architectural Decision Records (ADRs)](https://contributing.bitwarden.com/architecture/adr/)
- [Contributing guidelines](https://contributing.bitwarden.com/contributing/)
- [Setup guide](https://contributing.bitwarden.com/getting-started/server/guide/)
- [Code style](https://contributing.bitwarden.com/contributing/code-style/)
- [Bitwarden security whitepaper](https://bitwarden.com/help/bitwarden-security-white-paper/)
- [Bitwarden security definitions](https://contributing.bitwarden.com/architecture/security/definitions)

View File

@ -1,25 +0,0 @@
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 a comprehensive review including:
- Summary of changes since last review
- Critical issues found (be thorough)
- Suggested improvements (be thorough)
- Good practices observed (be concise - list only the most notable items without elaboration)
- Action items for the author
- Leverage collapsible <details> sections where appropriate for lengthy explanations or code snippets to enhance human readability
When reviewing subsequent commits:
- Track status of previously identified issues (fixed/unfixed/reopened)
- Identify NEW problems introduced since last review
- Note if fixes introduced new issues
IMPORTANT: Be comprehensive about issues and improvements. For good practices, be brief - just note what was done well without explaining why or praising excessively.

View File

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

View File

@ -123,12 +123,3 @@ csharp_style_namespace_declarations = file_scoped:warning
# Switch expression
dotnet_diagnostic.CS8509.severity = error # missing switch case for named enum value
dotnet_diagnostic.CS8524.severity = none # missing switch case for unnamed enum value
# CA2253: Named placeholders should nto be numeric values
dotnet_diagnostic.CA2253.severity = suggestion
# CA2254: Template should be a static expression
dotnet_diagnostic.CA2254.severity = warning
# CA1727: Use PascalCase for named placeholders
dotnet_diagnostic.CA1727.severity = suggestion

33
.github/CODEOWNERS vendored
View File

@ -4,18 +4,17 @@
#
# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
## 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
## Docker files have shared ownership ##
**/Dockerfile
**/*.Dockerfile
**/.dockerignore
**/entrypoint.sh
## BRE team owns these workflows ##
.github/workflows/publish.yml @bitwarden/dept-bre
## These are shared workflows ##
.github/workflows/_move_edd_db_scripts.yml
.github/workflows/_move_finalization_db_scripts.yml
.github/workflows/release.yml
# Database Operations for database changes
@ -34,10 +33,6 @@ 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
src/Core/MailTemplates/Mjml/.mjmlconfig # This change allows teams to add components within their own subdirectories without requiring a code review from UIF.
# Auth team
**/Auth @bitwarden/team-auth-dev
bitwarden_license/src/Sso @bitwarden/team-auth-dev
@ -52,7 +47,11 @@ src/Core/IdentityServer @bitwarden/team-auth-dev
**/Tools @bitwarden/team-tools-dev
# Dirt (Data Insights & Reporting) team
**/Dirt @bitwarden/team-data-insights-and-reporting-dev
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
# Vault team
**/Vault @bitwarden/team-vault-dev
@ -94,17 +93,7 @@ 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
# SDK
util/RustSdk @bitwarden/team-sdk-sme
# Multiple owners - DO NOT REMOVE (BRE)
**/packages.lock.json
Directory.Build.props
# Claude related files
.claude/ @bitwarden/team-ai-sme
.github/workflows/respond.yml @bitwarden/team-ai-sme
.github/workflows/review-code.yml @bitwarden/team-ai-sme

View File

@ -1,6 +1,7 @@
name: Bitwarden lite Deployment Bug Report
name: Bitwarden Unified Bug Report
name: Bitwarden Unified Deployment Bug Report
description: File a bug report
labels: [bug, bw-lite-deploy]
labels: [bug, bw-unified-deploy]
body:
- type: markdown
attributes:
@ -70,6 +71,15 @@ body:
mariadb:10
# Postgres Example
postgres:14
- type: textarea
id: epic-label
attributes:
label: Issue-Link
description: Link to our pinned issue, tracking all Bitwarden Unified
value: |
https://github.com/bitwarden/server/issues/2480
validations:
required: true
- type: checkboxes
id: issue-tracking-info
attributes:

View File

@ -2,7 +2,6 @@
$schema: "https://docs.renovatebot.com/renovate-schema.json",
extends: ["github>bitwarden/renovate-config"], // Extends our default configuration for pinned dependencies
enabledManagers: [
"cargo",
"dockerfile",
"docker-compose",
"github-actions",
@ -11,9 +10,16 @@
],
packageRules: [
{
groupName: "cargo minor",
matchManagers: ["cargo"],
matchUpdateTypes: ["minor"],
// 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",
@ -29,7 +35,6 @@
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.
@ -41,11 +46,6 @@
matchUpdateTypes: ["patch"],
dependencyDashboardApproval: false,
},
{
matchPackageNames: ["https://github.com/bitwarden/sdk-internal.git"],
groupName: "sdk-internal",
dependencyDashboardApproval: true
},
{
matchManagers: ["dockerfile", "docker-compose"],
commitMessagePrefix: "[deps] BRE:",
@ -64,6 +64,7 @@
},
{
matchPackageNames: [
"Azure.Extensions.AspNetCore.DataProtection.Blobs",
"DuoUniversal",
"Fido2.AspNet",
"Duende.IdentityServer",
@ -90,7 +91,12 @@
"Microsoft.AspNetCore.Mvc.Testing",
"Newtonsoft.Json",
"NSubstitute",
"Sentry.Serilog",
"Serilog.AspNetCore",
"Serilog.Extensions.Logging",
"Serilog.Extensions.Logging.File",
"Serilog.Sinks.AzureCosmosDB",
"Serilog.Sinks.SyslogMessages",
"Stripe.net",
"Swashbuckle.AspNetCore",
"Swashbuckle.AspNetCore.SwaggerGen",
@ -137,7 +143,6 @@
"AspNetCoreRateLimit",
"AspNetCoreRateLimit.Redis",
"Azure.Data.Tables",
"Azure.Extensions.AspNetCore.DataProtection.Blobs",
"Azure.Messaging.EventGrid",
"Azure.Messaging.ServiceBus",
"Azure.Storage.Blobs",

View File

@ -1,211 +0,0 @@
name: _move_edd_db_scripts
run-name: Move EDD database scripts
on:
workflow_call:
permissions:
pull-requests: write
contents: write
jobs:
setup:
name: Setup
runs-on: ubuntu-22.04
permissions:
contents: read
id-token: write
outputs:
migration_filename_prefix: ${{ steps.prefix.outputs.prefix }}
copy_edd_scripts: ${{ steps.check-script-existence.outputs.copy_edd_scripts }}
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: Retrieve secrets
id: retrieve-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
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:
token: ${{ steps.retrieve-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
persist-credentials: false
- name: Get script prefix
id: prefix
run: echo "prefix=$(date +'%Y-%m-%d')" >> "$GITHUB_OUTPUT"
- name: Check if any files in DB transition or finalization directories
id: check-script-existence
run: |
if [ -f util/Migrator/DbScripts_transition/* -o -f util/Migrator/DbScripts_finalization/* ]; then
echo "copy_edd_scripts=true" >> "$GITHUB_OUTPUT"
else
echo "copy_edd_scripts=false" >> "$GITHUB_OUTPUT"
fi
move-scripts:
name: Move scripts
runs-on: ubuntu-22.04
needs: setup
permissions:
contents: write
pull-requests: write
id-token: write
actions: read
if: ${{ needs.setup.outputs.copy_edd_scripts == 'true' }}
steps:
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
persist-credentials: true
- name: Generate branch name
id: branch_name
env:
PREFIX: ${{ needs.setup.outputs.migration_filename_prefix }}
run: echo "branch_name=move_edd_db_scripts_$PREFIX" >> "$GITHUB_OUTPUT"
- name: "Create branch"
env:
BRANCH: ${{ steps.branch_name.outputs.branch_name }}
run: git switch -c "$BRANCH"
- name: Move scripts and finalization database schema
id: move-files
env:
PREFIX: ${{ needs.setup.outputs.migration_filename_prefix }}
run: |
# 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
for src_dir in ${src_dirs//,/ }; do
for file in "$src_dir"/*; do
filenumber=$(printf "%02d" $i)
filename=$(basename "$file")
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
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: Retrieve secrets
id: retrieve-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: "bitwarden-ci"
secrets: "github-gpg-private-key,
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@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 }}
git_user_signingkey: true
git_commit_gpgsign: true
- name: Commit and push changes
id: commit
env:
BRANCH_NAME: ${{ steps.branch_name.outputs.branch_name }}
run: |
git config --local user.email "106330231+bitwarden-devops-bot@users.noreply.github.com"
git config --local user.name "bitwarden-devops-bot"
if [ -n "$(git status --porcelain)" ]; then
git add .
git commit -m "Move EDD database scripts" -a
git push -u origin "${BRANCH_NAME}"
echo "pr_needed=true" >> "$GITHUB_OUTPUT"
else
echo "No changes to commit!";
echo "pr_needed=false" >> "$GITHUB_OUTPUT"
echo "### :mega: No changes to commit! PR was ommited." >> "$GITHUB_STEP_SUMMARY"
fi
- name: Create PR for ${{ steps.branch_name.outputs.branch_name }}
if: ${{ steps.commit.outputs.pr_needed == 'true' }}
id: create-pr
env:
BRANCH: ${{ steps.branch_name.outputs.branch_name }}
GH_TOKEN: ${{ github.token }}
MOVED_FILES: ${{ steps.move-files.outputs.moved_files }}
TITLE: "Move EDD database scripts"
run: |
PR_URL=$(gh pr create --title "$TITLE" \
--base "main" \
--head "$BRANCH" \
--label "automated pr" \
--body "
Automated movement of EDD database scripts.
Files moved:
$(echo -e "$MOVED_FILES")
")
echo "pr_url=${PR_URL}" >> "$GITHUB_OUTPUT"
- name: Notify Slack about creation of PR
if: ${{ steps.commit.outputs.pr_needed == 'true' }}
uses: act10ns/slack@44541246747a30eb3102d87f7a4cc5471b0ffb7d # v2.1.0
env:
SLACK_WEBHOOK_URL: ${{ steps.retrieve-secrets.outputs.devops-alerts-slack-webhook-url }}
with:
message: "Created PR for moving EDD database scripts: ${{ steps.create-pr.outputs.pr_url }}"
status: ${{ job.status }}

View File

@ -0,0 +1,161 @@
name: _move_finalization_db_scripts
run-name: Move finalization database scripts
on:
workflow_call:
permissions:
pull-requests: write
contents: write
jobs:
setup:
name: Setup
runs-on: ubuntu-22.04
outputs:
migration_filename_prefix: ${{ steps.prefix.outputs.prefix }}
copy_finalization_scripts: ${{ steps.check-finalization-scripts-existence.outputs.copy_finalization_scripts }}
steps:
- name: Log in to Azure
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
- name: Retrieve secrets
id: retrieve-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: "bitwarden-ci"
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
- name: Check out branch
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
token: ${{ steps.retrieve-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
- name: Get script prefix
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
run: |
if [ -f util/Migrator/DbScripts_finalization/* ]; then
echo "copy_finalization_scripts=true" >> $GITHUB_OUTPUT
else
echo "copy_finalization_scripts=false" >> $GITHUB_OUTPUT
fi
move-finalization-db-scripts:
name: Move finalization database scripts
runs-on: ubuntu-22.04
needs: setup
if: ${{ needs.setup.outputs.copy_finalization_scripts == 'true' }}
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Generate branch name
id: branch_name
env:
PREFIX: ${{ needs.setup.outputs.migration_filename_prefix }}
run: echo "branch_name=move_finalization_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
id: move-files
env:
PREFIX: ${{ needs.setup.outputs.migration_filename_prefix }}
run: |
src_dir="util/Migrator/DbScripts_finalization"
dest_dir="util/Migrator/DbScripts"
i=0
moved_files=""
for file in "$src_dir"/*; do
filenumber=$(printf "%02d" $i)
filename=$(basename "$file")
new_filename="${PREFIX}_${filenumber}_${filename}"
dest_file="$dest_dir/$new_filename"
mv "$file" "$dest_file"
moved_files="$moved_files \n $filename -> $new_filename"
i=$((i+1))
done
echo "moved_files=$moved_files" >> $GITHUB_OUTPUT
- name: Log in to Azure - production subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
- name: Retrieve secrets
id: retrieve-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: "bitwarden-ci"
secrets: "github-gpg-private-key,
github-gpg-private-key-passphrase,
devops-alerts-slack-webhook-url"
- name: Import GPG keys
uses: crazy-max/ghaction-import-gpg@cb9bde2e2525e640591a934b1fd28eef1dcaf5e5 # v6.2.0
with:
gpg_private_key: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key }}
passphrase: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key-passphrase }}
git_user_signingkey: true
git_commit_gpgsign: true
- name: Commit and push changes
id: commit
run: |
git config --local user.email "106330231+bitwarden-devops-bot@users.noreply.github.com"
git config --local user.name "bitwarden-devops-bot"
if [ -n "$(git status --porcelain)" ]; then
git add .
git commit -m "Move DbScripts_finalization to DbScripts" -a
git push -u origin ${{ steps.branch_name.outputs.branch_name }}
echo "pr_needed=true" >> $GITHUB_OUTPUT
else
echo "No changes to commit!";
echo "pr_needed=false" >> $GITHUB_OUTPUT
echo "### :mega: No changes to commit! PR was ommited." >> $GITHUB_STEP_SUMMARY
fi
- name: Create PR for ${{ steps.branch_name.outputs.branch_name }}
if: ${{ steps.commit.outputs.pr_needed == 'true' }}
id: create-pr
env:
BRANCH: ${{ steps.branch_name.outputs.branch_name }}
GH_TOKEN: ${{ github.token }}
MOVED_FILES: ${{ steps.move-files.outputs.moved_files }}
TITLE: "Move finalization 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
## Files moved:
$(echo -e "$MOVED_FILES")
")
echo "pr_url=${PR_URL}" >> $GITHUB_OUTPUT
- name: Notify Slack about creation of PR
if: ${{ steps.commit.outputs.pr_needed == 'true' }}
uses: act10ns/slack@44541246747a30eb3102d87f7a4cc5471b0ffb7d # v2.1.0
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 }}"
status: ${{ job.status }}

View File

@ -11,7 +11,7 @@ on:
types: [opened, synchronize]
workflow_call:
inputs: {}
permissions:
contents: read
@ -22,23 +22,22 @@ env:
jobs:
lint:
name: Lint
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
steps:
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false
- name: Set up .NET
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0
- name: Verify format
run: dotnet format --verify-no-changes
build-artifacts:
name: Build Docker images
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
needs:
- lint
outputs:
@ -46,7 +45,6 @@ jobs:
permissions:
security-events: write
id-token: write
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
@ -97,33 +95,34 @@ 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_CLIENT_ID != '' }}
echo "has_secrets=$has_secrets" >> "$GITHUB_OUTPUT"
has_secrets=${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL != '' }}
echo "has_secrets=$has_secrets" >> $GITHUB_OUTPUT
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false
- name: Check branch to publish
env:
PUBLISH_BRANCHES: "main,rc,hotfix-rc"
id: publish-branch-check
run: |
IFS="," read -a publish_branches <<< "$PUBLISH_BRANCHES"
IFS="," read -a publish_branches <<< $PUBLISH_BRANCHES
if [[ " ${publish_branches[*]} " =~ " ${GITHUB_REF:11} " ]]; then
echo "is_publish_branch=true" >> "$GITHUB_ENV"
echo "is_publish_branch=true" >> $GITHUB_ENV
else
echo "is_publish_branch=false" >> "$GITHUB_ENV"
echo "is_publish_branch=false" >> $GITHUB_ENV
fi
- name: Set up .NET
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0
- name: Set up Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
with:
cache: "npm"
cache-dependency-path: "**/package-lock.json"
@ -160,7 +159,7 @@ jobs:
ls -atlh ../../../
- name: Upload project artifact
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
if: ${{ matrix.dotnet }}
with:
name: ${{ matrix.project_name }}.zip
@ -169,22 +168,32 @@ jobs:
########## Set up Docker ##########
- name: Set up QEMU emulators
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
uses: docker/setup-qemu-action@68827325e0b33c7199eb31dd4e31fbe9023e06e3 # v3.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0
########## ACRs ##########
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
- name: Log in to Azure - production subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
with:
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
- 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
with:
keyvault: "bitwarden-ci"
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
########## Generate image tag and build Docker image ##########
- name: Generate Docker image tag
id: tag
@ -205,8 +214,8 @@ jobs:
IMAGE_TAG=dev
fi
echo "image_tag=$IMAGE_TAG" >> "$GITHUB_OUTPUT"
echo "### :mega: Docker Image Tag: $IMAGE_TAG" >> "$GITHUB_STEP_SUMMARY"
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
echo "### :mega: Docker Image Tag: $IMAGE_TAG" >> $GITHUB_STEP_SUMMARY
- name: Set up project name
id: setup
@ -214,7 +223,7 @@ jobs:
PROJECT_NAME=$(echo "${{ matrix.project_name }}" | awk '{print tolower($0)}')
echo "Matrix name: ${{ matrix.project_name }}"
echo "PROJECT_NAME: $PROJECT_NAME"
echo "project_name=$PROJECT_NAME" >> "$GITHUB_OUTPUT"
echo "project_name=$PROJECT_NAME" >> $GITHUB_OUTPUT
- name: Generate image tags(s)
id: image-tags
@ -224,16 +233,16 @@ jobs:
SHA: ${{ github.sha }}
run: |
TAGS="${_AZ_REGISTRY}/${PROJECT_NAME}:${IMAGE_TAG}"
echo "primary_tag=$TAGS" >> "$GITHUB_OUTPUT"
echo "primary_tag=$TAGS" >> $GITHUB_OUTPUT
if [[ "${IMAGE_TAG}" == "dev" ]]; then
SHORT_SHA=$(git rev-parse --short "${SHA}")
SHORT_SHA=$(git rev-parse --short ${SHA})
TAGS=$TAGS",${_AZ_REGISTRY}/${PROJECT_NAME}:dev-${SHORT_SHA}"
fi
echo "tags=$TAGS" >> "$GITHUB_OUTPUT"
echo "tags=$TAGS" >> $GITHUB_OUTPUT
- name: Build Docker image
id: build-artifacts
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
uses: docker/build-push-action@67a2d409c0a876cbe6b11854e3e25193efe4e62d # v6.12.0
with:
context: .
file: ${{ matrix.base_path }}/${{ matrix.project_name }}/Dockerfile
@ -243,10 +252,12 @@ jobs:
linux/arm64
push: true
tags: ${{ steps.image-tags.outputs.tags }}
secrets: |
"GH_PAT=${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}"
- name: Install Cosign
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3.8.2
uses: sigstore/cosign-installer@dc72c7d5c4d10cd6bcb8cf6e3fd625a9e5e537da # v3.7.0
- name: Sign image with Cosign
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
@ -254,58 +265,48 @@ jobs:
DIGEST: ${{ steps.build-artifacts.outputs.digest }}
TAGS: ${{ steps.image-tags.outputs.tags }}
run: |
IFS=',' read -r -a tags_array <<< "${TAGS}"
images=()
for tag in "${tags_array[@]}"; do
images+=("${tag}@${DIGEST}")
IFS="," read -a tags <<< "${TAGS}"
images=""
for tag in "${tags[@]}"; do
images+="${tag}@${DIGEST} "
done
cosign sign --yes ${images[@]}
echo "images=${images[*]}" >> "$GITHUB_OUTPUT"
cosign sign --yes ${images}
- name: Scan Docker image
id: container-scan
uses: anchore/scan-action@f6601287cdb1efc985d6b765bbf99cb4c0ac29d8 # v7.0.0
uses: anchore/scan-action@abae793926ec39a78ab18002bc7fc45bbbd94342 # v6.0.0
with:
image: ${{ steps.image-tags.outputs.primary_tag }}
fail-build: false
output-format: sarif
- name: Upload Grype results to GitHub
uses: github/codeql-action/upload-sarif@e12f0178983d466f2f6028f5cc7a6d786fd97f4b # v4.31.4
uses: github/codeql-action/upload-sarif@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3.28.8
with:
sarif_file: ${{ steps.container-scan.outputs.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 }}
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
upload:
name: Upload
runs-on: ubuntu-22.04
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
with:
ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false
- name: Set up .NET
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
- name: Log in to Azure - production subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
with:
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
- name: Log in to ACR - production subscription
run: az acr login -n "$_AZ_REGISTRY" --only-show-errors
run: az acr login -n $_AZ_REGISTRY --only-show-errors
- name: Make Docker stubs
if: |
@ -328,35 +329,32 @@ jobs:
STUB_OUTPUT=$(pwd)/docker-stub
# Run setup
docker run -i --rm --name setup -v "$STUB_OUTPUT/US:/bitwarden" "$SETUP_IMAGE" \
docker run -i --rm --name setup -v $STUB_OUTPUT/US:/bitwarden $SETUP_IMAGE \
/app/Setup -stub 1 -install 1 -domain bitwarden.example.com -os lin -cloud-region US
docker run -i --rm --name setup -v "$STUB_OUTPUT/EU:/bitwarden" "$SETUP_IMAGE" \
docker run -i --rm --name setup -v $STUB_OUTPUT/EU:/bitwarden $SETUP_IMAGE \
/app/Setup -stub 1 -install 1 -domain bitwarden.example.com -os lin -cloud-region EU
sudo chown -R "$(whoami):$(whoami)" "$STUB_OUTPUT"
sudo chown -R $(whoami):$(whoami) $STUB_OUTPUT
# Remove extra directories and files
rm -rf "$STUB_OUTPUT/US/letsencrypt"
rm -rf "$STUB_OUTPUT/EU/letsencrypt"
rm "$STUB_OUTPUT/US/env/uid.env" "$STUB_OUTPUT/US/config.yml"
rm "$STUB_OUTPUT/EU/env/uid.env" "$STUB_OUTPUT/EU/config.yml"
rm -rf $STUB_OUTPUT/US/letsencrypt
rm -rf $STUB_OUTPUT/EU/letsencrypt
rm $STUB_OUTPUT/US/env/uid.env $STUB_OUTPUT/US/config.yml
rm $STUB_OUTPUT/EU/env/uid.env $STUB_OUTPUT/EU/config.yml
# Create uid environment files
touch "$STUB_OUTPUT/US/env/uid.env"
touch "$STUB_OUTPUT/EU/env/uid.env"
touch $STUB_OUTPUT/US/env/uid.env
touch $STUB_OUTPUT/EU/env/uid.env
# Zip up the Docker stub files
cd docker-stub/US; zip -r ../../docker-stub-US.zip ./*; cd ../..
cd docker-stub/EU; zip -r ../../docker-stub-EU.zip ./*; cd ../..
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
cd docker-stub/US; zip -r ../../docker-stub-US.zip *; cd ../..
cd docker-stub/EU; zip -r ../../docker-stub-EU.zip *; cd ../..
- name: Upload Docker stub US artifact
if: |
github.event_name != 'pull_request'
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: docker-stub-US.zip
path: docker-stub-US.zip
@ -366,33 +364,72 @@ jobs:
if: |
github.event_name != 'pull_request'
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: docker-stub-EU.zip
path: docker-stub-EU.zip
if-no-files-found: error
- name: Build Swagger files
- name: Build Public API Swagger
run: |
cd ./dev
pwsh ./generate_openapi_files.ps1
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"
- name: Upload Public API Swagger artifact
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: swagger.json
path: api.public.json
path: swagger.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@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: internal.json
path: api.json
path: internal.json
if-no-files-found: error
- name: Upload Identity Swagger artifact
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: identity.json
path: identity.json
@ -400,7 +437,7 @@ jobs:
build-mssqlmigratorutility:
name: Build MSSQL migrator utility
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
needs:
- lint
defaults:
@ -419,10 +456,9 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false
- name: Set up .NET
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0
- name: Print environment
run: |
@ -438,7 +474,7 @@ jobs:
- name: Upload project artifact for Windows
if: ${{ contains(matrix.target, 'win') == true }}
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: MsSqlMigratorUtility-${{ matrix.target }}
path: util/MsSqlMigratorUtility/obj/build-output/publish/MsSqlMigratorUtility.exe
@ -446,7 +482,7 @@ jobs:
- name: Upload project artifact
if: ${{ contains(matrix.target, 'win') == false }}
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: MsSqlMigratorUtility-${{ matrix.target }}
path: util/MsSqlMigratorUtility/obj/build-output/publish/MsSqlMigratorUtility
@ -457,47 +493,31 @@ jobs:
if: |
github.event_name != 'pull_request'
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
needs:
- build-artifacts
permissions:
id-token: write
steps:
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
- name: Log in to Azure - CI subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
with:
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
- name: Get Azure Key Vault secrets
id: get-kv-secrets
- name: Retrieve GitHub PAT secrets
id: retrieve-secret-pat
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: gh-org-bitwarden
secrets: "BW-GHAPP-ID,BW-GHAPP-KEY"
keyvault: "bitwarden-ci"
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Generate GH App token
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
id: app-token
- name: Trigger self-host build
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
owner: ${{ github.repository_owner }}
repositories: self-host
- name: Trigger Bitwarden lite build
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
github-token: ${{ steps.app-token.outputs.token }}
github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
script: |
await github.rest.actions.createWorkflowDispatch({
owner: 'bitwarden',
repo: 'self-host',
workflow_id: 'build-bitwarden-lite.yml',
workflow_id: 'build-unified.yml',
ref: 'main',
inputs: {
server_branch: process.env.GITHUB_REF
@ -510,39 +530,23 @@ jobs:
runs-on: ubuntu-22.04
needs:
- build-artifacts
permissions:
id-token: write
steps:
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
- name: Log in to Azure - CI subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
with:
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
- name: Get Azure Key Vault secrets
id: get-kv-secrets
- name: Retrieve GitHub PAT secrets
id: retrieve-secret-pat
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@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
id: app-token
with:
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
owner: ${{ github.repository_owner }}
repositories: devops
keyvault: "bitwarden-ci"
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
- name: Trigger k8s deploy
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ steps.app-token.outputs.token }}
github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
script: |
await github.rest.actions.createWorkflowDispatch({
owner: 'bitwarden',
@ -568,9 +572,7 @@ jobs:
project: server
pull_request_number: ${{ github.event.number || 0 }}
secrets: inherit
permissions:
contents: read
id-token: write
permissions: read-all
check-failures:
name: Check for failures
@ -583,8 +585,6 @@ jobs:
- build-mssqlmigratorutility
- self-host-build
- trigger-k8s-deploy
permissions:
id-token: write
steps:
- name: Check if any job failed
if: |
@ -593,12 +593,11 @@ jobs:
&& contains(needs.*.result, 'failure')
run: exit 1
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
- name: Log in to Azure - CI subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
if: failure()
with:
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
- name: Retrieve secrets
id: retrieve-secrets
@ -608,9 +607,6 @@ jobs:
keyvault: "bitwarden-ci"
secrets: "devops-alerts-slack-webhook-url"
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Notify Slack on failure
uses: act10ns/slack@44541246747a30eb3102d87f7a4cc5471b0ffb7d # v2.1.0
if: failure()

View File

@ -14,8 +14,6 @@ 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
@ -23,9 +21,3 @@ jobs:
if: ${{ github.event.pull_request.head.repo.full_name != github.repository }}
uses: ./.github/workflows/build.yml
secrets: inherit
permissions:
contents: read
actions: read
id-token: write
security-events: write

View File

@ -11,18 +11,14 @@ jobs:
build-docker:
name: Remove branch-specific Docker images
runs-on: ubuntu-22.04
permissions:
id-token: write
steps:
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
- name: Log in to Azure - production subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
with:
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
- name: Log in to Azure ACR
run: az acr login -n "$_AZ_REGISTRY" --only-show-errors
run: az acr login -n $_AZ_REGISTRY --only-show-errors
########## Remove Docker images ##########
- name: Remove the Docker image from ACR
@ -45,20 +41,20 @@ jobs:
- Setup
- Sso
run: |
for SERVICE in $(echo "${SERVICES}" | yq e ".services[]" - )
for SERVICE in $(echo "${{ env.SERVICES }}" | yq e ".services[]" - )
do
SERVICE_NAME=$(echo "$SERVICE" | awk '{print tolower($0)}')
SERVICE_NAME=$(echo $SERVICE | awk '{print tolower($0)}')
IMAGE_TAG=$(echo "${REF}" | sed "s#/#-#g") # slash safe branch name
echo "[*] Checking if remote exists: $_AZ_REGISTRY/$SERVICE_NAME:$IMAGE_TAG"
TAG_EXISTS=$(
az acr repository show-tags --name "$_AZ_REGISTRY" --repository "$SERVICE_NAME" \
| jq --arg TAG "$IMAGE_TAG" -e '. | any(. == $TAG)'
az acr repository show-tags --name $_AZ_REGISTRY --repository $SERVICE_NAME \
| jq --arg $TAG "$IMAGE_TAG" -e '. | any(. == "$TAG")'
)
if [[ "$TAG_EXISTS" == "true" ]]; then
echo "[*] Tag exists. Removing tag"
az acr repository delete --name "$_AZ_REGISTRY" --image "$SERVICE_NAME:$IMAGE_TAG" --yes
az acr repository delete --name $_AZ_REGISTRY --image $SERVICE_NAME:$IMAGE_TAG --yes
else
echo "[*] Tag does not exist. No action needed"
fi
@ -66,6 +62,3 @@ jobs:
- name: Log out of Docker
run: docker logout
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main

View File

@ -9,16 +9,11 @@ jobs:
delete-rc:
name: Delete RC Branch
runs-on: ubuntu-22.04
permissions:
contents: write
id-token: write
steps:
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
- name: Login to Azure - CI Subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
with:
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
- name: Retrieve bot secrets
id: retrieve-bot-secrets
@ -27,16 +22,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: Checkout main
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: main
token: ${{ steps.retrieve-bot-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
persist-credentials: false
fetch-depth: 0
- name: Check if a RC branch exists
id: branch-check
@ -45,11 +35,11 @@ jobs:
rc_branch_check=$(git ls-remote --heads origin rc | wc -l)
if [[ "${hotfix_rc_branch_check}" -gt 0 ]]; then
echo "hotfix-rc branch exists." | tee -a "$GITHUB_STEP_SUMMARY"
echo "name=hotfix-rc" >> "$GITHUB_OUTPUT"
echo "hotfix-rc branch exists." | tee -a $GITHUB_STEP_SUMMARY
echo "name=hotfix-rc" >> $GITHUB_OUTPUT
elif [[ "${rc_branch_check}" -gt 0 ]]; then
echo "rc branch exists." | tee -a "$GITHUB_STEP_SUMMARY"
echo "name=rc" >> "$GITHUB_OUTPUT"
echo "rc branch exists." | tee -a $GITHUB_STEP_SUMMARY
echo "name=rc" >> $GITHUB_OUTPUT
fi
- name: Delete RC branch
@ -57,6 +47,6 @@ jobs:
BRANCH_NAME: ${{ steps.branch-check.outputs.name }}
run: |
if ! [[ -z "$BRANCH_NAME" ]]; then
git push --quiet origin --delete "$BRANCH_NAME"
echo "Deleted $BRANCH_NAME branch." | tee -a "$GITHUB_STEP_SUMMARY"
git push --quiet origin --delete $BRANCH_NAME
echo "Deleted $BRANCH_NAME branch." | tee -a $GITHUB_STEP_SUMMARY
fi

View File

@ -1,80 +1,61 @@
name: Collect code references
on:
on:
push:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
check-secret-access:
name: Check for secret access
check-ld-secret:
name: Check for LD secret
runs-on: ubuntu-22.04
outputs:
available: ${{ steps.check-secret-access.outputs.available }}
permissions: {}
available: ${{ steps.check-ld-secret.outputs.available }}
permissions:
contents: read
steps:
- name: Check
id: check-secret-access
id: check-ld-secret
run: |
if [ "${{ secrets.AZURE_CLIENT_ID }}" != '' ]; then
echo "available=true" >> "$GITHUB_OUTPUT";
if [ "${{ secrets.LD_ACCESS_TOKEN }}" != '' ]; then
echo "available=true" >> $GITHUB_OUTPUT;
else
echo "available=false" >> "$GITHUB_OUTPUT";
echo "available=false" >> $GITHUB_OUTPUT;
fi
refs:
name: Code reference collection
runs-on: ubuntu-22.04
needs: check-secret-access
if: ${{ needs.check-secret-access.outputs.available == 'true' }}
needs: check-ld-secret
if: ${{ needs.check-ld-secret.outputs.available == 'true' }}
permissions:
contents: read
pull-requests: write
id-token: write
steps:
- name: Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
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: ${{ steps.get-kv-secrets.outputs.LD-ACCESS-TOKEN }}
accessToken: ${{ secrets.LD_ACCESS_TOKEN }}
projKey: default
allowTags: true
- name: Add label
if: steps.collect.outputs.any-changed == 'true'
run: gh pr edit "$PR_NUMBER" --add-label feature-flag
run: gh pr edit $PR_NUMBER --add-label feature-flag
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
- name: Remove label
if: steps.collect.outputs.any-changed == 'false'
run: gh pr edit "$PR_NUMBER" --remove-label feature-flag
run: gh pr edit $PR_NUMBER --remove-label feature-flag
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}

View File

@ -17,5 +17,5 @@ jobs:
- name: Check for label
run: |
echo "PRs with the hold, needs-qa or ephemeral-environment labels cannot be merged"
echo "### :x: PRs with the hold, needs-qa or ephemeral-environment labels cannot be merged" >> "$GITHUB_STEP_SUMMARY"
echo "### :x: PRs with the hold, needs-qa or ephemeral-environment labels cannot be merged" >> $GITHUB_STEP_SUMMARY
exit 1

View File

@ -4,10 +4,6 @@ on:
pull_request:
types: [labeled]
permissions:
contents: read
id-token: write
jobs:
setup-ephemeral-environment:
name: Setup Ephemeral Environment
@ -16,5 +12,5 @@ jobs:
with:
project: server
pull_request_number: ${{ github.event.number }}
sync_environment: false
sync_environment: true
secrets: inherit

View File

@ -1,114 +0,0 @@
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
env:
DD_API_KEY: ${{ steps.get-kv-secrets.outputs.DD-API-KEY }}
run: |
docker run --detach \
--name datadog-agent \
-p 4317:4317 \
-p 5555:5555 \
-e DD_SITE=us3.datadoghq.com \
-e DD_API_KEY="${DD_API_KEY}" \
-e DD_DOGSTATSD_NON_LOCAL_TRAFFIC=1 \
-e DD_OTLP_CONFIG_RECEIVER_PROTOCOLS_GRPC_ENDPOINT=0.0.0.0:4317 \
-e DD_HEALTH_PORT=5555 \
-e HOST_PROC=/proc \
--volume /var/run/docker.sock:/var/run/docker.sock:ro \
--volume /sys/fs/cgroup/:/host/sys/fs/cgroup:ro \
--health-cmd "curl -f http://localhost:5555/health || exit 1" \
--health-interval 10s \
--health-timeout 5s \
--health-retries 10 \
--health-start-period 30s \
--pid host \
datadog/agent:7-full@sha256:7ea933dec3b8baa8c19683b1c3f6f801dbf3291f748d9ed59234accdaac4e479
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Set up k6
uses: grafana/setup-k6-action@ffe7d7290dfa715e48c2ccc924d068444c94bde2 # v1.1.0
- name: Run k6 tests
uses: grafana/run-k6-action@c6b79182b9b666aa4f630f4a6be9158ead62536e # v1.2.0
continue-on-error: false
env:
K6_OTEL_METRIC_PREFIX: k6_
K6_OTEL_GRPC_EXPORTER_INSECURE: true
# Load test specific environment variables
API_URL: ${{ env.API_ENV_URL }}
IDENTITY_URL: ${{ env.IDENTITY_ENV_URL }}
CLIENT_ID: ${{ steps.get-kv-secrets.outputs.K6-CLIENT-ID }}
AUTH_USER_EMAIL: ${{ steps.get-kv-secrets.outputs.K6-AUTH-USER-EMAIL }}
AUTH_USER_PASSWORD_HASH: ${{ steps.get-kv-secrets.outputs.K6-AUTH-USER-PASSWORD-HASH }}
with:
flags: >-
--tag test-id=${{ env.TEST_ID }}
-o experimental-opentelemetry
${{ inputs.k6-flags }}
path: ${{ env.K6_TEST_PATH }}

View File

@ -34,7 +34,6 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 2
persist-credentials: false
- name: Check for file changes
id: check-changes
@ -44,9 +43,9 @@ jobs:
for file in $MODIFIED_FILES
do
if [[ $file == *"${{ matrix.path }}"* ]]; then
echo "changes_detected=true" >> "$GITHUB_OUTPUT"
echo "changes_detected=true" >> $GITHUB_OUTPUT
break
else echo "changes_detected=false" >> "$GITHUB_OUTPUT"
else echo "changes_detected=false" >> $GITHUB_OUTPUT
fi
done

View File

@ -26,9 +26,6 @@ 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 }}
@ -36,23 +33,21 @@ jobs:
steps:
- name: Version output
id: version-output
env:
INPUT_VERSION: ${{ inputs.version }}
run: |
if [[ "${INPUT_VERSION}" == "latest" || "${INPUT_VERSION}" == "" ]]; then
if [[ "${{ inputs.version }}" == "latest" || "${{ inputs.version }}" == "" ]]; then
VERSION=$(curl "https://api.github.com/repos/bitwarden/server/releases" | jq -c '.[] | select(.tag_name) | .tag_name' | head -1 | grep -ohE '20[0-9]{2}\.([1-9]|1[0-2])\.[0-9]+')
echo "Latest Released Version: $VERSION"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> $GITHUB_OUTPUT
else
echo "Release Version: ${INPUT_VERSION}"
echo "version=${INPUT_VERSION}" >> "$GITHUB_OUTPUT"
echo "Release Version: ${{ inputs.version }}"
echo "version=${{ inputs.version }}" >> $GITHUB_OUTPUT
fi
- name: Get branch name
id: branch
run: |
BRANCH_NAME=$(basename "${GITHUB_REF}")
echo "branch-name=$BRANCH_NAME" >> "$GITHUB_OUTPUT"
BRANCH_NAME=$(basename ${{ github.ref }})
echo "branch-name=$BRANCH_NAME" >> $GITHUB_OUTPUT
- name: Create GitHub deployment
uses: chrnorm/deployment-action@55729fcebec3d284f60f5bcabbd8376437d696b1 # v2.0.7
@ -68,9 +63,6 @@ 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 }}
@ -107,9 +99,6 @@ jobs:
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
persist-credentials: false
- name: Set up project name
id: setup
@ -117,27 +106,25 @@ jobs:
PROJECT_NAME=$(echo "${{ matrix.project_name }}" | awk '{print tolower($0)}')
echo "Matrix name: ${{ matrix.project_name }}"
echo "PROJECT_NAME: $PROJECT_NAME"
echo "project_name=$PROJECT_NAME" >> "$GITHUB_OUTPUT"
echo "project_name=$PROJECT_NAME" >> $GITHUB_OUTPUT
########## ACR PROD ##########
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
- name: Log in to Azure - production subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
with:
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
- name: Log in to Azure ACR
run: az acr login -n "$_AZ_REGISTRY" --only-show-errors
run: az acr login -n $_AZ_REGISTRY --only-show-errors
- name: Pull latest project image
env:
PROJECT_NAME: ${{ steps.setup.outputs.project_name }}
run: |
if [[ "${{ inputs.publish_type }}" == "Dry Run" ]]; then
docker pull "$_AZ_REGISTRY/$PROJECT_NAME:latest"
docker pull $_AZ_REGISTRY/$PROJECT_NAME:latest
else
docker pull "$_AZ_REGISTRY/$PROJECT_NAME:$_BRANCH_NAME"
docker pull $_AZ_REGISTRY/$PROJECT_NAME:$_BRANCH_NAME
fi
- name: Tag version and latest
@ -145,10 +132,10 @@ jobs:
PROJECT_NAME: ${{ steps.setup.outputs.project_name }}
run: |
if [[ "${{ inputs.publish_type }}" == "Dry Run" ]]; then
docker tag "$_AZ_REGISTRY/$PROJECT_NAME:latest" "$_AZ_REGISTRY/$PROJECT_NAME:dryrun"
docker tag $_AZ_REGISTRY/$PROJECT_NAME:latest $_AZ_REGISTRY/$PROJECT_NAME:dryrun
else
docker tag "$_AZ_REGISTRY/$PROJECT_NAME:$_BRANCH_NAME" "$_AZ_REGISTRY/$PROJECT_NAME:$_RELEASE_VERSION"
docker tag "$_AZ_REGISTRY/$PROJECT_NAME:$_BRANCH_NAME" "$_AZ_REGISTRY/$PROJECT_NAME:latest"
docker tag $_AZ_REGISTRY/$PROJECT_NAME:$_BRANCH_NAME $_AZ_REGISTRY/$PROJECT_NAME:$_RELEASE_VERSION
docker tag $_AZ_REGISTRY/$PROJECT_NAME:$_BRANCH_NAME $_AZ_REGISTRY/$PROJECT_NAME:latest
fi
- name: Push version and latest image
@ -156,26 +143,21 @@ jobs:
PROJECT_NAME: ${{ steps.setup.outputs.project_name }}
run: |
if [[ "${{ inputs.publish_type }}" == "Dry Run" ]]; then
docker push "$_AZ_REGISTRY/$PROJECT_NAME:dryrun"
docker push $_AZ_REGISTRY/$PROJECT_NAME:dryrun
else
docker push "$_AZ_REGISTRY/$PROJECT_NAME:$_RELEASE_VERSION"
docker push "$_AZ_REGISTRY/$PROJECT_NAME:latest"
docker push $_AZ_REGISTRY/$PROJECT_NAME:$_RELEASE_VERSION
docker push $_AZ_REGISTRY/$PROJECT_NAME:latest
fi
- name: Log out of Docker
run: docker logout
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
update-deployment:
name: Update Deployment Status
runs-on: ubuntu-22.04
needs:
- setup
- publish-docker
permissions:
deployments: write
if: ${{ always() && inputs.publish_type != 'Dry Run' }}
steps:
- name: Check if any job failed

View File

@ -40,9 +40,6 @@ jobs:
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
persist-credentials: false
- name: Check release version
id: version
@ -55,8 +52,8 @@ jobs:
- name: Get branch name
id: branch
run: |
BRANCH_NAME=$(basename "${GITHUB_REF}")
echo "branch-name=$BRANCH_NAME" >> "$GITHUB_OUTPUT"
BRANCH_NAME=$(basename ${{ github.ref }})
echo "branch-name=$BRANCH_NAME" >> $GITHUB_OUTPUT
release:
name: Create GitHub release
@ -89,7 +86,7 @@ jobs:
- name: Create release
if: ${{ inputs.release_type != 'Dry Run' }}
uses: ncipollo/release-action@440c8c1cb0ed28b9f43e4d1d670870f059653174 # v1.16.0
uses: ncipollo/release-action@cdcc88a9acf3ca41c16c37bb7d21b9ad48560d87 # v1.15.0
with:
artifacts: "docker-stub-US.zip,
docker-stub-EU.zip,

View File

@ -30,7 +30,6 @@ jobs:
runs-on: ubuntu-24.04
outputs:
branch: ${{ steps.set-branch.outputs.branch }}
permissions: {}
steps:
- name: Set branch
id: set-branch
@ -45,7 +44,7 @@ jobs:
BRANCH="hotfix-rc"
fi
echo "branch=$BRANCH" >> "$GITHUB_OUTPUT"
echo "branch=$BRANCH" >> $GITHUB_OUTPUT
bump_version:
name: Bump Version
@ -55,27 +54,7 @@ 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
@ -83,19 +62,17 @@ jobs:
version: ${{ inputs.version_number_override }}
- name: Generate GH App token
uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1
uses: actions/create-github-app-token@c1a285145b9d317df6ced56c09f525b5c2b6f755 # v1.11.1
id: app-token
with:
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
permission-contents: write
app-id: ${{ secrets.BW_GHAPP_ID }}
private-key: ${{ secrets.BW_GHAPP_KEY }}
- name: Check out branch
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: main
token: ${{ steps.app-token.outputs.token }}
persist-credentials: true
- name: Configure Git
run: |
@ -111,7 +88,7 @@ jobs:
id: current-version
run: |
CURRENT_VERSION=$(xmllint -xpath "/Project/PropertyGroup/Version/text()" Directory.Build.props)
echo "version=$CURRENT_VERSION" >> "$GITHUB_OUTPUT"
echo "version=$CURRENT_VERSION" >> $GITHUB_OUTPUT
- name: Verify input version
if: ${{ inputs.version_number_override != '' }}
@ -121,15 +98,16 @@ jobs:
run: |
# Error if version has not changed.
if [[ "$NEW_VERSION" == "$CURRENT_VERSION" ]]; then
echo "Specified override version is the same as the current version." >> "$GITHUB_STEP_SUMMARY"
echo "Specified override version is the same as the current version." >> $GITHUB_STEP_SUMMARY
exit 1
fi
# Check if version is newer.
if printf '%s\n' "${CURRENT_VERSION}" "${NEW_VERSION}" | sort -C -V; then
printf '%s\n' "${CURRENT_VERSION}" "${NEW_VERSION}" | sort -C -V
if [ $? -eq 0 ]; then
echo "Version is newer than the current version."
else
echo "Version is older than the current version." >> "$GITHUB_STEP_SUMMARY"
echo "Version is older than the current version." >> $GITHUB_STEP_SUMMARY
exit 1
fi
@ -160,20 +138,15 @@ jobs:
id: set-final-version-output
env:
VERSION: ${{ inputs.version_number_override }}
BUMP_VERSION_OVERRIDE_OUTCOME: ${{ steps.bump-version-override.outcome }}
BUMP_VERSION_AUTOMATIC_OUTCOME: ${{ steps.bump-version-automatic.outcome }}
CALCULATE_NEXT_VERSION: ${{ steps.calculate-next-version.outputs.version }}
run: |
if [[ "${BUMP_VERSION_OVERRIDE_OUTCOME}" = "success" ]]; then
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
elif [[ "${BUMP_VERSION_AUTOMATIC_OUTCOME}" = "success" ]]; then
echo "version=${CALCULATE_NEXT_VERSION}" >> "$GITHUB_OUTPUT"
if [[ "${{ steps.bump-version-override.outcome }}" = "success" ]]; then
echo "version=$VERSION" >> $GITHUB_OUTPUT
elif [[ "${{ steps.bump-version-automatic.outcome }}" = "success" ]]; then
echo "version=${{ steps.calculate-next-version.outputs.version }}" >> $GITHUB_OUTPUT
fi
- name: Commit files
env:
FINAL_VERSION: ${{ steps.set-final-version-output.outputs.version }}
run: git commit -m "Bumped version to $FINAL_VERSION" -a
run: git commit -m "Bumped version to ${{ steps.set-final-version-output.outputs.version }}" -a
- name: Push changes
run: git push
@ -185,49 +158,26 @@ 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@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1
uses: actions/create-github-app-token@c1a285145b9d317df6ced56c09f525b5c2b6f755 # v1.11.1
id: app-token
with:
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
permission-contents: write
app-id: ${{ secrets.BW_GHAPP_ID }}
private-key: ${{ secrets.BW_GHAPP_KEY }}
- name: Check out target ref
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ inputs.target_ref }}
token: ${{ steps.app-token.outputs.token }}
persist-credentials: true
fetch-depth: 0
- name: Check if ${{ needs.setup.outputs.branch }} branch exists
env:
BRANCH_NAME: ${{ needs.setup.outputs.branch }}
run: |
if [[ $(git ls-remote --heads origin "$BRANCH_NAME") ]]; then
echo "$BRANCH_NAME already exists! Please delete $BRANCH_NAME before running again." >> "$GITHUB_STEP_SUMMARY"
if [[ $(git ls-remote --heads origin $BRANCH_NAME) ]]; then
echo "$BRANCH_NAME already exists! Please delete $BRANCH_NAME before running again." >> $GITHUB_STEP_SUMMARY
exit 1
fi
@ -235,11 +185,11 @@ jobs:
env:
BRANCH_NAME: ${{ needs.setup.outputs.branch }}
run: |
git switch --quiet --create "$BRANCH_NAME"
git push --quiet --set-upstream origin "$BRANCH_NAME"
git switch --quiet --create $BRANCH_NAME
git push --quiet --set-upstream origin $BRANCH_NAME
move_edd_db_scripts:
name: Move EDD database scripts
move_future_db_scripts:
name: Move finalization database scripts
needs: cut_branch
permissions: {}
uses: ./.github/workflows/_move_edd_db_scripts.yml
uses: ./.github/workflows/_move_finalization_db_scripts.yml
secrets: inherit

View File

@ -1,28 +0,0 @@
name: Respond
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
permissions: {}
jobs:
respond:
name: Respond
uses: bitwarden/gh-actions/.github/workflows/_respond.yml@main
secrets:
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
permissions:
actions: read
contents: write
id-token: write
issues: write
pull-requests: write

View File

@ -1,21 +0,0 @@
name: Code Review
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
permissions: {}
jobs:
review:
name: Review
uses: bitwarden/gh-actions/.github/workflows/_review-code.yml@main
secrets:
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
permissions:
actions: read
contents: read
id-token: write
pull-requests: write

View File

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

View File

@ -45,11 +45,9 @@ jobs:
steps:
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Set up .NET
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0
- name: Restore tools
run: dotnet tool restore
@ -62,7 +60,7 @@ jobs:
docker compose --profile mssql --profile postgres --profile mysql up -d
shell: pwsh
- name: Add MariaDB for Bitwarden lite
- name: Add MariaDB for unified
# Use a different port than MySQL
run: |
docker run --detach --name mariadb --env MARIADB_ROOT_PASSWORD=mariadb-password -p 4306:3306 mariadb:10
@ -133,7 +131,7 @@ jobs:
# Default Sqlite
BW_TEST_DATABASES__3__TYPE: "Sqlite"
BW_TEST_DATABASES__3__CONNECTIONSTRING: "Data Source=${{ runner.temp }}/test.db"
# Bitwarden lite MariaDB
# Unified MariaDB
BW_TEST_DATABASES__4__TYPE: "MySql"
BW_TEST_DATABASES__4__CONNECTIONSTRING: "server=localhost;port=4306;uid=root;pwd=mariadb-password;database=vault_dev;Allow User Variables=true"
run: dotnet test --logger "trx;LogFileName=infrastructure-test-results.trx" /p:CoverletOutputFormatter="cobertura" --collect:"XPlat Code Coverage"
@ -141,31 +139,31 @@ jobs:
- name: Print MySQL Logs
if: failure()
run: 'docker logs "$(docker ps --quiet --filter "name=mysql")"'
run: 'docker logs $(docker ps --quiet --filter "name=mysql")'
- name: Print MariaDB Logs
if: failure()
run: 'docker logs "$(docker ps --quiet --filter "name=mariadb")"'
run: 'docker logs $(docker ps --quiet --filter "name=mariadb")'
- name: Print Postgres Logs
if: failure()
run: 'docker logs "$(docker ps --quiet --filter "name=postgres")"'
run: 'docker logs $(docker ps --quiet --filter "name=postgres")'
- name: Print MSSQL Logs
if: failure()
run: 'docker logs "$(docker ps --quiet --filter "name=mssql")"'
run: 'docker logs $(docker ps --quiet --filter "name=mssql")'
- name: Report test results
uses: dorny/test-reporter@890a17cecf52a379fc869ab770a71657660be727 # v2.1.0
uses: dorny/test-reporter@6e6a65b7a0bd2c9197df7d0ae36ac5cee784230c # v2.0.0
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !cancelled() }}
with:
name: Test Results
path: "./**/*-test-results.trx"
path: "**/*-test-results.trx"
reporter: dotnet-trx
fail-on-error: true
- name: Upload to codecov.io
uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3
uses: codecov/codecov-action@1e68e06f1dbfde0e4cefc87efeba9e4643565303 # v5.1.2
- name: Docker Compose down
if: always()
@ -179,11 +177,9 @@ jobs:
steps:
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Set up .NET
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0
- name: Print environment
run: |
@ -197,7 +193,7 @@ jobs:
shell: pwsh
- name: Upload DACPAC
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: sql.dacpac
path: Sql.dacpac
@ -223,7 +219,7 @@ jobs:
shell: pwsh
- name: Report validation results
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: report.xml
path: |
@ -233,27 +229,11 @@ jobs:
- name: Validate XML
run: |
if grep -q "<Operations>" "report.xml"; then
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"
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."
exit 1
else
echo "SUCCESS: Database validation passed"
echo "Report looks good"
fi
shell: bash
@ -262,26 +242,3 @@ jobs:
working-directory: "dev"
run: docker compose down
shell: pwsh
validate-migration-naming:
name: Validate new migration naming and order
runs-on: ubuntu-22.04
steps:
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
persist-credentials: false
- name: Validate new migrations for pull request
if: github.event_name == 'pull_request'
run: |
git fetch origin main:main
pwsh dev/verify_migrations.ps1 -BaseRef main
shell: pwsh
- name: Validate new migrations for push
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
run: pwsh dev/verify_migrations.ps1 -BaseRef HEAD~1
shell: pwsh

View File

@ -28,19 +28,9 @@ jobs:
steps:
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Set up .NET
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
- name: Install rust
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable
with:
toolchain: stable
- name: Cache cargo registry
uses: Swatinem/rust-cache@f0deed1e0edfc6a9be95417288c0e1099b1eeec3 # v2.7.7
uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0
- name: Print environment
run: |
@ -59,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@890a17cecf52a379fc869ab770a71657660be727 # v2.1.0
uses: dorny/test-reporter@6e6a65b7a0bd2c9197df7d0ae36ac5cee784230c # v2.0.0
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !cancelled() }}
with:
name: Test Results
@ -68,4 +58,4 @@ jobs:
fail-on-error: true
- name: Upload to codecov.io
uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3
uses: codecov/codecov-action@1e68e06f1dbfde0e4cefc87efeba9e4643565303 # v5.1.2

14
.gitignore vendored
View File

@ -129,7 +129,7 @@ publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# TODO: Comment the next line if you want to checkin your web deploy settings
# TODO: Comment the next line if you want to checkin your web deploy settings
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
@ -214,10 +214,6 @@ bitwarden_license/src/Sso/wwwroot/assets
.idea/*
**/**.swp
.mono
src/Core/MailTemplates/Mjml/out
src/Core/MailTemplates/Mjml/out-hbs
NativeMethods.g.cs
util/RustSdk/rust/target
src/Admin/Admin.zip
src/Api/Api.zip
@ -229,11 +225,3 @@ src/Notifications/Notifications.zip
bitwarden_license/src/Portal/Portal.zip
bitwarden_license/src/Sso/Sso.zip
**/src/**/flags.json
# Generated swagger specs
/identity.json
/api.json
/api.public.json
# Serena
.serena/

View File

@ -3,31 +3,70 @@
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Version>2025.12.0</Version>
<Version>2025.7.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>
<Nullable Condition="'$(Nullable)' == '' and '$(IsTestProject)' != 'true'">enable</Nullable>
<!-- Uncomment the below line when we are ready to enable nullable repo wide -->
<!-- <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>
<MicrosoftNetTestSdkVersion>18.0.1</MicrosoftNetTestSdkVersion>
<!--
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"/>
</Exec>
</Target>
<Target Name="WriteRevision" AfterTargets="SetSourceRevisionId">
<ItemGroup>
<AssemblyAttribute Include="System.Reflection.AssemblyMetadataAttribute">
<_Parameter1>GitHash</_Parameter1>
<_Parameter2>$(SourceRevisionId)</_Parameter2>
</AssemblyAttribute>
</ItemGroup>
</Target>
</Project>

View File

@ -133,11 +133,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Seeder", "util\Seeder\Seede
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
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SSO.Test", "bitwarden_license\test\SSO.Test\SSO.Test.csproj", "{7D98784C-C253-43FB-9873-25B65C6250D6}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -342,18 +337,6 @@ Global
{17A89266-260A-4A03-81AE-C0468C6EE06E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{17A89266-260A-4A03-81AE-C0468C6EE06E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{17A89266-260A-4A03-81AE-C0468C6EE06E}.Release|Any CPU.Build.0 = Release|Any CPU
{D1513D90-E4F5-44A9-9121-5E46E3E4A3F7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D1513D90-E4F5-44A9-9121-5E46E3E4A3F7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D1513D90-E4F5-44A9-9121-5E46E3E4A3F7}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D1513D90-E4F5-44A9-9121-5E46E3E4A3F7}.Release|Any CPU.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
{7D98784C-C253-43FB-9873-25B65C6250D6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7D98784C-C253-43FB-9873-25B65C6250D6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7D98784C-C253-43FB-9873-25B65C6250D6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7D98784C-C253-43FB-9873-25B65C6250D6}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@ -408,9 +391,6 @@ Global
{3631BA42-6731-4118-A917-DAA43C5032B9} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
{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}
{7D98784C-C253-43FB-9873-25B65C6250D6} = {287CFF34-BBDB-4BC4-AF88-1E19A5A4679B}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {E01CBF68-2E20-425F-9EDB-E0A6510CA92F}

View File

@ -1,6 +1,4 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
@ -136,42 +134,46 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
Items = [new SubscriptionItemOptions { Price = plan.PasswordManager.StripeSeatPlanId, Quantity = organization.Seats }]
};
subscriptionCreateOptions.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()
};
}
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);
}
else if (organization.IsStripeEnabled())
{
var subscription = await _stripeAdapter.SubscriptionGetAsync(organization.GatewaySubscriptionId, new SubscriptionGetOptions
{
Expand = ["customer"]
});
var subscription = await _stripeAdapter.SubscriptionGetAsync(organization.GatewaySubscriptionId);
if (subscription.Status is StripeConstants.SubscriptionStatus.Canceled or StripeConstants.SubscriptionStatus.IncompleteExpired)
{
return;
}
await _stripeAdapter.CustomerUpdateAsync(subscription.CustomerId, new CustomerUpdateOptions
await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, new CustomerUpdateOptions
{
Coupon = string.Empty,
Email = organization.BillingEmail
});
if (subscription.Customer.Discount?.Coupon != null)
{
await _stripeAdapter.CustomerDeleteDiscountAsync(subscription.CustomerId);
}
await _stripeAdapter.SubscriptionUpdateAsync(organization.GatewaySubscriptionId, new SubscriptionUpdateOptions
{
CollectionMethod = StripeConstants.CollectionMethod.SendInvoice,
DaysUntilDue = 30,
DaysUntilDue = 30
});
await _subscriberService.RemovePaymentSource(organization);

View File

@ -1,7 +1,4 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
@ -12,7 +9,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.Payment.Models;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Providers.Services;
using Bit.Core.Context;
@ -35,9 +32,8 @@ public class ProviderService : IProviderService
{
private static readonly PlanType[] _resellerDisallowedOrganizationTypes = [
PlanType.Free,
PlanType.FamiliesAnnually2025,
PlanType.FamiliesAnnually2019,
PlanType.FamiliesAnnually
PlanType.FamiliesAnnually,
PlanType.FamiliesAnnually2019
];
private readonly IDataProtector _dataProtector;
@ -91,7 +87,7 @@ public class ProviderService : IProviderService
_providerClientOrganizationSignUpCommand = providerClientOrganizationSignUpCommand;
}
public async Task<Provider> CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TokenizedPaymentMethod paymentMethod, BillingAddress billingAddress)
public async Task<Provider> CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TaxInfo taxInfo, TokenizedPaymentSource tokenizedPaymentSource = null)
{
var owner = await _userService.GetUserByIdAsync(ownerUserId);
if (owner == null)
@ -116,7 +112,24 @@ public class ProviderService : IProviderService
throw new BadRequestException("Invalid owner.");
}
var customer = await _providerBillingService.SetupCustomer(provider, paymentMethod, billingAddress);
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);
provider.GatewayCustomerId = customer.Id;
var subscription = await _providerBillingService.SetupSubscription(provider);
provider.GatewaySubscriptionId = subscription.Id;
@ -136,15 +149,7 @@ 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)
@ -720,20 +725,4 @@ public class ProviderService : IProviderService
throw new BadRequestException($"Unsupported provider type {providerType}.");
}
}
private async Task UpdateClientOrganizationsEnabledStatusAsync(Guid providerId, bool enabled)
{
var providerOrganizations = await _providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId);
foreach (var providerOrganization in providerOrganizations)
{
var organization = await _organizationRepository.GetByIdAsync(providerOrganization.OrganizationId);
if (organization != null && organization.Enabled != enabled)
{
organization.Enabled = enabled;
await _organizationRepository.ReplaceAsync(organization);
await _applicationCacheService.UpsertOrganizationAbilityAsync(organization);
}
}
}
}

View File

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

View File

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

View File

@ -1,7 +1,4 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.Globalization;
using System.Globalization;
using Bit.Commercial.Core.Billing.Providers.Models;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
@ -14,7 +11,6 @@ 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;
@ -22,8 +18,10 @@ 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;
@ -37,12 +35,10 @@ 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,
@ -53,7 +49,8 @@ public class ProviderBillingService(
IProviderUserRepository providerUserRepository,
ISetupIntentCache setupIntentCache,
IStripeAdapter stripeAdapter,
ISubscriberService subscriberService)
ISubscriberService subscriberService,
ITaxService taxService)
: IProviderBillingService
{
public async Task AddExistingOrganization(
@ -62,7 +59,10 @@ 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 == InvoiceStatus.Draft)
if (!wasTrialing && subscription.LatestInvoice.Status == StripeConstants.InvoiceStatus.Draft)
{
await stripeAdapter.InvoiceFinalizeInvoiceAsync(subscription.LatestInvoiceId,
new InvoiceFinalizeOptions { AutoAdvance = true });
@ -182,8 +182,16 @@ 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
}
]
};
@ -192,8 +200,7 @@ 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);
@ -204,7 +211,6 @@ public class ProviderBillingService(
{
throw new ConflictException($"Organization '{providerOrganization.Id}' not found.");
}
organization.PlanType = newPlanType;
organization.Plan = newPlan.Name;
await organizationRepository.ReplaceAsync(organization);
@ -220,15 +226,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();
@ -261,18 +267,25 @@ public class ProviderBillingService(
}
]
},
Metadata = new Dictionary<string, string> { { "region", globalSettings.BaseServiceUri.CloudRegion } },
TaxIdData = providerTaxId == null
? null
:
[
new CustomerTaxIdDataOptions { Type = providerTaxId.Type, Value = providerTaxId.Value }
]
Metadata = new Dictionary<string, string>
{
{ "region", globalSettings.BaseServiceUri.CloudRegion }
},
TaxIdData = providerTaxId == null ? null :
[
new CustomerTaxIdDataOptions
{
Type = providerTaxId.Type,
Value = providerTaxId.Value
}
]
};
if (providerCustomer.Address is not { Country: CountryAbbreviations.UnitedStates })
var setNonUSBusinessUseToReverseCharge = featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge);
if (setNonUSBusinessUseToReverseCharge && providerCustomer.Address is not { Country: "US" })
{
customerCreateOptions.TaxExempt = TaxExempt.Reverse;
customerCreateOptions.TaxExempt = StripeConstants.TaxExempt.Reverse;
}
var customer = await stripeAdapter.CustomerCreateAsync(customerCreateOptions);
@ -334,9 +347,9 @@ public class ProviderBillingService(
.Where(pair => pair.subscription is
{
Status:
SubscriptionStatus.Active or
SubscriptionStatus.Trialing or
SubscriptionStatus.PastDue
StripeConstants.SubscriptionStatus.Active or
StripeConstants.SubscriptionStatus.Trialing or
StripeConstants.SubscriptionStatus.PastDue
}).ToList();
if (active.Count == 0)
@ -461,25 +474,34 @@ 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,
TokenizedPaymentMethod paymentMethod,
BillingAddress billingAddress)
TaxInfo taxInfo,
TokenizedPaymentSource tokenizedPaymentSource = null)
{
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 = billingAddress.Country,
PostalCode = billingAddress.PostalCode,
Line1 = billingAddress.Line1,
Line2 = billingAddress.Line2,
City = billingAddress.City,
State = billingAddress.State
Country = taxInfo.BillingAddressCountry,
PostalCode = taxInfo.BillingAddressPostalCode,
Line1 = taxInfo.BillingAddressLine1,
Line2 = taxInfo.BillingAddressLine2,
City = taxInfo.BillingAddressCity,
State = taxInfo.BillingAddressState
},
Description = provider.DisplayBusinessName(),
Email = provider.BillingEmail,
@ -496,71 +518,112 @@ public class ProviderBillingService(
}
]
},
Metadata = new Dictionary<string, string> { { "region", globalSettings.BaseServiceUri.CloudRegion } },
TaxExempt = billingAddress.Country != CountryAbbreviations.UnitedStates ? TaxExempt.Reverse : TaxExempt.None
Metadata = new Dictionary<string, string>
{
{ "region", globalSettings.BaseServiceUri.CloudRegion }
}
};
if (billingAddress.TaxId != null)
var setNonUSBusinessUseToReverseCharge = featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge);
if (setNonUSBusinessUseToReverseCharge && taxInfo.BillingAddressCountry != "US")
{
options.TaxExempt = StripeConstants.TaxExempt.Reverse;
}
if (!string.IsNullOrEmpty(taxInfo.TaxIdNumber))
{
var taxIdType = taxService.GetStripeTaxCode(
taxInfo.BillingAddressCountry,
taxInfo.TaxIdNumber);
if (taxIdType == null)
{
logger.LogWarning("Could not infer tax ID type in country '{Country}' with tax ID '{TaxID}'.",
taxInfo.BillingAddressCountry,
taxInfo.TaxIdNumber);
throw new BadRequestException("billingTaxIdTypeInferenceError");
}
options.TaxIdData =
[
new CustomerTaxIdDataOptions { Type = billingAddress.TaxId.Code, Value = billingAddress.TaxId.Value }
new CustomerTaxIdDataOptions { Type = taxIdType, Value = taxInfo.TaxIdNumber }
];
if (billingAddress.TaxId.Code == TaxIdType.SpanishNIF)
if (taxIdType == StripeConstants.TaxIdType.SpanishNIF)
{
options.TaxIdData.Add(new CustomerTaxIdDataOptions
{
Type = TaxIdType.EUVAT,
Value = $"ES{billingAddress.TaxId.Value}"
Type = StripeConstants.TaxIdType.EUVAT,
Value = $"ES{taxInfo.TaxIdNumber}"
});
}
}
if (!string.IsNullOrEmpty(provider.DiscountId))
{
options.Coupon = provider.DiscountId;
}
var requireProviderPaymentMethodDuringSetup =
featureService.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup);
var braintreeCustomerId = "";
// ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault
switch (paymentMethod.Type)
if (requireProviderPaymentMethodDuringSetup)
{
case TokenizablePaymentMethodType.BankAccount:
if (tokenizedPaymentSource is not
{
var setupIntent =
(await stripeAdapter.SetupIntentList(new SetupIntentListOptions
{
PaymentMethod = paymentMethod.Token
}))
.FirstOrDefault();
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();
}
if (setupIntent == null)
var (type, token) = tokenizedPaymentSource;
// ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault
switch (type)
{
case PaymentMethodType.BankAccount:
{
logger.LogError(
"Cannot create customer for provider ({ProviderID}) without a setup intent for their bank account",
provider.Id);
throw new BillingException();
}
var setupIntent =
(await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = token }))
.FirstOrDefault();
await setupIntentCache.Set(provider.Id, setupIntent.Id);
break;
}
case TokenizablePaymentMethodType.Card:
{
options.PaymentMethod = paymentMethod.Token;
options.InvoiceSettings.DefaultPaymentMethod = paymentMethod.Token;
break;
}
case TokenizablePaymentMethodType.PayPal:
{
braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(provider, paymentMethod.Token);
options.Metadata[BraintreeCustomerIdKey] = braintreeCustomerId;
break;
}
if (setupIntent == null)
{
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:
{
options.PaymentMethod = token;
options.InvoiceSettings.DefaultPaymentMethod = token;
break;
}
case PaymentMethodType.PayPal:
{
braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(provider, token);
options.Metadata[BraintreeCustomerIdKey] = braintreeCustomerId;
break;
}
}
}
try
{
return await stripeAdapter.CustomerCreateAsync(options);
}
catch (StripeException stripeException) when (stripeException.StripeError?.Code == ErrorCodes.TaxIdInvalid)
catch (StripeException stripeException) when (stripeException.StripeError?.Code ==
StripeConstants.ErrorCodes.TaxIdInvalid)
{
await Revert();
throw new BadRequestException(
@ -574,22 +637,25 @@ public class ProviderBillingService(
async Task Revert()
{
// ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault
switch (paymentMethod.Type)
if (requireProviderPaymentMethodDuringSetup && tokenizedPaymentSource != null)
{
case TokenizablePaymentMethodType.BankAccount:
{
var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(provider.Id);
await stripeAdapter.SetupIntentCancel(setupIntentId,
new SetupIntentCancelOptions { CancellationReason = "abandoned" });
await setupIntentCache.RemoveSetupIntentForSubscriber(provider.Id);
break;
}
case TokenizablePaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId):
{
await braintreeGateway.Customer.DeleteAsync(braintreeCustomerId);
break;
}
// ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault
switch (tokenizedPaymentSource.Type)
{
case PaymentMethodType.BankAccount:
{
var setupIntentId = await setupIntentCache.Get(provider.Id);
await stripeAdapter.SetupIntentCancel(setupIntentId,
new SetupIntentCancelOptions { CancellationReason = "abandoned" });
await setupIntentCache.Remove(provider.Id);
break;
}
case PaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId):
{
await braintreeGateway.Customer.DeleteAsync(braintreeCustomerId);
break;
}
}
}
}
}
@ -604,10 +670,9 @@ public class ProviderBillingService(
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
if (providerPlans.Count == 0)
if (providerPlans == null || 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();
}
@ -620,9 +685,7 @@ 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();
}
@ -635,17 +698,23 @@ public class ProviderBillingService(
});
}
var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(provider.Id);
var requireProviderPaymentMethodDuringSetup =
featureService.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup);
var setupIntentId = await setupIntentCache.Get(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 =
!string.IsNullOrEmpty(customer.InvoiceSettings?.DefaultPaymentMethodId) ||
customer.Metadata?.ContainsKey(BraintreeCustomerIdKey) == true ||
setupIntent?.IsUnverifiedBankAccount() == true;
requireProviderPaymentMethodDuringSetup &&
(!string.IsNullOrEmpty(customer.InvoiceSettings.DefaultPaymentMethodId) ||
customer.Metadata.ContainsKey(BraintreeCustomerIdKey) ||
setupIntent.IsUnverifiedBankAccount());
int? trialPeriodDays = provider.Type switch
{
@ -656,28 +725,43 @@ public class ProviderBillingService(
var subscriptionCreateOptions = new SubscriptionCreateOptions
{
CollectionMethod =
usePaymentMethod
? CollectionMethod.ChargeAutomatically
: CollectionMethod.SendInvoice,
CollectionMethod = usePaymentMethod ?
StripeConstants.CollectionMethod.ChargeAutomatically : StripeConstants.CollectionMethod.SendInvoice,
Customer = customer.Id,
DaysUntilDue = usePaymentMethod ? null : 30,
Discounts = !string.IsNullOrEmpty(provider.DiscountId) ? [new SubscriptionDiscountOptions { Coupon = provider.DiscountId }] : null,
Items = subscriptionItemOptionsList,
Metadata = new Dictionary<string, string> { { "providerId", provider.Id.ToString() } },
Metadata = new Dictionary<string, string>
{
{ "providerId", provider.Id.ToString() }
},
OffSession = true,
ProrationBehavior = ProrationBehavior.CreateProrations,
TrialPeriodDays = trialPeriodDays,
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations,
TrialPeriodDays = trialPeriodDays
};
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
{
var subscription = await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
if (subscription is
{
Status: SubscriptionStatus.Active or SubscriptionStatus.Trialing
Status: StripeConstants.SubscriptionStatus.Active or StripeConstants.SubscriptionStatus.Trialing
})
{
return subscription;
@ -691,11 +775,9 @@ public class ProviderBillingService(
throw new BillingException();
}
catch (StripeException stripeException) when (stripeException.StripeError?.Code ==
ErrorCodes.CustomerTaxLocationInvalid)
catch (StripeException stripeException) when (stripeException.StripeError?.Code == StripeConstants.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.");
}
}
@ -709,7 +791,7 @@ public class ProviderBillingService(
subscriberService.UpdateTaxInformation(provider, taxInformation));
await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId,
new SubscriptionUpdateOptions { CollectionMethod = CollectionMethod.ChargeAutomatically });
new SubscriptionUpdateOptions { CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically });
}
public async Task UpdateSeatMinimums(UpdateProviderSeatMinimumsCommand command)
@ -809,9 +891,13 @@ 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
}
]
});
@ -834,8 +920,7 @@ public class ProviderBillingService(
var plan = await pricingClient.GetPlanOrThrow(planType);
return providerOrganizations
.Where(providerOrganization => providerOrganization.Plan == plan.Name &&
providerOrganization.Status == OrganizationStatusType.Managed)
.Where(providerOrganization => providerOrganization.Plan == plan.Name && providerOrganization.Status == OrganizationStatusType.Managed)
.Sum(providerOrganization => providerOrganization.Seats ?? 0);
}

View File

@ -1,10 +1,12 @@
// ReSharper disable SwitchExpressionHandlesSomeKnownEnumValuesWithExceptionInDefault
#nullable enable
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.Billing;
using Bit.Core.Billing.Enums;
using Stripe;
namespace Bit.Core.Billing.Providers.Services;
namespace Bit.Commercial.Core.Billing.Providers.Services;
public static class ProviderPriceAdapter
{
@ -50,7 +52,7 @@ public static class ProviderPriceAdapter
/// <param name="planType">The plan type correlating to the desired Stripe price ID.</param>
/// <returns>A Stripe <see cref="Stripe.Price"/> ID.</returns>
/// <exception cref="BillingException">Thrown when the provider's type is not <see cref="ProviderType.Msp"/> or <see cref="ProviderType.BusinessUnit"/>.</exception>
/// <exception cref="BillingException">Thrown when the provided <paramref name="planType"/> does not relate to a Stripe price ID.</exception>
/// <exception cref="BillingException">Thrown when the provided <see cref="planType"/> does not relate to a Stripe price ID.</exception>
public static string GetPriceId(
Provider provider,
Subscription subscription,
@ -102,7 +104,7 @@ public static class ProviderPriceAdapter
/// <param name="planType">The plan type correlating to the desired Stripe price ID.</param>
/// <returns>A Stripe <see cref="Stripe.Price"/> ID.</returns>
/// <exception cref="BillingException">Thrown when the provider's type is not <see cref="ProviderType.Msp"/> or <see cref="ProviderType.BusinessUnit"/>.</exception>
/// <exception cref="BillingException">Thrown when the provided <paramref name="planType"/> does not relate to a Stripe price ID.</exception>
/// <exception cref="BillingException">Thrown when the provided <see cref="planType"/> does not relate to a Stripe price ID.</exception>
public static string GetActivePriceId(
Provider provider,
PlanType planType)

View File

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

View File

@ -1,7 +1,4 @@
// 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;
using Bit.Core.SecretsManager.Commands.Porting.Interfaces;
using Bit.Core.SecretsManager.Entities;
using Bit.Core.SecretsManager.Repositories;

View File

@ -1,9 +1,6 @@
// 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.Context;
using Bit.Core.Exceptions;
using Bit.Core.Identity;
using Bit.Core.Repositories;
using Bit.Core.SecretsManager.Commands.Projects.Interfaces;
using Bit.Core.SecretsManager.Entities;

View File

@ -1,13 +1,7 @@
// 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.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;
@ -16,21 +10,15 @@ 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,
IEventService eventService,
ICurrentContext currentContext)
IServiceAccountRepository serviceAccountRepository)
{
_accessPolicyRepository = accessPolicyRepository;
_organizationUserRepository = organizationUserRepository;
_serviceAccountRepository = serviceAccountRepository;
_eventService = eventService;
_currentContext = currentContext;
}
public async Task<ServiceAccount> CreateAsync(ServiceAccount serviceAccount, Guid userId)
@ -47,7 +35,6 @@ public class CreateServiceAccountCommand : ICreateServiceAccountCommand
Write = true,
};
await _accessPolicyRepository.CreateManyAsync(new List<BaseAccessPolicy> { accessPolicy });
await _eventService.LogServiceAccountPeopleEventAsync(user.Id, accessPolicy, EventType.ServiceAccount_UserAdded, _currentContext.IdentityClientType);
return createdServiceAccount;
}
}

View File

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

View File

@ -1,10 +1,8 @@
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;
@ -19,6 +17,5 @@ public static class ServiceCollectionExtensions
services.AddScoped<IRemoveOrganizationFromProviderCommand, RemoveOrganizationFromProviderCommand>();
services.AddTransient<IProviderBillingService, ProviderBillingService>();
services.AddTransient<IBusinessUnitConverter, BusinessUnitConverter>();
services.AddTransient<IGetProviderWarningsQuery, GetProviderWarningsQuery>();
}
}

View File

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

View File

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

View File

@ -1,94 +0,0 @@
using AutoMapper;
using Bit.Core.SecretsManager.Repositories;
using Bit.Infrastructure.EntityFramework.Repositories;
using Bit.Infrastructure.EntityFramework.SecretsManager.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace Bit.Commercial.Infrastructure.EntityFramework.SecretsManager.Repositories;
public class SecretVersionRepository : Repository<Core.SecretsManager.Entities.SecretVersion, SecretVersion, Guid>, ISecretVersionRepository
{
public SecretVersionRepository(IServiceScopeFactory serviceScopeFactory, IMapper mapper)
: base(serviceScopeFactory, mapper, db => db.SecretVersion)
{ }
public override async Task<Core.SecretsManager.Entities.SecretVersion?> GetByIdAsync(Guid id)
{
using var scope = ServiceScopeFactory.CreateScope();
var dbContext = GetDatabaseContext(scope);
var secretVersion = await dbContext.SecretVersion
.Where(sv => sv.Id == id)
.FirstOrDefaultAsync();
return Mapper.Map<Core.SecretsManager.Entities.SecretVersion>(secretVersion);
}
public async Task<IEnumerable<Core.SecretsManager.Entities.SecretVersion>> GetManyBySecretIdAsync(Guid secretId)
{
using var scope = ServiceScopeFactory.CreateScope();
var dbContext = GetDatabaseContext(scope);
var secretVersions = await dbContext.SecretVersion
.Where(sv => sv.SecretId == secretId)
.OrderByDescending(sv => sv.VersionDate)
.ToListAsync();
return Mapper.Map<List<Core.SecretsManager.Entities.SecretVersion>>(secretVersions);
}
public async Task<IEnumerable<Core.SecretsManager.Entities.SecretVersion>> GetManyByIdsAsync(IEnumerable<Guid> ids)
{
using var scope = ServiceScopeFactory.CreateScope();
var dbContext = GetDatabaseContext(scope);
var versionIds = ids.ToList();
var secretVersions = await dbContext.SecretVersion
.Where(sv => versionIds.Contains(sv.Id))
.OrderByDescending(sv => sv.VersionDate)
.ToListAsync();
return Mapper.Map<List<Core.SecretsManager.Entities.SecretVersion>>(secretVersions);
}
public override async Task<Core.SecretsManager.Entities.SecretVersion> CreateAsync(Core.SecretsManager.Entities.SecretVersion secretVersion)
{
const int maxVersionsToKeep = 10;
await using var scope = ServiceScopeFactory.CreateAsyncScope();
var dbContext = GetDatabaseContext(scope);
await using var transaction = await dbContext.Database.BeginTransactionAsync();
// Get the IDs of the most recent (maxVersionsToKeep - 1) versions to keep
var versionsToKeepIds = await dbContext.SecretVersion
.Where(sv => sv.SecretId == secretVersion.SecretId)
.OrderByDescending(sv => sv.VersionDate)
.Take(maxVersionsToKeep - 1)
.Select(sv => sv.Id)
.ToListAsync();
// Delete all versions for this secret that are not in the "keep" list
if (versionsToKeepIds.Any())
{
await dbContext.SecretVersion
.Where(sv => sv.SecretId == secretVersion.SecretId && !versionsToKeepIds.Contains(sv.Id))
.ExecuteDeleteAsync();
}
secretVersion.SetNewId();
var entity = Mapper.Map<SecretVersion>(secretVersion);
await dbContext.AddAsync(entity);
await dbContext.SaveChangesAsync();
await transaction.CommitAsync();
return secretVersion;
}
public async Task DeleteManyByIdAsync(IEnumerable<Guid> ids)
{
await using var scope = ServiceScopeFactory.CreateAsyncScope();
var dbContext = GetDatabaseContext(scope);
var secretVersionIds = ids.ToList();
await dbContext.SecretVersion
.Where(sv => secretVersionIds.Contains(sv.Id))
.ExecuteDeleteAsync();
}
}

View File

@ -10,7 +10,6 @@ public static class SecretsManagerEfServiceCollectionExtensions
{
services.AddSingleton<IAccessPolicyRepository, AccessPolicyRepository>();
services.AddSingleton<ISecretRepository, SecretRepository>();
services.AddSingleton<ISecretVersionRepository, SecretVersionRepository>();
services.AddSingleton<IProjectRepository, ProjectRepository>();
services.AddSingleton<IServiceAccountRepository, ServiceAccountRepository>();
}

View File

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

View File

@ -1,7 +1,4 @@
// 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.OrganizationFeatures.Groups.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
@ -61,15 +58,17 @@ public class GroupsController : Controller
[HttpGet("")]
public async Task<IActionResult> Get(
Guid organizationId,
[FromQuery] GetGroupsQueryParamModel model)
[FromQuery] string filter,
[FromQuery] int? count,
[FromQuery] int? startIndex)
{
var groupsListQueryResult = await _getGroupsListQuery.GetGroupsListAsync(organizationId, model);
var groupsListQueryResult = await _getGroupsListQuery.GetGroupsListAsync(organizationId, filter, count, startIndex);
var scimListResponseModel = new ScimListResponseModel<ScimGroupResponseModel>
{
Resources = groupsListQueryResult.groupList.Select(g => new ScimGroupResponseModel(g)).ToList(),
ItemsPerPage = model.Count,
ItemsPerPage = count.GetValueOrDefault(groupsListQueryResult.groupList.Count()),
TotalResults = groupsListQueryResult.totalResults,
StartIndex = model.StartIndex,
StartIndex = startIndex.GetValueOrDefault(1),
};
return Ok(scimListResponseModel);
}

View File

@ -1,12 +1,9 @@
// 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.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.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;
@ -22,28 +19,29 @@ 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,
public UsersController(
IOrganizationUserRepository organizationUserRepository,
IOrganizationService organizationService,
IGetUsersListQuery getUsersListQuery,
IRemoveOrganizationUserCommand removeOrganizationUserCommand,
IPatchUserCommand patchUserCommand,
IPostUserCommand postUserCommand,
IRestoreOrganizationUserCommand restoreOrganizationUserCommand,
IRevokeOrganizationUserCommand revokeOrganizationUserCommand)
IRestoreOrganizationUserCommand restoreOrganizationUserCommand)
{
_organizationUserRepository = organizationUserRepository;
_organizationService = organizationService;
_getUsersListQuery = getUsersListQuery;
_removeOrganizationUserCommand = removeOrganizationUserCommand;
_patchUserCommand = patchUserCommand;
_postUserCommand = postUserCommand;
_restoreOrganizationUserCommand = restoreOrganizationUserCommand;
_revokeOrganizationUserCommand = revokeOrganizationUserCommand;
}
[HttpGet("{id}")]
@ -100,7 +98,7 @@ public class UsersController : Controller
}
else if (!model.Active && orgUser.Status != OrganizationUserStatusType.Revoked)
{
await _revokeOrganizationUserCommand.RevokeUserAsync(orgUser, EventSystemUser.SCIM);
await _organizationService.RevokeUserAsync(orgUser, EventSystemUser.SCIM);
}
// Have to get full details object for response model

View File

@ -1,7 +1,7 @@
###############################################
# Build stage #
###############################################
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0-alpine3.21 AS build
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0 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-musl-x64 ; \
RID=linux-x64 ; \
elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \
RID=linux-musl-arm64 ; \
RID=linux-arm64 ; \
elif [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then \
RID=linux-musl-arm ; \
RID=linux-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-alpine3.21
FROM mcr.microsoft.com/dotnet/aspnet:8.0
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 apk add --no-cache curl \
krb5 \
icu-libs \
shadow \
&& apk add --no-cache --repository=http://dl-cdn.alpinelinux.org/alpine/edge/community gosu
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
gosu \
curl \
krb5-user \
&& rm -rf /var/lib/apt/lists/*
# Copy app from the build stage
WORKDIR /app

View File

@ -1,10 +1,6 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Repositories;
using Bit.Scim.Groups.Interfaces;
using Bit.Scim.Models;
namespace Bit.Scim.Groups;
@ -17,16 +13,10 @@ public class GetGroupsListQuery : IGetGroupsListQuery
_groupRepository = groupRepository;
}
public async Task<(IEnumerable<Group> groupList, int totalResults)> GetGroupsListAsync(
Guid organizationId, GetGroupsQueryParamModel groupQueryParams)
public async Task<(IEnumerable<Group> groupList, int totalResults)> GetGroupsListAsync(Guid organizationId, string filter, int? count, int? startIndex)
{
string nameFilter = null;
string externalIdFilter = null;
int count = groupQueryParams.Count;
int startIndex = groupQueryParams.StartIndex;
string filter = groupQueryParams.Filter;
if (!string.IsNullOrWhiteSpace(filter))
{
if (filter.StartsWith("displayName eq "))
@ -60,11 +50,11 @@ public class GetGroupsListQuery : IGetGroupsListQuery
}
totalResults = groupList.Count;
}
else if (string.IsNullOrWhiteSpace(filter))
else if (string.IsNullOrWhiteSpace(filter) && startIndex.HasValue && count.HasValue)
{
groupList = groups.OrderBy(g => g.Name)
.Skip(startIndex - 1)
.Take(count)
.Skip(startIndex.Value - 1)
.Take(count.Value)
.ToList();
totalResults = groups.Count;
}

View File

@ -1,9 +1,8 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Scim.Models;
namespace Bit.Scim.Groups.Interfaces;
public interface IGetGroupsListQuery
{
Task<(IEnumerable<Group> groupList, int totalResults)> GetGroupsListAsync(Guid organizationId, GetGroupsQueryParamModel model);
Task<(IEnumerable<Group> groupList, int totalResults)> GetGroupsListAsync(Guid organizationId, string filter, int? count, int? startIndex);
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,14 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace Bit.Scim.Models;
public class GetGroupsQueryParamModel
{
public string Filter { get; init; } = string.Empty;
[Range(1, int.MaxValue)]
public int Count { get; init; } = 50;
[Range(1, int.MaxValue)]
public int StartIndex { get; init; } = 1;
}

View File

@ -1,7 +1,5 @@
using System.ComponentModel.DataAnnotations;
namespace Bit.Scim.Models;
public class GetUsersQueryParamModel
{
public string Filter { get; init; } = string.Empty;

View File

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

View File

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

View File

@ -1,7 +1,4 @@
// 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;
namespace Bit.Scim.Models;

View File

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

View File

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

View File

@ -1,14 +1,11 @@
// 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.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;
@ -47,7 +44,7 @@ public class ScimUserRequestModel : BaseScimUserModel
return new InviteOrganizationUsersRequest(
invites:
[
new OrganizationUserInviteCommandModel(
new Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models.OrganizationUserInvite(
email: email,
externalId: ExternalIdForInvite()
)

View File

@ -11,8 +11,21 @@ public class Program
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseStartup<Startup>();
webBuilder.ConfigureLogging((hostingContext, logging) =>
logging.AddSerilog(hostingContext, (e, globalSettings) =>
{
var context = e.Properties["SourceContext"].ToString();
if (e.Properties.TryGetValue("RequestPath", out var requestPath) &&
!string.IsNullOrWhiteSpace(requestPath?.ToString()) &&
(context.Contains(".Server.Kestrel") || context.Contains(".Core.IISHttpServer")))
{
return false;
}
return e.Level >= globalSettings.MinLogLevel.ScimSettings.Default;
}));
})
.AddSerilogFileLogging()
.Build()
.Run();
}

View File

@ -8,7 +8,7 @@ using Bit.Core.Utilities;
using Bit.Scim.Context;
using Bit.Scim.Utilities;
using Bit.SharedWeb.Utilities;
using Duende.IdentityModel;
using IdentityModel;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Stripe;
@ -94,8 +94,11 @@ public class Startup
public void Configure(
IApplicationBuilder app,
IWebHostEnvironment env,
IHostApplicationLifetime appLifetime,
GlobalSettings globalSettings)
{
app.UseSerilog(env, appLifetime, globalSettings);
// Add general security headers
app.UseMiddleware<SecurityHeadersMiddleware>();

View File

@ -1,9 +1,5 @@
// 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.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Repositories;
using Bit.Scim.Models;
using Bit.Scim.Users.Interfaces;
namespace Bit.Scim.Users;

View File

@ -1,5 +1,4 @@
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Scim.Models;
namespace Bit.Scim.Users.Interfaces;

View File

@ -1,7 +1,4 @@
// 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.Models.Data.Organizations.OrganizationUsers;
using Bit.Scim.Models;
namespace Bit.Scim.Users.Interfaces;

View File

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

View File

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

View File

@ -30,7 +30,6 @@
},
"storage": {
"connectionString": "UseDevelopmentStorage=true"
},
"pricingUri": "https://billingpricing.qa.bitwarden.pw"
}
}
}

View File

@ -30,6 +30,9 @@
"connectionString": "SECRET",
"applicationCacheTopicName": "SECRET"
},
"sentry": {
"dsn": "SECRET"
},
"notificationHub": {
"connectionString": "SECRET",
"hubName": "SECRET"

View File

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

View File

@ -1,6 +1,5 @@
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;
@ -20,10 +19,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;
@ -54,7 +53,6 @@ public class AccountController : Controller
private readonly IDataProtectorTokenFactory<SsoTokenable> _dataProtector;
private readonly IOrganizationDomainRepository _organizationDomainRepository;
private readonly IRegisterUserCommand _registerUserCommand;
private readonly IFeatureService _featureService;
public AccountController(
IAuthenticationSchemeProvider schemeProvider,
@ -75,8 +73,7 @@ public class AccountController : Controller
Core.Services.IEventService eventService,
IDataProtectorTokenFactory<SsoTokenable> dataProtector,
IOrganizationDomainRepository organizationDomainRepository,
IRegisterUserCommand registerUserCommand,
IFeatureService featureService)
IRegisterUserCommand registerUserCommand)
{
_schemeProvider = schemeProvider;
_clientStore = clientStore;
@ -97,43 +94,46 @@ public class AccountController : Controller
_dataProtector = dataProtector;
_organizationDomainRepository = organizationDomainRepository;
_registerUserCommand = registerUserCommand;
_featureService = featureService;
}
[HttpGet]
public async Task<IActionResult> PreValidateAsync(string domainHint)
public async Task<IActionResult> PreValidate(string domainHint)
{
try
{
// Validate domain_hint provided
if (string.IsNullOrWhiteSpace(domainHint))
{
_logger.LogError(new ArgumentException("domainHint is required."), "domainHint not specified.");
return InvalidJson("SsoInvalidIdentifierError");
return InvalidJson("NoOrganizationIdentifierProvidedError");
}
// Validate organization exists from domain_hint
var organization = await _organizationRepository.GetByIdentifierAsync(domainHint);
if (organization is not { UseSso: true })
if (organization == null)
{
_logger.LogError("Organization not configured to use SSO.");
return InvalidJson("SsoInvalidIdentifierError");
return InvalidJson("OrganizationNotFoundByIdentifierError");
}
if (!organization.UseSso)
{
return InvalidJson("SsoNotAllowedForOrganizationError");
}
// Validate SsoConfig exists and is Enabled
var ssoConfig = await _ssoConfigRepository.GetByIdentifierAsync(domainHint);
if (ssoConfig is not { Enabled: true })
if (ssoConfig == null)
{
_logger.LogError("SsoConfig not enabled.");
return InvalidJson("SsoInvalidIdentifierError");
return InvalidJson("SsoConfigurationNotFoundForOrganizationError");
}
if (!ssoConfig.Enabled)
{
return InvalidJson("SsoNotEnabledForOrganizationError");
}
// Validate Authentication Scheme exists and is loaded (cache)
var scheme = await _schemeProvider.GetSchemeAsync(organization.Id.ToString());
if (scheme is not IDynamicAuthenticationScheme dynamicScheme)
if (scheme == null || !(scheme is IDynamicAuthenticationScheme dynamicScheme))
{
_logger.LogError("Invalid authentication scheme for organization.");
return InvalidJson("SsoInvalidIdentifierError");
return InvalidJson("NoSchemeOrHandlerForSsoConfigurationFoundError");
}
// Run scheme validation
@ -143,8 +143,13 @@ public class AccountController : Controller
}
catch (Exception ex)
{
_logger.LogError(ex, "An error occurred while validating SSO dynamic scheme.");
return InvalidJson("SsoInvalidIdentifierError");
var translatedException = _i18nService.GetLocalizedHtmlString(ex.Message);
var errorKey = "InvalidSchemeConfigurationError";
if (!translatedException.ResourceNotFound)
{
errorKey = ex.Message;
}
return InvalidJson(errorKey, translatedException.ResourceNotFound ? ex : null);
}
var tokenable = new SsoTokenable(organization, _globalSettings.Sso.SsoTokenLifetimeInSeconds);
@ -154,18 +159,15 @@ public class AccountController : Controller
}
catch (Exception ex)
{
_logger.LogError(ex, "An error occurred during SSO prevalidation.");
return InvalidJson("SsoInvalidIdentifierError");
return InvalidJson("PreValidationError", ex);
}
}
[HttpGet]
public async Task<IActionResult> LoginAsync(string returnUrl)
public async Task<IActionResult> Login(string returnUrl)
{
var context = await _interaction.GetAuthorizationContextAsync(returnUrl);
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
if (!context.Parameters.AllKeys.Contains("domain_hint") ||
string.IsNullOrWhiteSpace(context.Parameters["domain_hint"]))
{
@ -181,7 +183,6 @@ public class AccountController : Controller
var domainHint = context.Parameters["domain_hint"];
var organization = await _organizationRepository.GetByIdentifierAsync(domainHint);
#nullable restore
if (organization == null)
{
@ -238,73 +239,32 @@ public class AccountController : Controller
[HttpGet]
public async Task<IActionResult> ExternalCallback()
{
// Feature flag (PM-24579): Prevent SSO on existing non-compliant users.
var preventOrgUserLoginIfStatusInvalid =
_featureService.IsEnabled(FeatureFlagKeys.PM24579_PreventSsoOnExistingNonCompliantUsers);
// Read external identity from the temporary cookie
var result = await HttpContext.AuthenticateAsync(
AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme);
if (preventOrgUserLoginIfStatusInvalid)
if (result?.Succeeded != true)
{
if (!result.Succeeded)
{
throw new Exception(_i18nService.T("ExternalAuthenticationError"));
}
}
else
{
if (result?.Succeeded != true)
{
throw new Exception(_i18nService.T("ExternalAuthenticationError"));
}
throw new Exception(_i18nService.T("ExternalAuthenticationError"));
}
// 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 (possibleSsoLinkedUser, provider, providerUserId, claims, ssoConfigData) = await FindUserFromExternalProviderAsync(result);
// Debugging
var externalClaims = result.Principal.Claims.Select(c => $"{c.Type}: {c.Value}");
_logger.LogDebug("External claims: {@claims}", externalClaims);
// We will look these up as required (lazy resolution) to avoid multiple DB hits.
Organization? organization = null;
OrganizationUser? orgUser = null;
// The user has not authenticated with this SSO provider before.
// They could have an existing Bitwarden account in the User table though.
if (possibleSsoLinkedUser == null)
// Lookup our user and external provider info
var (user, provider, providerUserId, claims, ssoConfigData) = await FindUserFromExternalProviderAsync(result);
if (user == null)
{
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
// 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;
var (resolvedUser, foundOrganization, foundOrCreatedOrgUser) =
await CreateUserAndOrgUserConditionallyAsync(
provider,
providerUserId,
claims,
userIdentifier,
ssoConfigData);
#nullable restore
possibleSsoLinkedUser = resolvedUser;
if (preventOrgUserLoginIfStatusInvalid)
{
organization = foundOrganization;
orgUser = foundOrCreatedOrgUser;
}
// 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
var userIdentifier = result.Properties.Items.Keys.Contains("user_identifier") ?
result.Properties.Items["user_identifier"] : null;
user = await AutoProvisionUserAsync(provider, providerUserId, claims, userIdentifier, ssoConfigData);
}
if (preventOrgUserLoginIfStatusInvalid)
if (user != null)
{
User resolvedSsoLinkedUser = possibleSsoLinkedUser
?? throw new Exception(_i18nService.T("UserShouldBeFound"));
await PreventOrgUserLoginIfStatusInvalidAsync(organization, provider, orgUser, resolvedSsoLinkedUser);
// This allows us to collect any additional claims or properties
// for the specific protocols used and store them in the local auth cookie.
// this is typically used to store data needed for signout from those protocols.
@ -317,52 +277,19 @@ public class AccountController : Controller
ProcessLoginCallback(result, additionalLocalClaims, localSignInProps);
// Issue authentication cookie for user
await HttpContext.SignInAsync(
new IdentityServerUser(resolvedSsoLinkedUser.Id.ToString())
{
DisplayName = resolvedSsoLinkedUser.Email,
IdentityProvider = provider,
AdditionalClaims = additionalLocalClaims.ToArray()
}, localSignInProps);
}
else
{
// PM-24579: remove this else block with feature flag removal.
// 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 (possibleSsoLinkedUser != null)
await HttpContext.SignInAsync(new IdentityServerUser(user.Id.ToString())
{
// This allows us to collect any additional claims or properties
// for the specific protocols used and store them in the local auth cookie.
// this is typically used to store data needed for signout from those protocols.
var additionalLocalClaims = new List<Claim>();
var localSignInProps = new AuthenticationProperties
{
IsPersistent = true,
ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(1)
};
ProcessLoginCallback(result, additionalLocalClaims, localSignInProps);
// Issue authentication cookie for user
await HttpContext.SignInAsync(
new IdentityServerUser(possibleSsoLinkedUser.Id.ToString())
{
DisplayName = possibleSsoLinkedUser.Email,
IdentityProvider = provider,
AdditionalClaims = additionalLocalClaims.ToArray()
}, localSignInProps);
}
DisplayName = user.Email,
IdentityProvider = provider,
AdditionalClaims = additionalLocalClaims.ToArray()
}, localSignInProps);
}
// Delete temporary cookie used during external authentication
await HttpContext.SignOutAsync(AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme);
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
// Retrieve return URL
var returnUrl = result.Properties.Items["return_url"] ?? "~/";
#nullable restore
// Check if external login is in the context of an OIDC request
var context = await _interaction.GetAuthorizationContextAsync(returnUrl);
@ -381,10 +308,8 @@ public class AccountController : Controller
return Redirect(returnUrl);
}
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
[HttpGet]
public async Task<IActionResult> LogoutAsync(string logoutId)
public async Task<IActionResult> Logout(string logoutId)
{
// Build a model so the logged out page knows what to display
var (updatedLogoutId, redirectUri, externalAuthenticationScheme) = await GetLoggedOutDataAsync(logoutId);
@ -407,7 +332,6 @@ public class AccountController : Controller
// This triggers a redirect to the external provider for sign-out
return SignOut(new AuthenticationProperties { RedirectUri = url }, externalAuthenticationScheme);
}
if (redirectUri != null)
{
return View("Redirect", new RedirectViewModel { RedirectUrl = redirectUri });
@ -417,22 +341,10 @@ public class AccountController : Controller
return Redirect("~/");
}
}
#nullable restore
/// <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? possibleSsoUser,
string provider,
string providerUserId,
IEnumerable<Claim> claims,
SsoConfigurationData config
)> FindUserFromExternalProviderAsync(AuthenticateResult result)
private async Task<(User user, string provider, string providerUserId, IEnumerable<Claim> claims, SsoConfigurationData config)>
FindUserFromExternalProviderAsync(AuthenticateResult result)
{
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
var provider = result.Properties.Items["scheme"];
var orgId = new Guid(provider);
var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(orgId);
@ -457,10 +369,9 @@ public class AccountController : Controller
// Ensure the NameIdentifier used is not a transient name ID, if so, we need a different attribute
// for the user identifier.
static bool nameIdIsNotTransient(Claim c) => c.Type == ClaimTypes.NameIdentifier
&& (c.Properties == null
|| !c.Properties.TryGetValue(SamlPropertyKeys.ClaimFormat,
out var claimFormat)
|| claimFormat != SamlNameIdFormats.Transient);
&& (c.Properties == null
|| !c.Properties.TryGetValue(SamlPropertyKeys.ClaimFormat, out var claimFormat)
|| claimFormat != SamlNameIdFormats.Transient);
// Try to determine the unique id of the external user (issued by the provider)
// the most common claim type for that are the sub claim and the NameIdentifier
@ -475,7 +386,6 @@ public class AccountController : Controller
externalUser.FindFirst("upn") ??
externalUser.FindFirst("eppn") ??
throw new Exception(_i18nService.T("UnknownUserId"));
#nullable restore
// Remove the user id claim so we don't include it as an extra claim if/when we provision the user
var claims = externalUser.Claims.ToList();
@ -484,121 +394,112 @@ public class AccountController : Controller
// find external user
var providerUserId = userIdClaim.Value;
var possibleSsoUser = await _userRepository.GetBySsoUserAsync(providerUserId, orgId);
var user = await _userRepository.GetBySsoUserAsync(providerUserId, orgId);
return (possibleSsoUser, provider, providerUserId, claims, ssoConfigData);
return (user, provider, providerUserId, claims, ssoConfigData);
}
/// <summary>
/// This function seeks to set up the org user record or create a new user record based on the conditions
/// below.
///
/// 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="ssoConfigData">The SSO configuration for the organization.</param>
/// <returns>Guaranteed to return the user to sign in as well as the found organization and org user.</returns>
/// <exception cref="Exception">An exception if the user cannot be provisioned as requested.</exception>
private async Task<(User resolvedUser, Organization foundOrganization, OrganizationUser foundOrgUser)> CreateUserAndOrgUserConditionallyAsync(
string provider,
string providerUserId,
IEnumerable<Claim> claims,
string userIdentifier,
SsoConfigurationData ssoConfigData
)
private async Task<User> AutoProvisionUserAsync(string provider, string providerUserId,
IEnumerable<Claim> claims, string userIdentifier, SsoConfigurationData config)
{
// Try to get the email from the claims as we don't know if we have a user record yet.
var name = GetName(claims, ssoConfigData.GetAdditionalNameClaimTypes());
var email = TryGetEmailAddress(claims, ssoConfigData, providerUserId);
var name = GetName(claims, config.GetAdditionalNameClaimTypes());
var email = GetEmailAddress(claims, config.GetAdditionalEmailClaimTypes());
if (string.IsNullOrWhiteSpace(email) && providerUserId.Contains("@"))
{
email = providerUserId;
}
User? possibleExistingUser;
if (!Guid.TryParse(provider, out var orgId))
{
// TODO: support non-org (server-wide) SSO in the future?
throw new Exception(_i18nService.T("SSOProviderIsNotAnOrgId", provider));
}
User existingUser = null;
if (string.IsNullOrWhiteSpace(userIdentifier))
{
if (string.IsNullOrWhiteSpace(email))
{
throw new Exception(_i18nService.T("CannotFindEmailClaim"));
}
possibleExistingUser = await _userRepository.GetByEmailAsync(email);
existingUser = await _userRepository.GetByEmailAsync(email);
}
else
{
possibleExistingUser = await GetUserFromManualLinkingDataAsync(userIdentifier);
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"));
}
}
}
// Find the org (we error if we can't find an org because no org is not valid)
var organization = await GetOrganizationByProviderAsync(provider);
// Try to find an org user (null org user possible and valid here)
var possibleOrgUser = await GetOrganizationUserByUserAndOrgIdOrEmailAsync(possibleExistingUser, organization.Id, email);
//----------------------------------------------------
// Scenario 1: We've found the user in the User table
//----------------------------------------------------
if (possibleExistingUser != null)
OrganizationUser orgUser = null;
var organization = await _organizationRepository.GetByIdAsync(orgId);
if (organization == null)
{
User guaranteedExistingUser = possibleExistingUser;
throw new Exception(_i18nService.T("CouldNotFindOrganization", orgId));
}
if (guaranteedExistingUser.UsesKeyConnector &&
(possibleOrgUser == null || possibleOrgUser.Status == OrganizationUserStatusType.Invited))
// 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
if (existingUser != null)
{
if (existingUser.UsesKeyConnector &&
(orgUser == null || orgUser.Status == OrganizationUserStatusType.Invited))
{
throw new Exception(_i18nService.T("UserAlreadyExistsKeyConnector"));
}
OrganizationUser guaranteedOrgUser = possibleOrgUser ?? throw new Exception(_i18nService.T("UserAlreadyExistsInviteProcess"));
/*
* ----------------------------------------------------
* Critical Code Check Here
*
* We want to ensure a user is not in the invited state
* explicitly. User's in the invited state should not
* be able to authenticate via SSO.
*
* See internal doc called "Added Context for SSO Login
* Flows" for further details.
* ----------------------------------------------------
*/
if (guaranteedOrgUser.Status == OrganizationUserStatusType.Invited)
if (orgUser == null)
{
// Org User is invited must accept via email first
throw new Exception(
_i18nService.T("AcceptInviteBeforeUsingSSO", organization.DisplayName()));
// Org User is not created - no invite has been sent
throw new Exception(_i18nService.T("UserAlreadyExistsInviteProcess"));
}
// 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.
EnforceAllowedOrgUserStatus(
guaranteedOrgUser.Status,
allowedStatuses: [
OrganizationUserStatusType.Accepted,
OrganizationUserStatusType.Confirmed
],
organization.DisplayName());
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()));
}
// 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 CreateSsoUserRecordAsync(providerUserId, guaranteedExistingUser.Id, organization.Id, guaranteedOrgUser);
return (guaranteedExistingUser, organization, guaranteedOrgUser);
// Accepted or Confirmed - create SSO link and return;
await CreateSsoUserRecord(providerUserId, existingUser.Id, orgId, orgUser);
return existingUser;
}
// Before any user creation - if Org User doesn't exist at this point - make sure there are enough seats to add one
if (possibleOrgUser == null && organization.Seats.HasValue)
if (orgUser == null && organization.Seats.HasValue)
{
var occupiedSeats =
await _organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
var occupiedSeats = await _organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
var initialSeatCount = organization.Seats.Value;
var availableSeats = initialSeatCount - occupiedSeats.Total;
if (availableSeats < 1)
@ -616,10 +517,8 @@ public class AccountController : Controller
{
if (organization.Seats.Value != initialSeatCount)
{
await _organizationService.AdjustSeatsAsync(organization.Id,
initialSeatCount - organization.Seats.Value);
await _organizationService.AdjustSeatsAsync(orgId, initialSeatCount - organization.Seats.Value);
}
_logger.LogInformation(e, "SSO auto provisioning failed");
throw new Exception(_i18nService.T("NoSeatsAvailable", organization.DisplayName()));
}
@ -627,257 +526,65 @@ public class AccountController : Controller
}
// If the email domain is verified, we can mark the email as verified
if (string.IsNullOrWhiteSpace(email))
{
throw new Exception(_i18nService.T("CannotFindEmailClaim"));
}
var emailVerified = false;
var emailDomain = CoreHelpers.GetEmailDomain(email);
if (!string.IsNullOrWhiteSpace(emailDomain))
{
var organizationDomain =
await _organizationDomainRepository.GetDomainByOrgIdAndDomainNameAsync(organization.Id, emailDomain);
var organizationDomain = await _organizationDomainRepository.GetDomainByOrgIdAndDomainNameAsync(orgId, emailDomain);
emailVerified = organizationDomain?.VerifiedDate.HasValue ?? false;
}
//--------------------------------------------------
// Scenarios 2 and 3: We need to register a new user
//--------------------------------------------------
var newUser = new User
// Create user record - all existing user flows are handled above
var user = new User
{
Name = name,
Email = email,
EmailVerified = emailVerified,
ApiKey = CoreHelpers.SecureRandomString(30)
};
/*
The feature flag is checked here so that we can send the new MJML welcome email templates.
The other organization invites flows have an OrganizationUser allowing the RegisterUserCommand the ability
to fetch the Organization. The old method RegisterUser(User) here does not have that context, so we need
to use a new method RegisterSSOAutoProvisionedUserAsync(User, Organization) to send the correct email.
[PM-28057]: Prefer RegisterSSOAutoProvisionedUserAsync for SSO auto-provisioned users.
TODO: Remove Feature flag: PM-28221
*/
if (_featureService.IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates))
{
await _registerUserCommand.RegisterSSOAutoProvisionedUserAsync(newUser, organization);
}
else
{
await _registerUserCommand.RegisterUser(newUser);
}
await _registerUserCommand.RegisterUser(user);
// If the organization has 2fa policy enabled, make sure to default jit user 2fa to email
var twoFactorPolicy =
await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.TwoFactorAuthentication);
await _policyRepository.GetByOrganizationIdTypeAsync(orgId, PolicyType.TwoFactorAuthentication);
if (twoFactorPolicy != null && twoFactorPolicy.Enabled)
{
newUser.SetTwoFactorProviders(new Dictionary<TwoFactorProviderType, TwoFactorProvider>
user.SetTwoFactorProviders(new Dictionary<TwoFactorProviderType, TwoFactorProvider>
{
[TwoFactorProviderType.Email] = new TwoFactorProvider
{
MetaData = new Dictionary<string, object> { ["Email"] = newUser.Email.ToLowerInvariant() },
MetaData = new Dictionary<string, object> { ["Email"] = user.Email.ToLowerInvariant() },
Enabled = true
}
});
await _userService.UpdateTwoFactorProviderAsync(newUser, TwoFactorProviderType.Email);
await _userService.UpdateTwoFactorProviderAsync(user, TwoFactorProviderType.Email);
}
//-----------------------------------------------------------------
// 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 (possibleOrgUser == null)
// Create Org User if null or else update existing Org User
if (orgUser == null)
{
possibleOrgUser = new OrganizationUser
orgUser = new OrganizationUser
{
OrganizationId = organization.Id,
UserId = newUser.Id,
OrganizationId = orgId,
UserId = user.Id,
Type = OrganizationUserType.User,
Status = OrganizationUserStatusType.Invited
};
await _organizationUserRepository.CreateAsync(possibleOrgUser);
}
//-----------------------------------------------------------------
// 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
{
possibleOrgUser.UserId = newUser.Id;
await _organizationUserRepository.ReplaceAsync(possibleOrgUser);
}
// Create the SsoUser record to link the user to the SSO provider.
await CreateSsoUserRecordAsync(providerUserId, newUser.Id, organization.Id, possibleOrgUser);
return (newUser, organization, possibleOrgUser);
}
/// <summary>
/// Validates an organization user is allowed to log in via SSO and blocks invalid statuses.
/// Lazily resolves the organization and organization user if not provided.
/// </summary>
/// <param name="organization">The target organization; if null, resolved from provider.</param>
/// <param name="provider">The SSO scheme provider value (organization id as a GUID string).</param>
/// <param name="orgUser">The organization-user record; if null, looked up by user/org or user email for invited users.</param>
/// <param name="user">The user attempting to sign in (existing or newly provisioned).</param>
/// <exception cref="Exception">Thrown if the organization cannot be resolved from provider;
/// the organization user cannot be found; or the organization user status is not allowed.</exception>
private async Task PreventOrgUserLoginIfStatusInvalidAsync(
Organization? organization,
string provider,
OrganizationUser? orgUser,
User user)
{
// Lazily get organization if not already known
organization ??= await GetOrganizationByProviderAsync(provider);
// Lazily get the org user if not already known
orgUser ??= await GetOrganizationUserByUserAndOrgIdOrEmailAsync(
user,
organization.Id,
user.Email);
if (orgUser != null)
{
// Invited is allowed at this point because we know the user is trying to accept an org invite.
EnforceAllowedOrgUserStatus(
orgUser.Status,
allowedStatuses: [
OrganizationUserStatusType.Invited,
OrganizationUserStatusType.Accepted,
OrganizationUserStatusType.Confirmed,
],
organization.DisplayName());
await _organizationUserRepository.CreateAsync(orgUser);
}
else
{
throw new Exception(_i18nService.T("CouldNotFindOrganizationUser", user.Id, organization.Id));
}
}
private async Task<User?> GetUserFromManualLinkingDataAsync(string userIdentifier)
{
User? user = null;
var split = userIdentifier.Split(",");
if (split.Length < 2)
{
throw new Exception(_i18nService.T("InvalidUserIdentifier"));
orgUser.UserId = user.Id;
await _organizationUserRepository.ReplaceAsync(orgUser);
}
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"));
}
}
// Create sso user record
await CreateSsoUserRecord(providerUserId, user.Id, orgId, orgUser);
return user;
}
/// <summary>
/// Tries to get the organization by the provider which is org id for us as we use the scheme
/// to identify organizations - not identity providers.
/// </summary>
/// <param name="provider">Org id string from SSO scheme property</param>
/// <exception cref="Exception">Errors if the provider string is not a valid org id guid or if the org cannot be found by the id.</exception>
private async Task<Organization> GetOrganizationByProviderAsync(string provider)
{
if (!Guid.TryParse(provider, out var organizationId))
{
// TODO: support non-org (server-wide) SSO in the future?
throw new Exception(_i18nService.T("SSOProviderIsNotAnOrgId", provider));
}
var organization = await _organizationRepository.GetByIdAsync(organizationId);
if (organization == null)
{
throw new Exception(_i18nService.T("CouldNotFindOrganization", organizationId));
}
return organization;
}
/// <summary>
/// Attempts to get an <see cref="OrganizationUser"/> for a given organization
/// by first checking for an existing user relationship, and if none is found,
/// by looking up an invited user via their email address.
/// </summary>
/// <param name="user">The existing user entity to be looked up in OrganizationUsers table.</param>
/// <param name="organizationId">Organization id from the provider data.</param>
/// <param name="email">Email to use as a fallback in case of an invited user not in the Org Users
/// table yet.</param>
private async Task<OrganizationUser?> GetOrganizationUserByUserAndOrgIdOrEmailAsync(
User? user,
Guid organizationId,
string? email)
{
OrganizationUser? orgUser = null;
// Try to find OrgUser via existing User Id.
// This covers any OrganizationUser state after they have accepted an invite.
if (user != null)
{
var orgUsersByUserId = await _organizationUserRepository.GetManyByUserAsync(user.Id);
orgUser = orgUsersByUserId.SingleOrDefault(u => u.OrganizationId == organizationId);
}
// 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.
if (email != null)
{
orgUser ??= await _organizationUserRepository.GetByOrganizationEmailAsync(organizationId, email);
}
return orgUser;
}
private void EnforceAllowedOrgUserStatus(
OrganizationUserStatusType statusToCheckAgainst,
OrganizationUserStatusType[] allowedStatuses,
string organizationDisplayNameForLogging)
{
// if this status is one of the allowed ones, just return
if (allowedStatuses.Contains(statusToCheckAgainst))
{
return;
}
// otherwise throw the appropriate exception
switch (statusToCheckAgainst)
{
case OrganizationUserStatusType.Revoked:
// Revoked users may not be (auto)provisioned
throw new Exception(
_i18nService.T("OrganizationUserAccessRevoked", organizationDisplayNameForLogging));
default:
// anything else is “unknown”
throw new Exception(
_i18nService.T("OrganizationUserUnknownStatus", organizationDisplayNameForLogging));
}
}
private IActionResult InvalidJson(string errorMessageKey, Exception? ex = null)
private IActionResult InvalidJson(string errorMessageKey, Exception ex = null)
{
Response.StatusCode = ex == null ? 400 : 500;
return Json(new ErrorResponseModel(_i18nService.T(errorMessageKey))
@ -888,13 +595,13 @@ public class AccountController : Controller
});
}
private string? TryGetEmailAddressFromClaims(IEnumerable<Claim> claims, IEnumerable<string> additionalClaimTypes)
private string GetEmailAddress(IEnumerable<Claim> claims, IEnumerable<string> additionalClaimTypes)
{
var filteredClaims = claims.Where(c => !string.IsNullOrWhiteSpace(c.Value) && c.Value.Contains("@"));
var email = filteredClaims.GetFirstMatch(additionalClaimTypes.ToArray()) ??
filteredClaims.GetFirstMatch(JwtClaimTypes.Email, ClaimTypes.Email,
SamlClaimTypes.Email, "mail", "emailaddress");
filteredClaims.GetFirstMatch(JwtClaimTypes.Email, ClaimTypes.Email,
SamlClaimTypes.Email, "mail", "emailaddress");
if (!string.IsNullOrWhiteSpace(email))
{
return email;
@ -910,15 +617,13 @@ public class AccountController : Controller
return null;
}
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
private string GetName(IEnumerable<Claim> claims, IEnumerable<string> additionalClaimTypes)
{
var filteredClaims = claims.Where(c => !string.IsNullOrWhiteSpace(c.Value));
var name = filteredClaims.GetFirstMatch(additionalClaimTypes.ToArray()) ??
filteredClaims.GetFirstMatch(JwtClaimTypes.Name, ClaimTypes.Name,
SamlClaimTypes.DisplayName, SamlClaimTypes.CommonName, "displayname", "cn");
filteredClaims.GetFirstMatch(JwtClaimTypes.Name, ClaimTypes.Name,
SamlClaimTypes.DisplayName, SamlClaimTypes.CommonName, "displayname", "cn");
if (!string.IsNullOrWhiteSpace(name))
{
return name;
@ -935,10 +640,8 @@ public class AccountController : Controller
return null;
}
#nullable restore
private async Task CreateSsoUserRecordAsync(string providerUserId, Guid userId, Guid orgId,
OrganizationUser orgUser)
private async Task CreateSsoUserRecord(string providerUserId, Guid userId, Guid orgId, OrganizationUser orgUser)
{
// Delete existing SsoUser (if any) - avoids error if providerId has changed and the sso link is stale
var existingSsoUser = await _ssoUserRepository.GetByUserIdOrganizationIdAsync(orgId, userId);
@ -953,12 +656,15 @@ public class AccountController : Controller
await _eventService.LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_FirstSsoLogin);
}
var ssoUser = new SsoUser { ExternalId = providerUserId, UserId = userId, OrganizationId = orgId, };
var ssoUser = new SsoUser
{
ExternalId = providerUserId,
UserId = userId,
OrganizationId = orgId,
};
await _ssoUserRepository.CreateAsync(ssoUser);
}
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
private void ProcessLoginCallback(AuthenticateResult externalResult,
List<Claim> localClaims, AuthenticationProperties localSignInProps)
{
@ -979,6 +685,18 @@ public class AccountController : Controller
}
}
private async Task<string> GetProviderAsync(string returnUrl)
{
var context = await _interaction.GetAuthorizationContextAsync(returnUrl);
if (context?.IdP != null && await _schemeProvider.GetSchemeAsync(context.IdP) != null)
{
return context.IdP;
}
var schemes = await _schemeProvider.GetAllSchemesAsync();
var providers = schemes.Select(x => x.Name).ToList();
return providers.FirstOrDefault();
}
private async Task<(string, string, string)> GetLoggedOutDataAsync(string logoutId)
{
// Get context information (client name, post logout redirect URI and iframe for federated signout)
@ -1009,31 +727,10 @@ public class AccountController : Controller
return (logoutId, logout?.PostLogoutRedirectUri, externalAuthenticationScheme);
}
#nullable restore
/**
* Tries to get a user's email from the claims and SSO configuration data or the provider user id if
* the claims email extraction returns null.
*/
private string? TryGetEmailAddress(
IEnumerable<Claim> claims,
SsoConfigurationData config,
string providerUserId)
{
var email = TryGetEmailAddressFromClaims(claims, config.GetAdditionalEmailClaimTypes());
// If email isn't populated from claims and providerUserId has @, assume it is the email.
if (string.IsNullOrWhiteSpace(email) && providerUserId.Contains("@"))
{
email = providerUserId;
}
return email;
}
public bool IsNativeClient(DIM.AuthorizationRequest context)
{
return !context.RedirectUri.StartsWith("https", StringComparison.Ordinal)
&& !context.RedirectUri.StartsWith("http", StringComparison.Ordinal);
&& !context.RedirectUri.StartsWith("http", StringComparison.Ordinal);
}
}

View File

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

View File

@ -1,7 +1,7 @@
###############################################
# Build stage #
###############################################
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0-alpine3.21 AS build
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0 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-musl-x64 ; \
RID=linux-x64 ; \
elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \
RID=linux-musl-arm64 ; \
RID=linux-arm64 ; \
elif [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then \
RID=linux-musl-arm ; \
RID=linux-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-alpine3.21
FROM mcr.microsoft.com/dotnet/aspnet:8.0
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 apk add --no-cache curl \
krb5 \
icu-libs \
shadow \
&& apk add --no-cache --repository=http://dl-cdn.alpinelinux.org/alpine/edge/community gosu
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
gosu \
curl \
krb5-user \
&& rm -rf /var/lib/apt/lists/*
# Copy app from the build stage
WORKDIR /app

View File

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

View File

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

View File

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

View File

@ -1,4 +1,5 @@
using Bit.Core.Utilities;
using Serilog;
namespace Bit.Sso;
@ -12,8 +13,19 @@ public class Program
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseStartup<Startup>();
webBuilder.ConfigureLogging((hostingContext, logging) =>
logging.AddSerilog(hostingContext, (e, globalSettings) =>
{
var context = e.Properties["SourceContext"].ToString();
if (e.Properties.TryGetValue("RequestPath", out var requestPath) &&
!string.IsNullOrWhiteSpace(requestPath?.ToString()) &&
(context.Contains(".Server.Kestrel") || context.Contains(".Core.IISHttpServer")))
{
return false;
}
return e.Level >= globalSettings.MinLogLevel.SsoSettings.Default;
}));
})
.AddSerilogFileLogging()
.Build()
.Run();
}

View File

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

View File

@ -100,6 +100,8 @@ public class Startup
IdentityModelEventSource.ShowPII = true;
}
app.UseSerilog(env, appLifetime, globalSettings);
// Add general security headers
app.UseMiddleware<SecurityHeadersMiddleware>();
@ -155,6 +157,6 @@ public class Startup
app.UseEndpoints(endpoints => endpoints.MapDefaultControllerRoute());
// Log startup
logger.LogInformation(Constants.BypassFiltersEventId, "{Project} started.", globalSettings.ProjectName);
logger.LogInformation(Constants.BypassFiltersEventId, globalSettings.ProjectName + " started.");
}
}

View File

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

View File

@ -1,19 +1,15 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.Security.Cryptography.X509Certificates;
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;
@ -417,7 +413,7 @@ public class DynamicAuthenticationSchemeProvider : AuthenticationSchemeProvider
SPOptions = spOptions,
SignInScheme = AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme,
SignOutScheme = IdentityServerConstants.DefaultCookieAuthenticationScheme,
CookieManager = new DistributedCacheCookieManager(),
CookieManager = new IdentityServer.DistributedCacheCookieManager(),
};
options.IdentityProviders.Add(idp);

View File

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

View File

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

View File

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

View File

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

View File

@ -24,13 +24,6 @@
"storage": {
"connectionString": "UseDevelopmentStorage=true"
},
"developmentDirectory": "../../../dev",
"pricingUri": "https://billingpricing.qa.bitwarden.pw",
"mail": {
"smtp": {
"host": "localhost",
"port": 10250
}
}
"developmentDirectory": "../../../dev"
}
}

View File

@ -13,11 +13,7 @@
"mail": {
"sendGridApiKey": "SECRET",
"amazonConfigSetName": "Email",
"replyToEmail": "no-reply@bitwarden.com",
"smtp": {
"host": "localhost",
"port": 10250
}
"replyToEmail": "no-reply@bitwarden.com"
},
"identityServer": {
"certificateThumbprint": "SECRET"

View File

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

View File

@ -17,9 +17,9 @@
"css-loader": "7.1.2",
"expose-loader": "5.0.1",
"mini-css-extract-plugin": "2.9.2",
"sass": "1.93.2",
"sass": "1.88.0",
"sass-loader": "16.0.5",
"webpack": "5.102.1",
"webpack": "5.99.8",
"webpack-cli": "5.1.4"
}
},
@ -34,14 +34,18 @@
}
},
"node_modules/@jridgewell/gen-mapping": {
"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==",
"version": "0.3.8",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz",
"integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
"@jridgewell/set-array": "^1.2.1",
"@jridgewell/sourcemap-codec": "^1.4.10",
"@jridgewell/trace-mapping": "^0.3.24"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/resolve-uri": {
@ -54,10 +58,20 @@
"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.11",
"resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz",
"integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==",
"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==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -66,16 +80,16 @@
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
"integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.30",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz",
"integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==",
"version": "0.3.25",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz",
"integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -427,9 +441,9 @@
}
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
"integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==",
"dev": true,
"license": "MIT"
},
@ -441,13 +455,13 @@
"license": "MIT"
},
"node_modules/@types/node": {
"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==",
"version": "22.15.21",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.21.tgz",
"integrity": "sha512-EV/37Td6c+MgKAbkcLG6vqZ2zEYHD7bvSrzqqs2RIhbA6w3x+Dqz8MZM3sP6kGTeLrdoOgKZe+Xja7tUB2DNkQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.10.0"
"undici-types": "~6.21.0"
}
},
"node_modules/@webassemblyjs/ast": {
@ -673,12 +687,11 @@
"license": "Apache-2.0"
},
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"version": "8.14.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
"integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -686,26 +699,12 @@
"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",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@ -748,16 +747,6 @@
"ajv": "^8.8.2"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.8.18",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.18.tgz",
"integrity": "sha512-UYmTpOBwgPScZpS4A+YbapwWuBwasxvO/2IOHArSsAhL/+ZdmATBXTex3t+l2hXwLVYK382ibr/nKoY9GKe86w==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.js"
}
},
"node_modules/bootstrap": {
"version": "5.3.6",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.6.tgz",
@ -792,9 +781,9 @@
}
},
"node_modules/browserslist": {
"version": "4.26.3",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz",
"integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==",
"version": "4.24.5",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.5.tgz",
"integrity": "sha512-FDToo4Wo82hIdgc1CQ+NQD0hEhmpPjrZ3hiUgwgOG6IuTdlpr8jdjyG24P6cNP1yJpTLzS5OcGgSw0xmDU1/Tw==",
"dev": true,
"funding": [
{
@ -811,12 +800,10 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.9",
"caniuse-lite": "^1.0.30001746",
"electron-to-chromium": "^1.5.227",
"node-releases": "^2.0.21",
"caniuse-lite": "^1.0.30001716",
"electron-to-chromium": "^1.5.149",
"node-releases": "^2.0.19",
"update-browserslist-db": "^1.1.3"
},
"bin": {
@ -834,9 +821,9 @@
"license": "MIT"
},
"node_modules/caniuse-lite": {
"version": "1.0.30001751",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz",
"integrity": "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==",
"version": "1.0.30001718",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001718.tgz",
"integrity": "sha512-AflseV1ahcSunK53NfEs9gFWgOEmzr0f+kaMFA4xiLZlr9Hzt7HxcSpIFcnNCUkz6R6dWKa54rUz3HUmI3nVcw==",
"dev": true,
"funding": [
{
@ -988,16 +975,16 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.237",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.237.tgz",
"integrity": "sha512-icUt1NvfhGLar5lSWH3tHNzablaA5js3HVHacQimfP8ViEBOQv+L7DKEuHdbTZ0SKCO1ogTJTIL1Gwk9S6Qvcg==",
"version": "1.5.155",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.155.tgz",
"integrity": "sha512-ps5KcGGmwL8VaeJlvlDlu4fORQpv3+GIcF5I3f9tUKUlJ/wsysh6HU8P5L1XWRYeXfA0oJd4PyM8ds8zTFf6Ng==",
"dev": true,
"license": "ISC"
},
"node_modules/enhanced-resolve": {
"version": "5.18.3",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
"integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==",
"version": "5.18.1",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz",
"integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -1120,9 +1107,9 @@
"license": "MIT"
},
"node_modules/fast-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz",
"integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==",
"dev": true,
"funding": [
{
@ -1254,9 +1241,9 @@
}
},
"node_modules/immutable": {
"version": "5.1.3",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.3.tgz",
"integrity": "sha512-+chQdDfvscSF1SJqv2gn4SRO2ZyS3xL3r7IW/wWEEzrzLisnOlKiQu5ytC/BVNcS15C39WT2Hg/bjKjDMcu+zg==",
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.2.tgz",
"integrity": "sha512-qHKXW1q6liAk1Oys6umoaZbDRqjcjgSrbnrifHsfsttza7zcvRAsL7mMV6xWcyhwQy7Xj5v4hhbr6b+iDYwlmQ==",
"dev": true,
"license": "MIT"
},
@ -1541,9 +1528,9 @@
"optional": true
},
"node_modules/node-releases": {
"version": "2.0.26",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.26.tgz",
"integrity": "sha512-S2M9YimhSjBSvYnlr5/+umAnPHE++ODwt5e2Ij6FoX45HA/s4vHdkDx1eax2pAPeAOqu4s9b7ppahsyEFdVqQA==",
"version": "2.0.19",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
"integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==",
"dev": true,
"license": "MIT"
},
@ -1648,9 +1635,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"version": "8.5.3",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz",
"integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==",
"dev": true,
"funding": [
{
@ -1667,9 +1654,8 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"nanoid": "^3.3.8",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@ -1874,12 +1860,11 @@
"license": "MIT"
},
"node_modules/sass": {
"version": "1.93.2",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.93.2.tgz",
"integrity": "sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==",
"version": "1.88.0",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.88.0.tgz",
"integrity": "sha512-sF6TWQqjFvr4JILXzG4ucGOLELkESHL+I5QJhh7CNaE+Yge0SI+ehCatsXhJ7ymU1hAFcIS3/PBpjdIbXoyVbg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"chokidar": "^4.0.0",
"immutable": "^5.0.2",
@ -1937,9 +1922,9 @@
}
},
"node_modules/schema-utils": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz",
"integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==",
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz",
"integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -2076,28 +2061,24 @@
}
},
"node_modules/tapable": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
"integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.2.tgz",
"integrity": "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
}
},
"node_modules/terser": {
"version": "5.44.0",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.44.0.tgz",
"integrity": "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==",
"version": "5.39.2",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.39.2.tgz",
"integrity": "sha512-yEPUmWve+VA78bI71BW70Dh0TuV4HHd+I5SHOAfS1+QBOmvmCiiffgjR8ryyEd3KIfvPGFqoADt8LdQ6XpXIvg==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"@jridgewell/source-map": "^0.3.3",
"acorn": "^8.15.0",
"acorn": "^8.14.0",
"commander": "^2.20.0",
"source-map-support": "~0.5.20"
},
@ -2158,9 +2139,9 @@
}
},
"node_modules/undici-types": {
"version": "7.10.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz",
"integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==",
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
@ -2217,24 +2198,22 @@
}
},
"node_modules/webpack": {
"version": "5.102.1",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.102.1.tgz",
"integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==",
"version": "5.99.8",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.99.8.tgz",
"integrity": "sha512-lQ3CPiSTpfOnrEGeXDwoq5hIGzSjmwD72GdfVzF7CQAI7t47rJG9eDWvcEkEn3CUQymAElVvDg3YNTlCYj+qUQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/eslint-scope": "^3.7.7",
"@types/estree": "^1.0.8",
"@types/estree": "^1.0.6",
"@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.15.0",
"acorn-import-phases": "^1.0.3",
"browserslist": "^4.26.3",
"acorn": "^8.14.0",
"browserslist": "^4.24.0",
"chrome-trace-event": "^1.0.2",
"enhanced-resolve": "^5.17.3",
"enhanced-resolve": "^5.17.1",
"es-module-lexer": "^1.2.1",
"eslint-scope": "5.1.1",
"events": "^3.2.0",
@ -2244,11 +2223,11 @@
"loader-runner": "^4.2.0",
"mime-types": "^2.1.27",
"neo-async": "^2.6.2",
"schema-utils": "^4.3.3",
"tapable": "^2.3.0",
"schema-utils": "^4.3.2",
"tapable": "^2.1.1",
"terser-webpack-plugin": "^5.3.11",
"watchpack": "^2.4.4",
"webpack-sources": "^3.3.3"
"watchpack": "^2.4.1",
"webpack-sources": "^3.2.3"
},
"bin": {
"webpack": "bin/webpack.js"
@ -2272,7 +2251,6 @@
"integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@discoveryjs/json-ext": "^0.5.0",
"@webpack-cli/configtest": "^2.1.1",
@ -2339,9 +2317,9 @@
}
},
"node_modules/webpack-sources": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.3.tgz",
"integrity": "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==",
"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==",
"dev": true,
"license": "MIT",
"engines": {

View File

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

View File

@ -1,4 +1,5 @@
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;
@ -13,7 +14,7 @@ using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Test.Billing.Mocks;
using Bit.Core.Utilities;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
@ -156,18 +157,16 @@ public class RemoveOrganizationFromProviderCommandTests
"b@example.com"
]);
sutProvider.GetDependency<IStripeAdapter>().SubscriptionGetAsync(organization.GatewaySubscriptionId, Arg.Is<SubscriptionGetOptions>(
options => options.Expand.Contains("customer")))
.Returns(GetSubscription(organization.GatewaySubscriptionId, organization.GatewayCustomerId));
sutProvider.GetDependency<IStripeAdapter>().SubscriptionGetAsync(organization.GatewaySubscriptionId)
.Returns(GetSubscription(organization.GatewaySubscriptionId));
await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization);
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
await stripeAdapter.Received(1).CustomerUpdateAsync(organization.GatewayCustomerId,
Arg.Is<CustomerUpdateOptions>(options => options.Email == "a@example.com"));
await stripeAdapter.Received(1).CustomerDeleteDiscountAsync(organization.GatewayCustomerId);
Arg.Is<CustomerUpdateOptions>(options =>
options.Coupon == string.Empty && options.Email == "a@example.com"));
await stripeAdapter.Received(1).SubscriptionUpdateAsync(organization.GatewaySubscriptionId,
Arg.Is<SubscriptionUpdateOptions>(options =>
@ -207,7 +206,7 @@ public class RemoveOrganizationFromProviderCommandTests
organization.PlanType = PlanType.TeamsMonthly;
var teamsMonthlyPlan = MockPlans.Get(PlanType.TeamsMonthly);
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(PlanType.TeamsMonthly).Returns(teamsMonthlyPlan);
@ -264,8 +263,7 @@ public class RemoveOrganizationFromProviderCommandTests
org =>
org.BillingEmail == "a@example.com" &&
org.GatewaySubscriptionId == "subscription_id" &&
org.Status == OrganizationStatusType.Created &&
org.Enabled == true)); // Verify organization is enabled when new subscription is created
org.Status == OrganizationStatusType.Created));
await sutProvider.GetDependency<IProviderOrganizationRepository>().Received(1)
.DeleteAsync(providerOrganization);
@ -296,7 +294,7 @@ public class RemoveOrganizationFromProviderCommandTests
organization.PlanType = PlanType.TeamsMonthly;
var teamsMonthlyPlan = MockPlans.Get(PlanType.TeamsMonthly);
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(PlanType.TeamsMonthly).Returns(teamsMonthlyPlan);
@ -333,6 +331,9 @@ 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 =>
@ -353,8 +354,7 @@ public class RemoveOrganizationFromProviderCommandTests
org =>
org.BillingEmail == "a@example.com" &&
org.GatewaySubscriptionId == "subscription_id" &&
org.Status == OrganizationStatusType.Created &&
org.Enabled == true)); // Verify organization is enabled when new subscription is created
org.Status == OrganizationStatusType.Created));
await sutProvider.GetDependency<IProviderOrganizationRepository>().Received(1)
.DeleteAsync(providerOrganization);
@ -370,21 +370,10 @@ public class RemoveOrganizationFromProviderCommandTests
Arg.Is<IEnumerable<string>>(emails => emails.FirstOrDefault() == "a@example.com"));
}
private static Subscription GetSubscription(string subscriptionId, string customerId) =>
private static Subscription GetSubscription(string subscriptionId) =>
new()
{
Id = subscriptionId,
CustomerId = customerId,
Customer = new Customer
{
Discount = new Discount
{
Coupon = new Coupon
{
Id = "coupon-id"
}
}
},
Status = StripeConstants.SubscriptionStatus.Active,
Items = new StripeList<SubscriptionItem>
{
@ -401,62 +390,4 @@ 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 = MockPlans.Get(PlanType.TeamsMonthly);
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(PlanType.TeamsMonthly).Returns(teamsMonthlyPlan);
sutProvider.GetDependency<IHasConfirmedOwnersExceptQuery>().HasConfirmedOwnersExceptAsync(
providerOrganization.OrganizationId,
[],
includeProvider: false)
.Returns(true);
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
organizationRepository.GetOwnerEmailAddressesById(organization.Id).Returns([
"owner@example.com"
]);
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, Arg.Any<CustomerUpdateOptions>())
.Returns(new Customer
{
Id = "customer_id",
Address = new Address
{
Country = "US"
}
});
stripeAdapter.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(new Subscription
{
Id = "new_subscription_id"
});
// Act
await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization);
// Assert: Verify the disabled organization is now enabled
await organizationRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(
org =>
org.Enabled == true && // The previously disabled organization should now be enabled
org.Status == OrganizationStatusType.Created &&
org.GatewaySubscriptionId == "new_subscription_id"));
}
}

View File

@ -1,15 +1,15 @@
using Bit.Commercial.Core.AdminConsole.Services;
using Bit.Commercial.Core.Test.AdminConsole.AutoFixture;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Models.Business.Provider;
using Bit.Core.AdminConsole.Models.Business.Tokenables;
using Bit.Core.AdminConsole.Models.Data.Provider;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Payment.Models;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Providers.Services;
using Bit.Core.Context;
@ -20,7 +20,6 @@ using Bit.Core.Models.Business;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Test.AutoFixture.OrganizationFixtures;
using Bit.Core.Test.Billing.Mocks;
using Bit.Core.Tokens;
using Bit.Core.Utilities;
using Bit.Test.Common.AutoFixture;
@ -42,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, null));
() => sutProvider.Sut.CompleteSetupAsync(default, default, default, default, null));
Assert.Contains("Invalid owner.", exception.Message);
}
@ -54,12 +53,85 @@ public class ProviderServiceTests
userService.GetUserByIdAsync(user.Id).Returns(user);
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.CompleteSetupAsync(provider, user.Id, default, default, null, null));
() => sutProvider.Sut.CompleteSetupAsync(provider, user.Id, default, default, null));
Assert.Contains("Invalid token.", exception.Message);
}
[Theory, BitAutoData]
public async Task CompleteSetupAsync_Success(User user, Provider provider, string key, TokenizedPaymentMethod tokenizedPaymentMethod, BillingAddress billingAddress,
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,
[ProviderUser] ProviderUser providerUser,
SutProvider<ProviderService> sutProvider)
{
@ -79,7 +151,7 @@ public class ProviderServiceTests
var providerBillingService = sutProvider.GetDependency<IProviderBillingService>();
var customer = new Customer { Id = "customer_id" };
providerBillingService.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress).Returns(customer);
providerBillingService.SetupCustomer(provider, taxInfo, tokenizedPaymentSource).Returns(customer);
var subscription = new Subscription { Id = "subscription_id" };
providerBillingService.SetupSubscription(provider).Returns(subscription);
@ -88,7 +160,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, tokenizedPaymentMethod, billingAddress);
await sutProvider.Sut.CompleteSetupAsync(provider, user.Id, token, key, taxInfo, tokenizedPaymentSource);
await sutProvider.GetDependency<IProviderRepository>().Received().UpsertAsync(Arg.Is<Provider>(
p =>
@ -116,262 +188,6 @@ 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)
{
@ -812,12 +628,12 @@ public class ProviderServiceTests
organization.Plan = "Enterprise (Monthly)";
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(organization.PlanType)
.Returns(MockPlans.Get(organization.PlanType));
.Returns(StaticStore.GetPlan(organization.PlanType));
var expectedPlanType = PlanType.EnterpriseMonthly2020;
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(expectedPlanType)
.Returns(MockPlans.Get(expectedPlanType));
.Returns(StaticStore.GetPlan(expectedPlanType));
var expectedPlanId = "2020-enterprise-org-seat-monthly";
@ -1121,7 +937,7 @@ public class ProviderServiceTests
private static SubscriptionUpdateOptions SubscriptionUpdateRequest(string expectedPlanId, Subscription subscriptionItem) =>
new()
{
Items = new List<SubscriptionItemOptions>
Items = new List<Stripe.SubscriptionItemOptions>
{
new() { Id = subscriptionItem.Id, Price = expectedPlanId },
}

View File

@ -11,14 +11,12 @@ 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;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Test.Billing.Mocks;
using Bit.Core.Utilities;
using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.AspNetCore.DataProtection;
@ -73,7 +71,7 @@ public class BusinessUnitConverterTests
{
organization.PlanType = PlanType.EnterpriseAnnually2020;
var enterpriseAnnually2020 = MockPlans.Get(PlanType.EnterpriseAnnually2020);
var enterpriseAnnually2020 = StaticStore.GetPlan(PlanType.EnterpriseAnnually2020);
var subscription = new Subscription
{
@ -135,7 +133,7 @@ public class BusinessUnitConverterTests
_pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually2020)
.Returns(enterpriseAnnually2020);
var enterpriseAnnually = MockPlans.Get(PlanType.EnterpriseAnnually);
var enterpriseAnnually = StaticStore.GetPlan(PlanType.EnterpriseAnnually);
_pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually)
.Returns(enterpriseAnnually);
@ -243,7 +241,7 @@ public class BusinessUnitConverterTests
argument.Status == ProviderStatusType.Pending &&
argument.Type == ProviderType.BusinessUnit)).Returns(provider);
var plan = MockPlans.Get(organization.PlanType);
var plan = StaticStore.GetPlan(organization.PlanType);
_pricingClient.GetPlanOrThrow(organization.PlanType).Returns(plan);

View File

@ -1,6 +1,8 @@
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;
@ -9,20 +11,21 @@ using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Payment.Models;
using Bit.Core.Billing.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;
using Bit.Core.Test.Billing.Mocks;
using Bit.Core.Utilities;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Braintree;
@ -140,7 +143,7 @@ public class ProviderBillingServiceTests
.Returns(existingPlan);
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(existingPlan.PlanType)
.Returns(MockPlans.Get(existingPlan.PlanType));
.Returns(StaticStore.GetPlan(existingPlan.PlanType));
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider)
.Returns(new Subscription
@ -155,7 +158,7 @@ public class ProviderBillingServiceTests
Id = "si_ent_annual",
Price = new Price
{
Id = MockPlans.Get(PlanType.EnterpriseAnnually).PasswordManager
Id = StaticStore.GetPlan(PlanType.EnterpriseAnnually).PasswordManager
.StripeProviderPortalSeatPlanId
},
Quantity = 10
@ -168,7 +171,7 @@ public class ProviderBillingServiceTests
new ChangeProviderPlanCommand(provider, providerPlanId, PlanType.EnterpriseMonthly);
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(command.NewPlan)
.Returns(MockPlans.Get(command.NewPlan));
.Returns(StaticStore.GetPlan(command.NewPlan));
// Act
await sutProvider.Sut.ChangePlan(command);
@ -185,7 +188,7 @@ public class ProviderBillingServiceTests
Arg.Is<SubscriptionUpdateOptions>(p =>
p.Items.Count(si => si.Id == "si_ent_annual" && si.Deleted == true) == 1));
var newPlanCfg = MockPlans.Get(command.NewPlan);
var newPlanCfg = StaticStore.GetPlan(command.NewPlan);
await stripeAdapter.Received(1)
.SubscriptionUpdateAsync(
Arg.Is(provider.GatewaySubscriptionId),
@ -349,6 +352,9 @@ 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 &&
@ -491,7 +497,7 @@ public class ProviderBillingServiceTests
foreach (var plan in providerPlans)
{
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(MockPlans.Get(plan.PlanType));
.Returns(StaticStore.GetPlan(plan.PlanType));
}
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id).Returns(providerPlans);
@ -514,7 +520,7 @@ public class ProviderBillingServiceTests
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider).Returns(subscription);
// 50 seats currently assigned with a seat minimum of 100
var teamsMonthlyPlan = MockPlans.Get(PlanType.TeamsMonthly);
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
sutProvider.GetDependency<IProviderOrganizationRepository>().GetManyDetailsByProviderAsync(provider.Id).Returns(
[
@ -573,7 +579,7 @@ public class ProviderBillingServiceTests
foreach (var plan in providerPlans)
{
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(MockPlans.Get(plan.PlanType));
.Returns(StaticStore.GetPlan(plan.PlanType));
}
var providerPlan = providerPlans.First();
@ -598,7 +604,7 @@ public class ProviderBillingServiceTests
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider).Returns(subscription);
// 95 seats currently assigned with a seat minimum of 100
var teamsMonthlyPlan = MockPlans.Get(PlanType.TeamsMonthly);
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
sutProvider.GetDependency<IProviderOrganizationRepository>().GetManyDetailsByProviderAsync(provider.Id).Returns(
[
@ -661,7 +667,7 @@ public class ProviderBillingServiceTests
foreach (var plan in providerPlans)
{
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(MockPlans.Get(plan.PlanType));
.Returns(StaticStore.GetPlan(plan.PlanType));
}
var providerPlan = providerPlans.First();
@ -686,7 +692,7 @@ public class ProviderBillingServiceTests
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider).Returns(subscription);
// 110 seats currently assigned with a seat minimum of 100
var teamsMonthlyPlan = MockPlans.Get(PlanType.TeamsMonthly);
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
sutProvider.GetDependency<IProviderOrganizationRepository>().GetManyDetailsByProviderAsync(provider.Id).Returns(
[
@ -749,7 +755,7 @@ public class ProviderBillingServiceTests
foreach (var plan in providerPlans)
{
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(MockPlans.Get(plan.PlanType));
.Returns(StaticStore.GetPlan(plan.PlanType));
}
var providerPlan = providerPlans.First();
@ -774,7 +780,7 @@ public class ProviderBillingServiceTests
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider).Returns(subscription);
// 110 seats currently assigned with a seat minimum of 100
var teamsMonthlyPlan = MockPlans.Get(PlanType.TeamsMonthly);
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
sutProvider.GetDependency<IProviderOrganizationRepository>().GetManyDetailsByProviderAsync(provider.Id).Returns(
[
@ -827,13 +833,13 @@ public class ProviderBillingServiceTests
}
]);
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(planType).Returns(MockPlans.Get(planType));
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(planType).Returns(StaticStore.GetPlan(planType));
sutProvider.GetDependency<IProviderOrganizationRepository>().GetManyDetailsByProviderAsync(provider.Id).Returns(
[
new ProviderOrganizationOrganizationDetails
{
Plan = MockPlans.Get(planType).Name,
Plan = StaticStore.GetPlan(planType).Name,
Status = OrganizationStatusType.Managed,
Seats = 5
}
@ -865,13 +871,13 @@ public class ProviderBillingServiceTests
}
]);
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(planType).Returns(MockPlans.Get(planType));
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(planType).Returns(StaticStore.GetPlan(planType));
sutProvider.GetDependency<IProviderOrganizationRepository>().GetManyDetailsByProviderAsync(provider.Id).Returns(
[
new ProviderOrganizationOrganizationDetails
{
Plan = MockPlans.Get(planType).Name,
Plan = StaticStore.GetPlan(planType).Name,
Status = OrganizationStatusType.Managed,
Seats = 15
}
@ -892,110 +898,50 @@ public class ProviderBillingServiceTests
#region SetupCustomer
[Theory, BitAutoData]
public async Task SetupCustomer_NullPaymentMethod_ThrowsNullReferenceException(
public async Task SetupCustomer_MissingCountry_ContactSupport(
SutProvider<ProviderBillingService> sutProvider,
Provider provider,
BillingAddress billingAddress)
TaxInfo taxInfo)
{
await Assert.ThrowsAsync<NullReferenceException>(() =>
sutProvider.Sut.SetupCustomer(provider, null, 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_WithBankAccount_Error_Reverts(
public async Task SetupCustomer_MissingPostalCode_ContactSupport(
SutProvider<ProviderBillingService> sutProvider,
Provider provider,
BillingAddress billingAddress)
TaxInfo taxInfo)
{
provider.Name = "MSP";
billingAddress.Country = "AD";
billingAddress.TaxId = new TaxID("es_nif", "12345678Z");
taxInfo.BillingAddressCountry = null;
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.BankAccount, Token = "token" };
await ThrowsBillingExceptionAsync(() => sutProvider.Sut.SetupCustomer(provider, taxInfo));
stripeAdapter.SetupIntentList(Arg.Is<SetupIntentListOptions>(options =>
options.PaymentMethod == tokenizedPaymentMethod.Token)).Returns([
new SetupIntent { Id = "setup_intent_id" }
]);
stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o =>
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.SubscriberType() &&
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == provider.DisplayName() &&
o.Metadata["region"] == "" &&
o.TaxIdData.FirstOrDefault().Type == billingAddress.TaxId.Code &&
o.TaxIdData.FirstOrDefault().Value == billingAddress.TaxId.Value))
.Throws<StripeException>();
sutProvider.GetDependency<ISetupIntentCache>().GetSetupIntentIdForSubscriber(provider.Id).Returns("setup_intent_id");
await Assert.ThrowsAsync<StripeException>(() =>
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).RemoveSetupIntentForSubscriber(provider.Id);
await sutProvider.GetDependency<IStripeAdapter>()
.DidNotReceiveWithAnyArgs()
.CustomerGetAsync(Arg.Any<string>(), Arg.Any<CustomerGetOptions>());
}
[Theory, BitAutoData]
public async Task SetupCustomer_WithPayPal_Error_Reverts(
public async Task SetupCustomer_NoPaymentMethod_Success(
SutProvider<ProviderBillingService> sutProvider,
Provider provider,
BillingAddress billingAddress)
TaxInfo taxInfo)
{
provider.Name = "MSP";
billingAddress.Country = "AD";
billingAddress.TaxId = new TaxID("es_nif", "12345678Z");
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.PayPal, Token = "token" };
sutProvider.GetDependency<ITaxService>()
.GetStripeTaxCode(Arg.Is<string>(
p => p == taxInfo.BillingAddressCountry),
Arg.Is<string>(p => p == taxInfo.TaxIdNumber))
.Returns(taxInfo.TaxIdType);
sutProvider.GetDependency<ISubscriberService>().CreateBraintreeCustomer(provider, tokenizedPaymentMethod.Token)
.Returns("braintree_customer_id");
stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o =>
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.SubscriberType() &&
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == provider.DisplayName() &&
o.Metadata["region"] == "" &&
o.Metadata["btCustomerId"] == "braintree_customer_id" &&
o.TaxIdData.FirstOrDefault().Type == billingAddress.TaxId.Code &&
o.TaxIdData.FirstOrDefault().Value == billingAddress.TaxId.Value))
.Throws<StripeException>();
await Assert.ThrowsAsync<StripeException>(() =>
sutProvider.Sut.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress));
await sutProvider.GetDependency<IBraintreeGateway>().Customer.Received(1).DeleteAsync("braintree_customer_id");
}
[Theory, BitAutoData]
public async Task SetupCustomer_WithBankAccount_Success(
SutProvider<ProviderBillingService> sutProvider,
Provider provider,
BillingAddress billingAddress)
{
provider.Name = "MSP";
billingAddress.Country = "AD";
billingAddress.TaxId = new TaxID("es_nif", "12345678Z");
taxInfo.BillingAddressCountry = "AD";
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
@ -1005,30 +951,210 @@ public class ProviderBillingServiceTests
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
};
var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.BankAccount, 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.Email == provider.BillingEmail &&
o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" &&
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" &&
o.Metadata["region"] == "" &&
o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType &&
o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber))
.Returns(expected);
var actual = await sutProvider.Sut.SetupCustomer(provider, taxInfo);
Assert.Equivalent(expected, actual);
}
[Theory, BitAutoData]
public async Task SetupCustomer_InvalidRequiredPaymentMethod_ThrowsBillingException(
SutProvider<ProviderBillingService> sutProvider,
Provider provider,
TaxInfo taxInfo,
TokenizedPaymentSource tokenizedPaymentSource)
{
provider.Name = "MSP";
sutProvider.GetDependency<ITaxService>()
.GetStripeTaxCode(Arg.Is<string>(
p => p == taxInfo.BillingAddressCountry),
Arg.Is<string>(p => p == taxInfo.TaxIdNumber))
.Returns(taxInfo.TaxIdType);
taxInfo.BillingAddressCountry = "AD";
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
tokenizedPaymentSource = tokenizedPaymentSource with { Type = PaymentMethodType.BitPay };
await ThrowsBillingExceptionAsync(() =>
sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource));
}
[Theory, BitAutoData]
public async Task SetupCustomer_WithBankAccount_Error_Reverts(
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 tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.BankAccount, "token");
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
stripeAdapter.SetupIntentList(Arg.Is<SetupIntentListOptions>(options =>
options.PaymentMethod == tokenizedPaymentMethod.Token)).Returns([
options.PaymentMethod == tokenizedPaymentSource.Token)).Returns([
new SetupIntent { Id = "setup_intent_id" }
]);
stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o =>
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.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.SubscriberType() &&
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == provider.DisplayName() &&
o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" &&
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" &&
o.Metadata["region"] == "" &&
o.TaxIdData.FirstOrDefault().Type == billingAddress.TaxId.Code &&
o.TaxIdData.FirstOrDefault().Value == billingAddress.TaxId.Value))
o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType &&
o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber))
.Throws<StripeException>();
sutProvider.GetDependency<ISetupIntentCache>().Get(provider.Id).Returns("setup_intent_id");
await Assert.ThrowsAsync<StripeException>(() =>
sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource));
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);
}
[Theory, BitAutoData]
public async Task SetupCustomer_WithPayPal_Error_Reverts(
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 tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.PayPal, "token");
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
sutProvider.GetDependency<ISubscriberService>().CreateBraintreeCustomer(provider, tokenizedPaymentSource.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.Email == provider.BillingEmail &&
o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" &&
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" &&
o.Metadata["region"] == "" &&
o.Metadata["btCustomerId"] == "braintree_customer_id" &&
o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType &&
o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber))
.Throws<StripeException>();
await Assert.ThrowsAsync<StripeException>(() =>
sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource));
await sutProvider.GetDependency<IBraintreeGateway>().Customer.Received(1).DeleteAsync("braintree_customer_id");
}
[Theory, BitAutoData]
public async Task SetupCustomer_WithBankAccount_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 }
};
var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.BankAccount, "token");
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
stripeAdapter.SetupIntentList(Arg.Is<SetupIntentListOptions>(options =>
options.PaymentMethod == tokenizedPaymentSource.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.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, tokenizedPaymentMethod, billingAddress);
var actual = await sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource);
Assert.Equivalent(expected, actual);
@ -1039,11 +1165,17 @@ public class ProviderBillingServiceTests
public async Task SetupCustomer_WithPayPal_Success(
SutProvider<ProviderBillingService> sutProvider,
Provider provider,
BillingAddress billingAddress)
TaxInfo taxInfo)
{
provider.Name = "MSP";
billingAddress.Country = "AD";
billingAddress.TaxId = new TaxID("es_nif", "12345678Z");
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>();
@ -1053,29 +1185,32 @@ public class ProviderBillingServiceTests
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
};
var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.PayPal, Token = "token" };
var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.PayPal, "token");
sutProvider.GetDependency<ISubscriberService>().CreateBraintreeCustomer(provider, tokenizedPaymentMethod.Token)
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
sutProvider.GetDependency<ISubscriberService>().CreateBraintreeCustomer(provider, tokenizedPaymentSource.Token)
.Returns("braintree_customer_id");
stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o =>
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.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.SubscriberType() &&
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == provider.DisplayName() &&
o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" &&
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" &&
o.Metadata["region"] == "" &&
o.Metadata["btCustomerId"] == "braintree_customer_id" &&
o.TaxIdData.FirstOrDefault().Type == billingAddress.TaxId.Code &&
o.TaxIdData.FirstOrDefault().Value == billingAddress.TaxId.Value))
o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType &&
o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber))
.Returns(expected);
var actual = await sutProvider.Sut.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress);
var actual = await sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource);
Assert.Equivalent(expected, actual);
}
@ -1084,11 +1219,17 @@ public class ProviderBillingServiceTests
public async Task SetupCustomer_WithCard_Success(
SutProvider<ProviderBillingService> sutProvider,
Provider provider,
BillingAddress billingAddress)
TaxInfo taxInfo)
{
provider.Name = "MSP";
billingAddress.Country = "AD";
billingAddress.TaxId = new TaxID("es_nif", "12345678Z");
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>();
@ -1098,26 +1239,30 @@ public class ProviderBillingServiceTests
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
};
var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.Card, Token = "token" };
var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.Card, "token");
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o =>
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.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.DefaultPaymentMethod == tokenizedPaymentMethod.Token &&
o.InvoiceSettings.CustomFields.FirstOrDefault().Name == provider.SubscriberType() &&
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == provider.DisplayName() &&
o.PaymentMethod == tokenizedPaymentSource.Token &&
o.InvoiceSettings.DefaultPaymentMethod == tokenizedPaymentSource.Token &&
o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" &&
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" &&
o.Metadata["region"] == "" &&
o.TaxIdData.FirstOrDefault().Type == billingAddress.TaxId.Code &&
o.TaxIdData.FirstOrDefault().Value == billingAddress.TaxId.Value))
o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType &&
o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber))
.Returns(expected);
var actual = await sutProvider.Sut.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress);
var actual = await sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource);
Assert.Equivalent(expected, actual);
}
@ -1126,11 +1271,17 @@ public class ProviderBillingServiceTests
public async Task SetupCustomer_WithCard_ReverseCharge_Success(
SutProvider<ProviderBillingService> sutProvider,
Provider provider,
BillingAddress billingAddress)
TaxInfo taxInfo)
{
provider.Name = "MSP";
billingAddress.Country = "FR"; // Non-US country to trigger reverse charge
billingAddress.TaxId = new TaxID("fr_siren", "123456789");
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>();
@ -1140,51 +1291,59 @@ public class ProviderBillingServiceTests
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
};
var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.Card, Token = "token" };
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);
stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o =>
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.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.DefaultPaymentMethod == tokenizedPaymentMethod.Token &&
o.InvoiceSettings.CustomFields.FirstOrDefault().Name == provider.SubscriberType() &&
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == provider.DisplayName() &&
o.PaymentMethod == tokenizedPaymentSource.Token &&
o.InvoiceSettings.DefaultPaymentMethod == tokenizedPaymentSource.Token &&
o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" &&
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" &&
o.Metadata["region"] == "" &&
o.TaxIdData.FirstOrDefault().Type == billingAddress.TaxId.Code &&
o.TaxIdData.FirstOrDefault().Value == billingAddress.TaxId.Value &&
o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType &&
o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber &&
o.TaxExempt == StripeConstants.TaxExempt.Reverse))
.Returns(expected);
var actual = await sutProvider.Sut.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress);
var actual = await sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource);
Assert.Equivalent(expected, actual);
}
[Theory, BitAutoData]
public async Task SetupCustomer_WithInvalidTaxId_ThrowsBadRequestException(
public async Task SetupCustomer_Throws_BadRequestException_WhenTaxIdIsInvalid(
SutProvider<ProviderBillingService> sutProvider,
Provider provider,
BillingAddress billingAddress)
TaxInfo taxInfo)
{
provider.Name = "MSP";
billingAddress.Country = "AD";
billingAddress.TaxId = new TaxID("es_nif", "invalid_tax_id");
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.Card, Token = "token" };
taxInfo.BillingAddressCountry = "AD";
stripeAdapter.CustomerCreateAsync(Arg.Any<CustomerCreateOptions>())
.Throws(new StripeException("Invalid tax ID") { StripeError = new StripeError { Code = "tax_id_invalid" } });
sutProvider.GetDependency<ITaxService>()
.GetStripeTaxCode(Arg.Is<string>(
p => p == taxInfo.BillingAddressCountry),
Arg.Is<string>(p => p == taxInfo.TaxIdNumber))
.Returns((string)null);
var actual = await Assert.ThrowsAsync<BadRequestException>(async () =>
await sutProvider.Sut.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress));
await sutProvider.Sut.SetupCustomer(provider, taxInfo));
Assert.Equal("Your tax ID wasn't recognized for your selected country. Please ensure your country and tax ID are valid.", actual.Message);
Assert.IsType<BadRequestException>(actual);
Assert.Equal("billingTaxIdTypeInferenceError", actual.Message);
}
#endregion
@ -1238,7 +1397,7 @@ public class ProviderBillingServiceTests
.Returns(providerPlans);
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(PlanType.EnterpriseMonthly)
.Returns(MockPlans.Get(PlanType.EnterpriseMonthly));
.Returns(StaticStore.GetPlan(PlanType.EnterpriseMonthly));
await ThrowsBillingExceptionAsync(() => sutProvider.Sut.SetupSubscription(provider));
@ -1266,7 +1425,7 @@ public class ProviderBillingServiceTests
.Returns(providerPlans);
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(PlanType.TeamsMonthly)
.Returns(MockPlans.Get(PlanType.TeamsMonthly));
.Returns(StaticStore.GetPlan(PlanType.TeamsMonthly));
await ThrowsBillingExceptionAsync(() => sutProvider.Sut.SetupSubscription(provider));
@ -1317,7 +1476,7 @@ public class ProviderBillingServiceTests
foreach (var plan in providerPlans)
{
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(MockPlans.Get(plan.PlanType));
.Returns(StaticStore.GetPlan(plan.PlanType));
}
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
@ -1373,7 +1532,7 @@ public class ProviderBillingServiceTests
foreach (var plan in providerPlans)
{
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(MockPlans.Get(plan.PlanType));
.Returns(StaticStore.GetPlan(plan.PlanType));
}
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
@ -1449,7 +1608,7 @@ public class ProviderBillingServiceTests
foreach (var plan in providerPlans)
{
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(MockPlans.Get(plan.PlanType));
.Returns(StaticStore.GetPlan(plan.PlanType));
}
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
@ -1457,6 +1616,8 @@ 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 =>
@ -1525,7 +1686,7 @@ public class ProviderBillingServiceTests
foreach (var plan in providerPlans)
{
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(MockPlans.Get(plan.PlanType));
.Returns(StaticStore.GetPlan(plan.PlanType));
}
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
@ -1533,10 +1694,12 @@ 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>().GetSetupIntentIdForSubscriber(provider.Id).Returns(setupIntentId);
sutProvider.GetDependency<ISetupIntentCache>().Get(provider.Id).Returns(setupIntentId);
sutProvider.GetDependency<IStripeAdapter>().SetupIntentGet(setupIntentId, Arg.Is<SetupIntentGetOptions>(options =>
options.Expand.Contains("payment_method"))).Returns(new SetupIntent
@ -1626,7 +1789,7 @@ public class ProviderBillingServiceTests
foreach (var plan in providerPlans)
{
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(MockPlans.Get(plan.PlanType));
.Returns(StaticStore.GetPlan(plan.PlanType));
}
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
@ -1634,6 +1797,8 @@ 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 =>
@ -1704,7 +1869,7 @@ public class ProviderBillingServiceTests
foreach (var plan in providerPlans)
{
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(MockPlans.Get(plan.PlanType));
.Returns(StaticStore.GetPlan(plan.PlanType));
}
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
@ -1712,6 +1877,11 @@ 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 =>
@ -1772,8 +1942,8 @@ public class ProviderBillingServiceTests
const string enterpriseLineItemId = "enterprise_line_item_id";
const string teamsLineItemId = "teams_line_item_id";
var enterprisePriceId = MockPlans.Get(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
var teamsPriceId = MockPlans.Get(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
var enterprisePriceId = StaticStore.GetPlan(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
var teamsPriceId = StaticStore.GetPlan(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
var subscription = new Subscription
{
@ -1806,7 +1976,7 @@ public class ProviderBillingServiceTests
foreach (var plan in providerPlans)
{
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(MockPlans.Get(plan.PlanType));
.Returns(StaticStore.GetPlan(plan.PlanType));
}
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
@ -1852,8 +2022,8 @@ public class ProviderBillingServiceTests
const string enterpriseLineItemId = "enterprise_line_item_id";
const string teamsLineItemId = "teams_line_item_id";
var enterprisePriceId = MockPlans.Get(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
var teamsPriceId = MockPlans.Get(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
var enterprisePriceId = StaticStore.GetPlan(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
var teamsPriceId = StaticStore.GetPlan(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
var subscription = new Subscription
{
@ -1886,7 +2056,7 @@ public class ProviderBillingServiceTests
foreach (var plan in providerPlans)
{
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(MockPlans.Get(plan.PlanType));
.Returns(StaticStore.GetPlan(plan.PlanType));
}
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
@ -1932,8 +2102,8 @@ public class ProviderBillingServiceTests
const string enterpriseLineItemId = "enterprise_line_item_id";
const string teamsLineItemId = "teams_line_item_id";
var enterprisePriceId = MockPlans.Get(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
var teamsPriceId = MockPlans.Get(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
var enterprisePriceId = StaticStore.GetPlan(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
var teamsPriceId = StaticStore.GetPlan(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
var subscription = new Subscription
{
@ -1966,7 +2136,7 @@ public class ProviderBillingServiceTests
foreach (var plan in providerPlans)
{
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(MockPlans.Get(plan.PlanType));
.Returns(StaticStore.GetPlan(plan.PlanType));
}
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
@ -2006,8 +2176,8 @@ public class ProviderBillingServiceTests
const string enterpriseLineItemId = "enterprise_line_item_id";
const string teamsLineItemId = "teams_line_item_id";
var enterprisePriceId = MockPlans.Get(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
var teamsPriceId = MockPlans.Get(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
var enterprisePriceId = StaticStore.GetPlan(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
var teamsPriceId = StaticStore.GetPlan(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
var subscription = new Subscription
{
@ -2040,7 +2210,7 @@ public class ProviderBillingServiceTests
foreach (var plan in providerPlans)
{
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(MockPlans.Get(plan.PlanType));
.Returns(StaticStore.GetPlan(plan.PlanType));
}
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
@ -2086,8 +2256,8 @@ public class ProviderBillingServiceTests
const string enterpriseLineItemId = "enterprise_line_item_id";
const string teamsLineItemId = "teams_line_item_id";
var enterprisePriceId = MockPlans.Get(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
var teamsPriceId = MockPlans.Get(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
var enterprisePriceId = StaticStore.GetPlan(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
var teamsPriceId = StaticStore.GetPlan(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
var subscription = new Subscription
{
@ -2120,7 +2290,7 @@ public class ProviderBillingServiceTests
foreach (var plan in providerPlans)
{
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(MockPlans.Get(plan.PlanType));
.Returns(StaticStore.GetPlan(plan.PlanType));
}
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);

View File

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

View File

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

View File

@ -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));
}

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