1
0
mirror of https://github.com/bitwarden/server synced 2025-12-22 19:23:45 +00:00

Merge remote-tracking branch 'origin/main' into ps/pm-19659/add-notifications-readmes

This commit is contained in:
Justin Baur
2025-11-19 16:45:09 -05:00
1229 changed files with 173890 additions and 14534 deletions

77
.claude/CLAUDE.md Normal file
View File

@@ -0,0 +1,77 @@
# 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

@@ -0,0 +1,25 @@
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, "isRoot": true,
"tools": { "tools": {
"swashbuckle.aspnetcore.cli": { "swashbuckle.aspnetcore.cli": {
"version": "9.0.2", "version": "9.0.4",
"commands": ["swagger"] "commands": ["swagger"]
}, },
"dotnet-ef": { "dotnet-ef": {

View File

@@ -123,3 +123,12 @@ csharp_style_namespace_declarations = file_scoped:warning
# Switch expression # Switch expression
dotnet_diagnostic.CS8509.severity = error # missing switch case for named enum value dotnet_diagnostic.CS8509.severity = error # missing switch case for named enum value
dotnet_diagnostic.CS8524.severity = none # missing switch case for unnamed 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

19
.github/CODEOWNERS vendored
View File

@@ -4,11 +4,12 @@
# #
# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners # https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
## Docker files have shared ownership ## ## Docker-related files
**/Dockerfile **/Dockerfile @bitwarden/team-appsec @bitwarden/dept-bre
**/*.Dockerfile **/*.Dockerfile @bitwarden/team-appsec @bitwarden/dept-bre
**/.dockerignore **/*.dockerignore @bitwarden/team-appsec @bitwarden/dept-bre
**/entrypoint.sh **/docker-compose.yml @bitwarden/team-appsec @bitwarden/dept-bre
**/entrypoint.sh @bitwarden/team-appsec @bitwarden/dept-bre
## BRE team owns these workflows ## ## BRE team owns these workflows ##
.github/workflows/publish.yml @bitwarden/dept-bre .github/workflows/publish.yml @bitwarden/dept-bre
@@ -95,6 +96,14 @@ src/Admin/Views/Tools @bitwarden/team-billing-dev
# The PushType enum is expected to be editted by anyone without need for Platform review # The PushType enum is expected to be editted by anyone without need for Platform review
src/Core/Platform/Push/PushType.cs src/Core/Platform/Push/PushType.cs
# SDK
util/RustSdk @bitwarden/team-sdk-sme
# Multiple owners - DO NOT REMOVE (BRE) # Multiple owners - DO NOT REMOVE (BRE)
**/packages.lock.json **/packages.lock.json
Directory.Build.props 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,6 @@
name: Bitwarden Unified Deployment Bug Report name: Bitwarden Lite Deployment Bug Report
description: File a bug report description: File a bug report
labels: [bug, bw-unified-deploy] labels: [bug, bw-lite-deploy]
body: body:
- type: markdown - type: markdown
attributes: attributes:
@@ -74,7 +74,7 @@ body:
id: epic-label id: epic-label
attributes: attributes:
label: Issue-Link label: Issue-Link
description: Link to our pinned issue, tracking all Bitwarden Unified description: Link to our pinned issue, tracking all Bitwarden Lite
value: | value: |
https://github.com/bitwarden/server/issues/2480 https://github.com/bitwarden/server/issues/2480
validations: validations:

View File

@@ -2,6 +2,7 @@
$schema: "https://docs.renovatebot.com/renovate-schema.json", $schema: "https://docs.renovatebot.com/renovate-schema.json",
extends: ["github>bitwarden/renovate-config"], // Extends our default configuration for pinned dependencies extends: ["github>bitwarden/renovate-config"], // Extends our default configuration for pinned dependencies
enabledManagers: [ enabledManagers: [
"cargo",
"dockerfile", "dockerfile",
"docker-compose", "docker-compose",
"github-actions", "github-actions",
@@ -9,6 +10,11 @@
"nuget", "nuget",
], ],
packageRules: [ packageRules: [
{
groupName: "cargo minor",
matchManagers: ["cargo"],
matchUpdateTypes: ["minor"],
},
{ {
groupName: "dockerfile minor", groupName: "dockerfile minor",
matchManagers: ["dockerfile"], matchManagers: ["dockerfile"],
@@ -35,6 +41,10 @@
matchUpdateTypes: ["patch"], matchUpdateTypes: ["patch"],
dependencyDashboardApproval: false, dependencyDashboardApproval: false,
}, },
{
matchSourceUrls: ["https://github.com/bitwarden/sdk-internal"],
groupName: "sdk-internal",
},
{ {
matchManagers: ["dockerfile", "docker-compose"], matchManagers: ["dockerfile", "docker-compose"],
commitMessagePrefix: "[deps] BRE:", commitMessagePrefix: "[deps] BRE:",

View File

@@ -41,18 +41,19 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
token: ${{ steps.retrieve-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }} token: ${{ steps.retrieve-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
persist-credentials: false
- name: Get script prefix - name: Get script prefix
id: prefix id: prefix
run: echo "prefix=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT run: echo "prefix=$(date +'%Y-%m-%d')" >> "$GITHUB_OUTPUT"
- name: Check if any files in DB transition or finalization directories - name: Check if any files in DB transition or finalization directories
id: check-script-existence id: check-script-existence
run: | run: |
if [ -f util/Migrator/DbScripts_transition/* -o -f util/Migrator/DbScripts_finalization/* ]; then if [ -f util/Migrator/DbScripts_transition/* -o -f util/Migrator/DbScripts_finalization/* ]; then
echo "copy_edd_scripts=true" >> $GITHUB_OUTPUT echo "copy_edd_scripts=true" >> "$GITHUB_OUTPUT"
else else
echo "copy_edd_scripts=false" >> $GITHUB_OUTPUT echo "copy_edd_scripts=false" >> "$GITHUB_OUTPUT"
fi fi
move-scripts: move-scripts:
@@ -70,17 +71,18 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
fetch-depth: 0 fetch-depth: 0
persist-credentials: true
- name: Generate branch name - name: Generate branch name
id: branch_name id: branch_name
env: env:
PREFIX: ${{ needs.setup.outputs.migration_filename_prefix }} PREFIX: ${{ needs.setup.outputs.migration_filename_prefix }}
run: echo "branch_name=move_edd_db_scripts_$PREFIX" >> $GITHUB_OUTPUT run: echo "branch_name=move_edd_db_scripts_$PREFIX" >> "$GITHUB_OUTPUT"
- name: "Create branch" - name: "Create branch"
env: env:
BRANCH: ${{ steps.branch_name.outputs.branch_name }} BRANCH: ${{ steps.branch_name.outputs.branch_name }}
run: git switch -c $BRANCH run: git switch -c "$BRANCH"
- name: Move scripts and finalization database schema - name: Move scripts and finalization database schema
id: move-files id: move-files
@@ -120,7 +122,7 @@ jobs:
# sync finalization schema back to dbo, maintaining structure # sync finalization schema back to dbo, maintaining structure
rsync -r "$src_dir/" "$dest_dir/" rsync -r "$src_dir/" "$dest_dir/"
rm -rf $src_dir/* rm -rf "${src_dir}"/*
# Replace any finalization references due to the move # Replace any finalization references due to the move
find ./src/Sql/dbo -name "*.sql" -type f -exec sed -i \ find ./src/Sql/dbo -name "*.sql" -type f -exec sed -i \
@@ -131,7 +133,7 @@ jobs:
moved_files="$moved_files \n $file" moved_files="$moved_files \n $file"
done done
echo "moved_files=$moved_files" >> $GITHUB_OUTPUT echo "moved_files=$moved_files" >> "$GITHUB_OUTPUT"
- name: Log in to Azure - name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main uses: bitwarden/gh-actions/azure-login@main
@@ -162,18 +164,20 @@ jobs:
- name: Commit and push changes - name: Commit and push changes
id: commit id: commit
env:
BRANCH_NAME: ${{ steps.branch_name.outputs.branch_name }}
run: | run: |
git config --local user.email "106330231+bitwarden-devops-bot@users.noreply.github.com" git config --local user.email "106330231+bitwarden-devops-bot@users.noreply.github.com"
git config --local user.name "bitwarden-devops-bot" git config --local user.name "bitwarden-devops-bot"
if [ -n "$(git status --porcelain)" ]; then if [ -n "$(git status --porcelain)" ]; then
git add . git add .
git commit -m "Move EDD database scripts" -a git commit -m "Move EDD database scripts" -a
git push -u origin ${{ steps.branch_name.outputs.branch_name }} git push -u origin "${BRANCH_NAME}"
echo "pr_needed=true" >> $GITHUB_OUTPUT echo "pr_needed=true" >> "$GITHUB_OUTPUT"
else else
echo "No changes to commit!"; echo "No changes to commit!";
echo "pr_needed=false" >> $GITHUB_OUTPUT echo "pr_needed=false" >> "$GITHUB_OUTPUT"
echo "### :mega: No changes to commit! PR was ommited." >> $GITHUB_STEP_SUMMARY echo "### :mega: No changes to commit! PR was ommited." >> "$GITHUB_STEP_SUMMARY"
fi fi
- name: Create PR for ${{ steps.branch_name.outputs.branch_name }} - name: Create PR for ${{ steps.branch_name.outputs.branch_name }}
@@ -195,7 +199,7 @@ jobs:
Files moved: Files moved:
$(echo -e "$MOVED_FILES") $(echo -e "$MOVED_FILES")
") ")
echo "pr_url=${PR_URL}" >> $GITHUB_OUTPUT echo "pr_url=${PR_URL}" >> "$GITHUB_OUTPUT"
- name: Notify Slack about creation of PR - name: Notify Slack about creation of PR
if: ${{ steps.commit.outputs.pr_needed == 'true' }} if: ${{ steps.commit.outputs.pr_needed == 'true' }}

View File

@@ -22,22 +22,23 @@ env:
jobs: jobs:
lint: lint:
name: Lint name: Lint
runs-on: ubuntu-24.04 runs-on: ubuntu-22.04
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
- name: Verify format - name: Verify format
run: dotnet format --verify-no-changes run: dotnet format --verify-no-changes
build-artifacts: build-artifacts:
name: Build Docker images name: Build Docker images
runs-on: ubuntu-24.04 runs-on: ubuntu-22.04
needs: needs:
- lint - lint
outputs: outputs:
@@ -45,6 +46,7 @@ jobs:
permissions: permissions:
security-events: write security-events: write
id-token: write id-token: write
timeout-minutes: 45
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@@ -97,30 +99,31 @@ jobs:
id: check-secrets id: check-secrets
run: | run: |
has_secrets=${{ secrets.AZURE_CLIENT_ID != '' }} has_secrets=${{ secrets.AZURE_CLIENT_ID != '' }}
echo "has_secrets=$has_secrets" >> $GITHUB_OUTPUT echo "has_secrets=$has_secrets" >> "$GITHUB_OUTPUT"
- name: Check out repo - name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false
- name: Check branch to publish - name: Check branch to publish
env: env:
PUBLISH_BRANCHES: "main,rc,hotfix-rc" PUBLISH_BRANCHES: "main,rc,hotfix-rc"
id: publish-branch-check id: publish-branch-check
run: | run: |
IFS="," read -a publish_branches <<< $PUBLISH_BRANCHES IFS="," read -a publish_branches <<< "$PUBLISH_BRANCHES"
if [[ " ${publish_branches[*]} " =~ " ${GITHUB_REF:11} " ]]; then if [[ " ${publish_branches[*]} " =~ " ${GITHUB_REF:11} " ]]; then
echo "is_publish_branch=true" >> $GITHUB_ENV echo "is_publish_branch=true" >> "$GITHUB_ENV"
else else
echo "is_publish_branch=false" >> $GITHUB_ENV echo "is_publish_branch=false" >> "$GITHUB_ENV"
fi fi
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
- name: Set up Node - name: Set up Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with: with:
cache: "npm" cache: "npm"
cache-dependency-path: "**/package-lock.json" cache-dependency-path: "**/package-lock.json"
@@ -157,7 +160,7 @@ jobs:
ls -atlh ../../../ ls -atlh ../../../
- name: Upload project artifact - name: Upload project artifact
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
if: ${{ matrix.dotnet }} if: ${{ matrix.dotnet }}
with: with:
name: ${{ matrix.project_name }}.zip name: ${{ matrix.project_name }}.zip
@@ -209,8 +212,8 @@ jobs:
IMAGE_TAG=dev IMAGE_TAG=dev
fi fi
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT echo "image_tag=$IMAGE_TAG" >> "$GITHUB_OUTPUT"
echo "### :mega: Docker Image Tag: $IMAGE_TAG" >> $GITHUB_STEP_SUMMARY echo "### :mega: Docker Image Tag: $IMAGE_TAG" >> "$GITHUB_STEP_SUMMARY"
- name: Set up project name - name: Set up project name
id: setup id: setup
@@ -218,7 +221,7 @@ jobs:
PROJECT_NAME=$(echo "${{ matrix.project_name }}" | awk '{print tolower($0)}') PROJECT_NAME=$(echo "${{ matrix.project_name }}" | awk '{print tolower($0)}')
echo "Matrix name: ${{ matrix.project_name }}" echo "Matrix name: ${{ matrix.project_name }}"
echo "PROJECT_NAME: $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) - name: Generate image tags(s)
id: image-tags id: image-tags
@@ -228,12 +231,12 @@ jobs:
SHA: ${{ github.sha }} SHA: ${{ github.sha }}
run: | run: |
TAGS="${_AZ_REGISTRY}/${PROJECT_NAME}:${IMAGE_TAG}" TAGS="${_AZ_REGISTRY}/${PROJECT_NAME}:${IMAGE_TAG}"
echo "primary_tag=$TAGS" >> $GITHUB_OUTPUT echo "primary_tag=$TAGS" >> "$GITHUB_OUTPUT"
if [[ "${IMAGE_TAG}" == "dev" ]]; then 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}" TAGS=$TAGS",${_AZ_REGISTRY}/${PROJECT_NAME}:dev-${SHORT_SHA}"
fi fi
echo "tags=$TAGS" >> $GITHUB_OUTPUT echo "tags=$TAGS" >> "$GITHUB_OUTPUT"
- name: Build Docker image - name: Build Docker image
id: build-artifacts id: build-artifacts
@@ -260,23 +263,24 @@ jobs:
DIGEST: ${{ steps.build-artifacts.outputs.digest }} DIGEST: ${{ steps.build-artifacts.outputs.digest }}
TAGS: ${{ steps.image-tags.outputs.tags }} TAGS: ${{ steps.image-tags.outputs.tags }}
run: | run: |
IFS="," read -a tags <<< "${TAGS}" IFS=',' read -r -a tags_array <<< "${TAGS}"
images="" images=()
for tag in "${tags[@]}"; do for tag in "${tags_array[@]}"; do
images+="${tag}@${DIGEST} " images+=("${tag}@${DIGEST}")
done done
cosign sign --yes ${images} cosign sign --yes ${images[@]}
echo "images=${images[*]}" >> "$GITHUB_OUTPUT"
- name: Scan Docker image - name: Scan Docker image
id: container-scan id: container-scan
uses: anchore/scan-action@2c901ab7378897c01b8efaa2d0c9bf519cc64b9e # v6.2.0 uses: anchore/scan-action@f6601287cdb1efc985d6b765bbf99cb4c0ac29d8 # v7.0.0
with: with:
image: ${{ steps.image-tags.outputs.primary_tag }} image: ${{ steps.image-tags.outputs.primary_tag }}
fail-build: false fail-build: false
output-format: sarif output-format: sarif
- name: Upload Grype results to GitHub - name: Upload Grype results to GitHub
uses: github/codeql-action/upload-sarif@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3.28.8 uses: github/codeql-action/upload-sarif@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
with: with:
sarif_file: ${{ steps.container-scan.outputs.sarif }} sarif_file: ${{ steps.container-scan.outputs.sarif }}
sha: ${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.sha || github.sha }} sha: ${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.sha || github.sha }}
@@ -287,7 +291,7 @@ jobs:
upload: upload:
name: Upload name: Upload
runs-on: ubuntu-24.04 runs-on: ubuntu-22.04
needs: build-artifacts needs: build-artifacts
permissions: permissions:
id-token: write id-token: write
@@ -297,9 +301,10 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
- name: Log in to Azure - name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main uses: bitwarden/gh-actions/azure-login@main
@@ -309,7 +314,7 @@ jobs:
client_id: ${{ secrets.AZURE_CLIENT_ID }} client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Log in to ACR - production subscription - name: Log in to ACR - production subscription
run: az acr login -n $_AZ_REGISTRY --only-show-errors run: az acr login -n "$_AZ_REGISTRY" --only-show-errors
- name: Make Docker stubs - name: Make Docker stubs
if: | if: |
@@ -332,26 +337,26 @@ jobs:
STUB_OUTPUT=$(pwd)/docker-stub STUB_OUTPUT=$(pwd)/docker-stub
# Run setup # 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 /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 /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 # Remove extra directories and files
rm -rf $STUB_OUTPUT/US/letsencrypt rm -rf "$STUB_OUTPUT/US/letsencrypt"
rm -rf $STUB_OUTPUT/EU/letsencrypt rm -rf "$STUB_OUTPUT/EU/letsencrypt"
rm $STUB_OUTPUT/US/env/uid.env $STUB_OUTPUT/US/config.yml 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 "$STUB_OUTPUT/EU/env/uid.env" "$STUB_OUTPUT/EU/config.yml"
# Create uid environment files # Create uid environment files
touch $STUB_OUTPUT/US/env/uid.env touch "$STUB_OUTPUT/US/env/uid.env"
touch $STUB_OUTPUT/EU/env/uid.env touch "$STUB_OUTPUT/EU/env/uid.env"
# Zip up the Docker stub files # Zip up the Docker stub files
cd docker-stub/US; zip -r ../../docker-stub-US.zip *; cd ../.. cd docker-stub/US; zip -r ../../docker-stub-US.zip ./*; cd ../..
cd docker-stub/EU; zip -r ../../docker-stub-EU.zip *; cd ../.. cd docker-stub/EU; zip -r ../../docker-stub-EU.zip ./*; cd ../..
- name: Log out from Azure - name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main uses: bitwarden/gh-actions/azure-logout@main
@@ -360,7 +365,7 @@ jobs:
if: | if: |
github.event_name != 'pull_request' github.event_name != 'pull_request'
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with: with:
name: docker-stub-US.zip name: docker-stub-US.zip
path: docker-stub-US.zip path: docker-stub-US.zip
@@ -370,7 +375,7 @@ jobs:
if: | if: |
github.event_name != 'pull_request' github.event_name != 'pull_request'
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with: with:
name: docker-stub-EU.zip name: docker-stub-EU.zip
path: docker-stub-EU.zip path: docker-stub-EU.zip
@@ -382,21 +387,21 @@ jobs:
pwsh ./generate_openapi_files.ps1 pwsh ./generate_openapi_files.ps1
- name: Upload Public API Swagger artifact - name: Upload Public API Swagger artifact
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with: with:
name: swagger.json name: swagger.json
path: api.public.json path: api.public.json
if-no-files-found: error if-no-files-found: error
- name: Upload Internal API Swagger artifact - name: Upload Internal API Swagger artifact
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with: with:
name: internal.json name: internal.json
path: api.json path: api.json
if-no-files-found: error if-no-files-found: error
- name: Upload Identity Swagger artifact - name: Upload Identity Swagger artifact
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with: with:
name: identity.json name: identity.json
path: identity.json path: identity.json
@@ -404,7 +409,7 @@ jobs:
build-mssqlmigratorutility: build-mssqlmigratorutility:
name: Build MSSQL migrator utility name: Build MSSQL migrator utility
runs-on: ubuntu-24.04 runs-on: ubuntu-22.04
needs: needs:
- lint - lint
defaults: defaults:
@@ -423,9 +428,10 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
- name: Print environment - name: Print environment
run: | run: |
@@ -441,7 +447,7 @@ jobs:
- name: Upload project artifact for Windows - name: Upload project artifact for Windows
if: ${{ contains(matrix.target, 'win') == true }} if: ${{ contains(matrix.target, 'win') == true }}
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with: with:
name: MsSqlMigratorUtility-${{ matrix.target }} name: MsSqlMigratorUtility-${{ matrix.target }}
path: util/MsSqlMigratorUtility/obj/build-output/publish/MsSqlMigratorUtility.exe path: util/MsSqlMigratorUtility/obj/build-output/publish/MsSqlMigratorUtility.exe
@@ -449,7 +455,7 @@ jobs:
- name: Upload project artifact - name: Upload project artifact
if: ${{ contains(matrix.target, 'win') == false }} if: ${{ contains(matrix.target, 'win') == false }}
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with: with:
name: MsSqlMigratorUtility-${{ matrix.target }} name: MsSqlMigratorUtility-${{ matrix.target }}
path: util/MsSqlMigratorUtility/obj/build-output/publish/MsSqlMigratorUtility path: util/MsSqlMigratorUtility/obj/build-output/publish/MsSqlMigratorUtility
@@ -460,7 +466,7 @@ jobs:
if: | if: |
github.event_name != 'pull_request' github.event_name != 'pull_request'
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
runs-on: ubuntu-24.04 runs-on: ubuntu-22.04
needs: needs:
- build-artifacts - build-artifacts
permissions: permissions:
@@ -483,15 +489,15 @@ jobs:
- name: Log out from Azure - name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main uses: bitwarden/gh-actions/azure-logout@main
- name: Trigger self-host build - name: Trigger Bitwarden Lite build
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with: with:
github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }} github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
script: | script: |
await github.rest.actions.createWorkflowDispatch({ await github.rest.actions.createWorkflowDispatch({
owner: 'bitwarden', owner: 'bitwarden',
repo: 'self-host', repo: 'self-host',
workflow_id: 'build-unified.yml', workflow_id: 'build-bitwarden-lite.yml',
ref: 'main', ref: 'main',
inputs: { inputs: {
server_branch: process.env.GITHUB_REF server_branch: process.env.GITHUB_REF
@@ -525,7 +531,7 @@ jobs:
uses: bitwarden/gh-actions/azure-logout@main uses: bitwarden/gh-actions/azure-logout@main
- name: Trigger k8s deploy - name: Trigger k8s deploy
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with: with:
github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }} github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
script: | script: |

View File

@@ -22,7 +22,7 @@ jobs:
client_id: ${{ secrets.AZURE_CLIENT_ID }} client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Log in to Azure ACR - name: Log in to Azure ACR
run: az acr login -n $_AZ_REGISTRY --only-show-errors run: az acr login -n "$_AZ_REGISTRY" --only-show-errors
########## Remove Docker images ########## ########## Remove Docker images ##########
- name: Remove the Docker image from ACR - name: Remove the Docker image from ACR
@@ -45,20 +45,20 @@ jobs:
- Setup - Setup
- Sso - Sso
run: | run: |
for SERVICE in $(echo "${{ env.SERVICES }}" | yq e ".services[]" - ) for SERVICE in $(echo "${SERVICES}" | yq e ".services[]" - )
do 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 IMAGE_TAG=$(echo "${REF}" | sed "s#/#-#g") # slash safe branch name
echo "[*] Checking if remote exists: $_AZ_REGISTRY/$SERVICE_NAME:$IMAGE_TAG" echo "[*] Checking if remote exists: $_AZ_REGISTRY/$SERVICE_NAME:$IMAGE_TAG"
TAG_EXISTS=$( TAG_EXISTS=$(
az acr repository show-tags --name $_AZ_REGISTRY --repository $SERVICE_NAME \ az acr repository show-tags --name "$_AZ_REGISTRY" --repository "$SERVICE_NAME" \
| jq --arg $TAG "$IMAGE_TAG" -e '. | any(. == "$TAG")' | jq --arg TAG "$IMAGE_TAG" -e '. | any(. == $TAG)'
) )
if [[ "$TAG_EXISTS" == "true" ]]; then if [[ "$TAG_EXISTS" == "true" ]]; then
echo "[*] Tag exists. Removing tag" 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 else
echo "[*] Tag does not exist. No action needed" echo "[*] Tag does not exist. No action needed"
fi fi

View File

@@ -35,6 +35,8 @@ jobs:
with: with:
ref: main ref: main
token: ${{ steps.retrieve-bot-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }} token: ${{ steps.retrieve-bot-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
persist-credentials: false
fetch-depth: 0
- name: Check if a RC branch exists - name: Check if a RC branch exists
id: branch-check id: branch-check
@@ -43,11 +45,11 @@ jobs:
rc_branch_check=$(git ls-remote --heads origin rc | wc -l) rc_branch_check=$(git ls-remote --heads origin rc | wc -l)
if [[ "${hotfix_rc_branch_check}" -gt 0 ]]; then if [[ "${hotfix_rc_branch_check}" -gt 0 ]]; then
echo "hotfix-rc branch exists." | tee -a $GITHUB_STEP_SUMMARY echo "hotfix-rc branch exists." | tee -a "$GITHUB_STEP_SUMMARY"
echo "name=hotfix-rc" >> $GITHUB_OUTPUT echo "name=hotfix-rc" >> "$GITHUB_OUTPUT"
elif [[ "${rc_branch_check}" -gt 0 ]]; then elif [[ "${rc_branch_check}" -gt 0 ]]; then
echo "rc branch exists." | tee -a $GITHUB_STEP_SUMMARY echo "rc branch exists." | tee -a "$GITHUB_STEP_SUMMARY"
echo "name=rc" >> $GITHUB_OUTPUT echo "name=rc" >> "$GITHUB_OUTPUT"
fi fi
- name: Delete RC branch - name: Delete RC branch
@@ -55,6 +57,6 @@ jobs:
BRANCH_NAME: ${{ steps.branch-check.outputs.name }} BRANCH_NAME: ${{ steps.branch-check.outputs.name }}
run: | run: |
if ! [[ -z "$BRANCH_NAME" ]]; then if ! [[ -z "$BRANCH_NAME" ]]; then
git push --quiet origin --delete $BRANCH_NAME git push --quiet origin --delete "$BRANCH_NAME"
echo "Deleted $BRANCH_NAME branch." | tee -a $GITHUB_STEP_SUMMARY echo "Deleted $BRANCH_NAME branch." | tee -a "$GITHUB_STEP_SUMMARY"
fi fi

View File

@@ -19,9 +19,9 @@ jobs:
id: check-secret-access id: check-secret-access
run: | run: |
if [ "${{ secrets.AZURE_CLIENT_ID }}" != '' ]; then if [ "${{ secrets.AZURE_CLIENT_ID }}" != '' ]; then
echo "available=true" >> $GITHUB_OUTPUT; echo "available=true" >> "$GITHUB_OUTPUT";
else else
echo "available=false" >> $GITHUB_OUTPUT; echo "available=false" >> "$GITHUB_OUTPUT";
fi fi
refs: refs:
@@ -37,6 +37,8 @@ jobs:
steps: steps:
- name: Check out repository - name: Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Log in to Azure - name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main uses: bitwarden/gh-actions/azure-login@main
@@ -65,14 +67,14 @@ jobs:
- name: Add label - name: Add label
if: steps.collect.outputs.any-changed == 'true' 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: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }} PR_NUMBER: ${{ github.event.pull_request.number }}
- name: Remove label - name: Remove label
if: steps.collect.outputs.any-changed == 'false' 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: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }} PR_NUMBER: ${{ github.event.pull_request.number }}

View File

@@ -17,5 +17,5 @@ jobs:
- name: Check for label - name: Check for label
run: | run: |
echo "PRs with the hold, needs-qa or ephemeral-environment labels cannot be merged" 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 exit 1

View File

@@ -16,5 +16,5 @@ jobs:
with: with:
project: server project: server
pull_request_number: ${{ github.event.number }} pull_request_number: ${{ github.event.number }}
sync_environment: true sync_environment: false
secrets: inherit secrets: inherit

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

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

View File

@@ -36,21 +36,23 @@ jobs:
steps: steps:
- name: Version output - name: Version output
id: version-output id: version-output
env:
INPUT_VERSION: ${{ inputs.version }}
run: | run: |
if [[ "${{ inputs.version }}" == "latest" || "${{ inputs.version }}" == "" ]]; then if [[ "${INPUT_VERSION}" == "latest" || "${INPUT_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]+') 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 "Latest Released Version: $VERSION"
echo "version=$VERSION" >> $GITHUB_OUTPUT echo "version=$VERSION" >> "$GITHUB_OUTPUT"
else else
echo "Release Version: ${{ inputs.version }}" echo "Release Version: ${INPUT_VERSION}"
echo "version=${{ inputs.version }}" >> $GITHUB_OUTPUT echo "version=${INPUT_VERSION}" >> "$GITHUB_OUTPUT"
fi fi
- name: Get branch name - name: Get branch name
id: branch id: branch
run: | run: |
BRANCH_NAME=$(basename ${{ github.ref }}) BRANCH_NAME=$(basename "${GITHUB_REF}")
echo "branch-name=$BRANCH_NAME" >> $GITHUB_OUTPUT echo "branch-name=$BRANCH_NAME" >> "$GITHUB_OUTPUT"
- name: Create GitHub deployment - name: Create GitHub deployment
uses: chrnorm/deployment-action@55729fcebec3d284f60f5bcabbd8376437d696b1 # v2.0.7 uses: chrnorm/deployment-action@55729fcebec3d284f60f5bcabbd8376437d696b1 # v2.0.7
@@ -105,6 +107,9 @@ jobs:
- name: Check out repo - name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
persist-credentials: false
- name: Set up project name - name: Set up project name
id: setup id: setup
@@ -112,7 +117,7 @@ jobs:
PROJECT_NAME=$(echo "${{ matrix.project_name }}" | awk '{print tolower($0)}') PROJECT_NAME=$(echo "${{ matrix.project_name }}" | awk '{print tolower($0)}')
echo "Matrix name: ${{ matrix.project_name }}" echo "Matrix name: ${{ matrix.project_name }}"
echo "PROJECT_NAME: $PROJECT_NAME" echo "PROJECT_NAME: $PROJECT_NAME"
echo "project_name=$PROJECT_NAME" >> $GITHUB_OUTPUT echo "project_name=$PROJECT_NAME" >> "$GITHUB_OUTPUT"
########## ACR PROD ########## ########## ACR PROD ##########
- name: Log in to Azure - name: Log in to Azure
@@ -123,16 +128,16 @@ jobs:
client_id: ${{ secrets.AZURE_CLIENT_ID }} client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Log in to Azure ACR - name: Log in to Azure ACR
run: az acr login -n $_AZ_REGISTRY --only-show-errors run: az acr login -n "$_AZ_REGISTRY" --only-show-errors
- name: Pull latest project image - name: Pull latest project image
env: env:
PROJECT_NAME: ${{ steps.setup.outputs.project_name }} PROJECT_NAME: ${{ steps.setup.outputs.project_name }}
run: | run: |
if [[ "${{ inputs.publish_type }}" == "Dry Run" ]]; then if [[ "${{ inputs.publish_type }}" == "Dry Run" ]]; then
docker pull $_AZ_REGISTRY/$PROJECT_NAME:latest docker pull "$_AZ_REGISTRY/$PROJECT_NAME:latest"
else else
docker pull $_AZ_REGISTRY/$PROJECT_NAME:$_BRANCH_NAME docker pull "$_AZ_REGISTRY/$PROJECT_NAME:$_BRANCH_NAME"
fi fi
- name: Tag version and latest - name: Tag version and latest
@@ -140,10 +145,10 @@ jobs:
PROJECT_NAME: ${{ steps.setup.outputs.project_name }} PROJECT_NAME: ${{ steps.setup.outputs.project_name }}
run: | run: |
if [[ "${{ inputs.publish_type }}" == "Dry Run" ]]; then 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 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:$_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:latest"
fi fi
- name: Push version and latest image - name: Push version and latest image
@@ -151,10 +156,10 @@ jobs:
PROJECT_NAME: ${{ steps.setup.outputs.project_name }} PROJECT_NAME: ${{ steps.setup.outputs.project_name }}
run: | run: |
if [[ "${{ inputs.publish_type }}" == "Dry Run" ]]; then if [[ "${{ inputs.publish_type }}" == "Dry Run" ]]; then
docker push $_AZ_REGISTRY/$PROJECT_NAME:dryrun docker push "$_AZ_REGISTRY/$PROJECT_NAME:dryrun"
else else
docker push $_AZ_REGISTRY/$PROJECT_NAME:$_RELEASE_VERSION docker push "$_AZ_REGISTRY/$PROJECT_NAME:$_RELEASE_VERSION"
docker push $_AZ_REGISTRY/$PROJECT_NAME:latest docker push "$_AZ_REGISTRY/$PROJECT_NAME:latest"
fi fi
- name: Log out of Docker - name: Log out of Docker

View File

@@ -40,6 +40,9 @@ jobs:
- name: Check out repo - name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
persist-credentials: false
- name: Check release version - name: Check release version
id: version id: version
@@ -52,8 +55,8 @@ jobs:
- name: Get branch name - name: Get branch name
id: branch id: branch
run: | run: |
BRANCH_NAME=$(basename ${{ github.ref }}) BRANCH_NAME=$(basename "${GITHUB_REF}")
echo "branch-name=$BRANCH_NAME" >> $GITHUB_OUTPUT echo "branch-name=$BRANCH_NAME" >> "$GITHUB_OUTPUT"
release: release:
name: Create GitHub release name: Create GitHub release

View File

@@ -22,7 +22,9 @@ on:
required: false required: false
type: string type: string
permissions: {} permissions:
pull-requests: write
contents: write
jobs: jobs:
setup: setup:
@@ -44,7 +46,7 @@ jobs:
BRANCH="hotfix-rc" BRANCH="hotfix-rc"
fi fi
echo "branch=$BRANCH" >> $GITHUB_OUTPUT echo "branch=$BRANCH" >> "$GITHUB_OUTPUT"
bump_version: bump_version:
name: Bump Version name: Bump Version
@@ -82,7 +84,7 @@ jobs:
version: ${{ inputs.version_number_override }} version: ${{ inputs.version_number_override }}
- name: Generate GH App token - name: Generate GH App token
uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1.12.0 uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1
id: app-token id: app-token
with: with:
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
@@ -93,6 +95,7 @@ jobs:
with: with:
ref: main ref: main
token: ${{ steps.app-token.outputs.token }} token: ${{ steps.app-token.outputs.token }}
persist-credentials: true
- name: Configure Git - name: Configure Git
run: | run: |
@@ -108,7 +111,7 @@ jobs:
id: current-version id: current-version
run: | run: |
CURRENT_VERSION=$(xmllint -xpath "/Project/PropertyGroup/Version/text()" Directory.Build.props) 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 - name: Verify input version
if: ${{ inputs.version_number_override != '' }} if: ${{ inputs.version_number_override != '' }}
@@ -118,16 +121,15 @@ jobs:
run: | run: |
# Error if version has not changed. # Error if version has not changed.
if [[ "$NEW_VERSION" == "$CURRENT_VERSION" ]]; then 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 exit 1
fi fi
# Check if version is newer. # Check if version is newer.
printf '%s\n' "${CURRENT_VERSION}" "${NEW_VERSION}" | sort -C -V if printf '%s\n' "${CURRENT_VERSION}" "${NEW_VERSION}" | sort -C -V; then
if [ $? -eq 0 ]; then
echo "Version is newer than the current version." echo "Version is newer than the current version."
else 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 exit 1
fi fi
@@ -158,15 +160,20 @@ jobs:
id: set-final-version-output id: set-final-version-output
env: env:
VERSION: ${{ inputs.version_number_override }} 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: | run: |
if [[ "${{ steps.bump-version-override.outcome }}" = "success" ]]; then if [[ "${BUMP_VERSION_OVERRIDE_OUTCOME}" = "success" ]]; then
echo "version=$VERSION" >> $GITHUB_OUTPUT echo "version=$VERSION" >> "$GITHUB_OUTPUT"
elif [[ "${{ steps.bump-version-automatic.outcome }}" = "success" ]]; then elif [[ "${BUMP_VERSION_AUTOMATIC_OUTCOME}" = "success" ]]; then
echo "version=${{ steps.calculate-next-version.outputs.version }}" >> $GITHUB_OUTPUT echo "version=${CALCULATE_NEXT_VERSION}" >> "$GITHUB_OUTPUT"
fi fi
- name: Commit files - name: Commit files
run: git commit -m "Bumped version to ${{ steps.set-final-version-output.outputs.version }}" -a env:
FINAL_VERSION: ${{ steps.set-final-version-output.outputs.version }}
run: git commit -m "Bumped version to $FINAL_VERSION" -a
- name: Push changes - name: Push changes
run: git push run: git push
@@ -200,7 +207,7 @@ jobs:
uses: bitwarden/gh-actions/azure-logout@main uses: bitwarden/gh-actions/azure-logout@main
- name: Generate GH App token - name: Generate GH App token
uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1.12.0 uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1
id: app-token id: app-token
with: with:
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
@@ -211,13 +218,15 @@ jobs:
with: with:
ref: ${{ inputs.target_ref }} ref: ${{ inputs.target_ref }}
token: ${{ steps.app-token.outputs.token }} token: ${{ steps.app-token.outputs.token }}
persist-credentials: true
fetch-depth: 0
- name: Check if ${{ needs.setup.outputs.branch }} branch exists - name: Check if ${{ needs.setup.outputs.branch }} branch exists
env: env:
BRANCH_NAME: ${{ needs.setup.outputs.branch }} BRANCH_NAME: ${{ needs.setup.outputs.branch }}
run: | run: |
if [[ $(git ls-remote --heads origin $BRANCH_NAME) ]]; then if [[ $(git ls-remote --heads origin "$BRANCH_NAME") ]]; then
echo "$BRANCH_NAME already exists! Please delete $BRANCH_NAME before running again." >> $GITHUB_STEP_SUMMARY echo "$BRANCH_NAME already exists! Please delete $BRANCH_NAME before running again." >> "$GITHUB_STEP_SUMMARY"
exit 1 exit 1
fi fi
@@ -225,11 +234,16 @@ jobs:
env: env:
BRANCH_NAME: ${{ needs.setup.outputs.branch }} BRANCH_NAME: ${{ needs.setup.outputs.branch }}
run: | run: |
git switch --quiet --create $BRANCH_NAME git switch --quiet --create "$BRANCH_NAME"
git push --quiet --set-upstream origin $BRANCH_NAME git push --quiet --set-upstream origin "$BRANCH_NAME"
move_edd_db_scripts: move_edd_db_scripts:
name: Move EDD database scripts name: Move EDD database scripts
needs: cut_branch needs: cut_branch
permissions:
actions: read
contents: write
id-token: write
pull-requests: write
uses: ./.github/workflows/_move_edd_db_scripts.yml uses: ./.github/workflows/_move_edd_db_scripts.yml
secrets: inherit secrets: inherit

28
.github/workflows/respond.yml vendored Normal file
View File

@@ -0,0 +1,28 @@
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

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

@@ -0,0 +1,21 @@
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

@@ -45,9 +45,11 @@ jobs:
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
- name: Restore tools - name: Restore tools
run: dotnet tool restore run: dotnet tool restore
@@ -60,7 +62,7 @@ jobs:
docker compose --profile mssql --profile postgres --profile mysql up -d docker compose --profile mssql --profile postgres --profile mysql up -d
shell: pwsh shell: pwsh
- name: Add MariaDB for unified - name: Add MariaDB for Bitwarden Lite
# Use a different port than MySQL # Use a different port than MySQL
run: | run: |
docker run --detach --name mariadb --env MARIADB_ROOT_PASSWORD=mariadb-password -p 4306:3306 mariadb:10 docker run --detach --name mariadb --env MARIADB_ROOT_PASSWORD=mariadb-password -p 4306:3306 mariadb:10
@@ -131,7 +133,7 @@ jobs:
# Default Sqlite # Default Sqlite
BW_TEST_DATABASES__3__TYPE: "Sqlite" BW_TEST_DATABASES__3__TYPE: "Sqlite"
BW_TEST_DATABASES__3__CONNECTIONSTRING: "Data Source=${{ runner.temp }}/test.db" BW_TEST_DATABASES__3__CONNECTIONSTRING: "Data Source=${{ runner.temp }}/test.db"
# Unified MariaDB # Bitwarden Lite MariaDB
BW_TEST_DATABASES__4__TYPE: "MySql" 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" 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" run: dotnet test --logger "trx;LogFileName=infrastructure-test-results.trx" /p:CoverletOutputFormatter="cobertura" --collect:"XPlat Code Coverage"
@@ -139,26 +141,26 @@ jobs:
- name: Print MySQL Logs - name: Print MySQL Logs
if: failure() if: failure()
run: 'docker logs $(docker ps --quiet --filter "name=mysql")' run: 'docker logs "$(docker ps --quiet --filter "name=mysql")"'
- name: Print MariaDB Logs - name: Print MariaDB Logs
if: failure() if: failure()
run: 'docker logs $(docker ps --quiet --filter "name=mariadb")' run: 'docker logs "$(docker ps --quiet --filter "name=mariadb")"'
- name: Print Postgres Logs - name: Print Postgres Logs
if: failure() if: failure()
run: 'docker logs $(docker ps --quiet --filter "name=postgres")' run: 'docker logs "$(docker ps --quiet --filter "name=postgres")"'
- name: Print MSSQL Logs - name: Print MSSQL Logs
if: failure() if: failure()
run: 'docker logs $(docker ps --quiet --filter "name=mssql")' run: 'docker logs "$(docker ps --quiet --filter "name=mssql")"'
- name: Report test results - name: Report test results
uses: dorny/test-reporter@890a17cecf52a379fc869ab770a71657660be727 # v2.1.0 uses: dorny/test-reporter@890a17cecf52a379fc869ab770a71657660be727 # v2.1.0
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !cancelled() }} if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !cancelled() }}
with: with:
name: Test Results name: Test Results
path: "**/*-test-results.trx" path: "./**/*-test-results.trx"
reporter: dotnet-trx reporter: dotnet-trx
fail-on-error: true fail-on-error: true
@@ -177,9 +179,11 @@ jobs:
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
- name: Print environment - name: Print environment
run: | run: |
@@ -193,7 +197,7 @@ jobs:
shell: pwsh shell: pwsh
- name: Upload DACPAC - name: Upload DACPAC
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with: with:
name: sql.dacpac name: sql.dacpac
path: Sql.dacpac path: Sql.dacpac
@@ -219,7 +223,7 @@ jobs:
shell: pwsh shell: pwsh
- name: Report validation results - name: Report validation results
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with: with:
name: report.xml name: report.xml
path: | path: |

View File

@@ -28,9 +28,19 @@ jobs:
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 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
- name: Print environment - name: Print environment
run: | run: |

6
.gitignore vendored
View File

@@ -215,6 +215,9 @@ bitwarden_license/src/Sso/wwwroot/assets
**/**.swp **/**.swp
.mono .mono
src/Core/MailTemplates/Mjml/out src/Core/MailTemplates/Mjml/out
src/Core/MailTemplates/Mjml/out-hbs
NativeMethods.g.cs
util/RustSdk/rust/target
src/Admin/Admin.zip src/Admin/Admin.zip
src/Api/Api.zip src/Api/Api.zip
@@ -231,3 +234,6 @@ bitwarden_license/src/Sso/Sso.zip
/identity.json /identity.json
/api.json /api.json
/api.public.json /api.public.json
# Serena
.serena/

View File

@@ -3,12 +3,10 @@
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<Version>2025.8.1</Version> <Version>2025.11.1</Version>
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace> <RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
<IsTestProject Condition="'$(IsTestProject)' == '' and ($(MSBuildProjectName.EndsWith('.Test')) or $(MSBuildProjectName.EndsWith('.IntegrationTest')))">true</IsTestProject> <IsTestProject Condition="'$(IsTestProject)' == '' and ($(MSBuildProjectName.EndsWith('.Test')) or $(MSBuildProjectName.EndsWith('.IntegrationTest')))">true</IsTestProject>
<Nullable Condition="'$(Nullable)' == '' and '$(IsTestProject)' == 'true'">annotations</Nullable> <Nullable Condition="'$(Nullable)' == '' and '$(IsTestProject)' == 'true'">annotations</Nullable>
<Nullable Condition="'$(Nullable)' == '' and '$(IsTestProject)' != 'true'">enable</Nullable> <Nullable Condition="'$(Nullable)' == '' and '$(IsTestProject)' != 'true'">enable</Nullable>
@@ -32,19 +30,4 @@
<AutoFixtureAutoNSubstituteVersion>4.18.1</AutoFixtureAutoNSubstituteVersion> <AutoFixtureAutoNSubstituteVersion>4.18.1</AutoFixtureAutoNSubstituteVersion>
</PropertyGroup> </PropertyGroup>
<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> </Project>

View File

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

View File

@@ -148,22 +148,30 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
} }
else if (organization.IsStripeEnabled()) else if (organization.IsStripeEnabled())
{ {
var subscription = await _stripeAdapter.SubscriptionGetAsync(organization.GatewaySubscriptionId); var subscription = await _stripeAdapter.SubscriptionGetAsync(organization.GatewaySubscriptionId, new SubscriptionGetOptions
{
Expand = ["customer"]
});
if (subscription.Status is StripeConstants.SubscriptionStatus.Canceled or StripeConstants.SubscriptionStatus.IncompleteExpired) if (subscription.Status is StripeConstants.SubscriptionStatus.Canceled or StripeConstants.SubscriptionStatus.IncompleteExpired)
{ {
return; return;
} }
await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, new CustomerUpdateOptions await _stripeAdapter.CustomerUpdateAsync(subscription.CustomerId, new CustomerUpdateOptions
{ {
Coupon = string.Empty,
Email = organization.BillingEmail Email = organization.BillingEmail
}); });
if (subscription.Customer.Discount?.Coupon != null)
{
await _stripeAdapter.CustomerDeleteDiscountAsync(subscription.CustomerId);
}
await _stripeAdapter.SubscriptionUpdateAsync(organization.GatewaySubscriptionId, new SubscriptionUpdateOptions await _stripeAdapter.SubscriptionUpdateAsync(organization.GatewaySubscriptionId, new SubscriptionUpdateOptions
{ {
CollectionMethod = StripeConstants.CollectionMethod.SendInvoice, CollectionMethod = StripeConstants.CollectionMethod.SendInvoice,
DaysUntilDue = 30 DaysUntilDue = 30,
}); });
await _subscriberService.RemovePaymentSource(organization); await _subscriberService.RemovePaymentSource(organization);

View File

@@ -12,7 +12,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Organizations;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Models; using Bit.Core.Billing.Payment.Models;
using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Providers.Services; using Bit.Core.Billing.Providers.Services;
using Bit.Core.Context; using Bit.Core.Context;
@@ -35,8 +35,9 @@ public class ProviderService : IProviderService
{ {
private static readonly PlanType[] _resellerDisallowedOrganizationTypes = [ private static readonly PlanType[] _resellerDisallowedOrganizationTypes = [
PlanType.Free, PlanType.Free,
PlanType.FamiliesAnnually, PlanType.FamiliesAnnually2025,
PlanType.FamiliesAnnually2019 PlanType.FamiliesAnnually2019,
PlanType.FamiliesAnnually
]; ];
private readonly IDataProtector _dataProtector; private readonly IDataProtector _dataProtector;
@@ -90,7 +91,7 @@ public class ProviderService : IProviderService
_providerClientOrganizationSignUpCommand = providerClientOrganizationSignUpCommand; _providerClientOrganizationSignUpCommand = providerClientOrganizationSignUpCommand;
} }
public async Task<Provider> CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TaxInfo taxInfo, TokenizedPaymentSource tokenizedPaymentSource = null) public async Task<Provider> CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TokenizedPaymentMethod paymentMethod, BillingAddress billingAddress)
{ {
var owner = await _userService.GetUserByIdAsync(ownerUserId); var owner = await _userService.GetUserByIdAsync(ownerUserId);
if (owner == null) if (owner == null)
@@ -115,21 +116,7 @@ public class ProviderService : IProviderService
throw new BadRequestException("Invalid owner."); throw new BadRequestException("Invalid owner.");
} }
if (taxInfo == null || string.IsNullOrEmpty(taxInfo.BillingAddressCountry) || string.IsNullOrEmpty(taxInfo.BillingAddressPostalCode)) var customer = await _providerBillingService.SetupCustomer(provider, paymentMethod, billingAddress);
{
throw new BadRequestException("Both address and postal code are required to set up your provider.");
}
if (tokenizedPaymentSource is not
{
Type: PaymentMethodType.BankAccount or PaymentMethodType.Card or PaymentMethodType.PayPal,
Token: not null and not ""
})
{
throw new BadRequestException("A payment method is required to set up your provider.");
}
var customer = await _providerBillingService.SetupCustomer(provider, taxInfo, tokenizedPaymentSource);
provider.GatewayCustomerId = customer.Id; provider.GatewayCustomerId = customer.Id;
var subscription = await _providerBillingService.SetupSubscription(provider); var subscription = await _providerBillingService.SetupSubscription(provider);
provider.GatewaySubscriptionId = subscription.Id; provider.GatewaySubscriptionId = subscription.Id;

View File

@@ -10,6 +10,7 @@ using Stripe.Tax;
namespace Bit.Commercial.Core.Billing.Providers.Queries; namespace Bit.Commercial.Core.Billing.Providers.Queries;
using static Bit.Core.Constants;
using static StripeConstants; using static StripeConstants;
using SuspensionWarning = ProviderWarnings.SuspensionWarning; using SuspensionWarning = ProviderWarnings.SuspensionWarning;
using TaxIdWarning = ProviderWarnings.TaxIdWarning; using TaxIdWarning = ProviderWarnings.TaxIdWarning;
@@ -61,6 +62,11 @@ public class GetProviderWarningsQuery(
Provider provider, Provider provider,
Customer customer) Customer customer)
{ {
if (customer.Address?.Country == CountryAbbreviations.UnitedStates)
{
return null;
}
if (!currentContext.ProviderProviderAdmin(provider.Id)) if (!currentContext.ProviderProviderAdmin(provider.Id))
{ {
return null; return null;
@@ -75,7 +81,7 @@ public class GetProviderWarningsQuery(
.SelectMany(registrations => registrations.Data); .SelectMany(registrations => registrations.Data);
// Find the matching registration for the customer // Find the matching registration for the customer
var registration = registrations.FirstOrDefault(registration => registration.Country == customer.Address.Country); 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 we're not registered in their country, we don't need a warning
if (registration == null) if (registration == null)

View File

@@ -3,6 +3,7 @@
using System.Globalization; using System.Globalization;
using Bit.Commercial.Core.Billing.Providers.Models; using Bit.Commercial.Core.Billing.Providers.Models;
using Bit.Core;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Enums.Provider;
@@ -13,6 +14,7 @@ using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Models; using Bit.Core.Billing.Models;
using Bit.Core.Billing.Payment.Models;
using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Providers.Entities; using Bit.Core.Billing.Providers.Entities;
using Bit.Core.Billing.Providers.Models; using Bit.Core.Billing.Providers.Models;
@@ -20,10 +22,8 @@ using Bit.Core.Billing.Providers.Repositories;
using Bit.Core.Billing.Providers.Services; using Bit.Core.Billing.Providers.Services;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Services;
using Bit.Core.Billing.Tax.Models; using Bit.Core.Billing.Tax.Models;
using Bit.Core.Billing.Tax.Services;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Models.Business;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Settings; using Bit.Core.Settings;
@@ -37,6 +37,9 @@ using Subscription = Stripe.Subscription;
namespace Bit.Commercial.Core.Billing.Providers.Services; namespace Bit.Commercial.Core.Billing.Providers.Services;
using static Constants;
using static StripeConstants;
public class ProviderBillingService( public class ProviderBillingService(
IBraintreeGateway braintreeGateway, IBraintreeGateway braintreeGateway,
IEventService eventService, IEventService eventService,
@@ -50,8 +53,7 @@ public class ProviderBillingService(
IProviderUserRepository providerUserRepository, IProviderUserRepository providerUserRepository,
ISetupIntentCache setupIntentCache, ISetupIntentCache setupIntentCache,
IStripeAdapter stripeAdapter, IStripeAdapter stripeAdapter,
ISubscriberService subscriberService, ISubscriberService subscriberService)
ITaxService taxService)
: IProviderBillingService : IProviderBillingService
{ {
public async Task AddExistingOrganization( public async Task AddExistingOrganization(
@@ -60,10 +62,7 @@ public class ProviderBillingService(
string key) string key)
{ {
await stripeAdapter.SubscriptionUpdateAsync(organization.GatewaySubscriptionId, await stripeAdapter.SubscriptionUpdateAsync(organization.GatewaySubscriptionId,
new SubscriptionUpdateOptions new SubscriptionUpdateOptions { CancelAtPeriodEnd = false });
{
CancelAtPeriodEnd = false
});
var subscription = var subscription =
await stripeAdapter.SubscriptionCancelAsync(organization.GatewaySubscriptionId, await stripeAdapter.SubscriptionCancelAsync(organization.GatewaySubscriptionId,
@@ -82,7 +81,7 @@ public class ProviderBillingService(
var wasTrialing = subscription.TrialEnd.HasValue && subscription.TrialEnd.Value > now; var wasTrialing = subscription.TrialEnd.HasValue && subscription.TrialEnd.Value > now;
if (!wasTrialing && subscription.LatestInvoice.Status == StripeConstants.InvoiceStatus.Draft) if (!wasTrialing && subscription.LatestInvoice.Status == InvoiceStatus.Draft)
{ {
await stripeAdapter.InvoiceFinalizeInvoiceAsync(subscription.LatestInvoiceId, await stripeAdapter.InvoiceFinalizeInvoiceAsync(subscription.LatestInvoiceId,
new InvoiceFinalizeOptions { AutoAdvance = true }); new InvoiceFinalizeOptions { AutoAdvance = true });
@@ -183,16 +182,8 @@ public class ProviderBillingService(
{ {
Items = Items =
[ [
new SubscriptionItemOptions new SubscriptionItemOptions { Price = newPriceId, Quantity = oldSubscriptionItem!.Quantity },
{ new SubscriptionItemOptions { Id = oldSubscriptionItem.Id, Deleted = true }
Price = newPriceId,
Quantity = oldSubscriptionItem!.Quantity
},
new SubscriptionItemOptions
{
Id = oldSubscriptionItem.Id,
Deleted = true
}
] ]
}; };
@@ -201,7 +192,8 @@ public class ProviderBillingService(
// Refactor later to ?ChangeClientPlanCommand? (ProviderPlanId, ProviderId, OrganizationId) // Refactor later to ?ChangeClientPlanCommand? (ProviderPlanId, ProviderId, OrganizationId)
// 1. Retrieve PlanType and PlanName for ProviderPlan // 1. Retrieve PlanType and PlanName for ProviderPlan
// 2. Assign PlanType & PlanName to Organization // 2. Assign PlanType & PlanName to Organization
var providerOrganizations = await providerOrganizationRepository.GetManyDetailsByProviderAsync(providerPlan.ProviderId); var providerOrganizations =
await providerOrganizationRepository.GetManyDetailsByProviderAsync(providerPlan.ProviderId);
var newPlan = await pricingClient.GetPlanOrThrow(newPlanType); var newPlan = await pricingClient.GetPlanOrThrow(newPlanType);
@@ -212,6 +204,7 @@ public class ProviderBillingService(
{ {
throw new ConflictException($"Organization '{providerOrganization.Id}' not found."); throw new ConflictException($"Organization '{providerOrganization.Id}' not found.");
} }
organization.PlanType = newPlanType; organization.PlanType = newPlanType;
organization.Plan = newPlan.Name; organization.Plan = newPlan.Name;
await organizationRepository.ReplaceAsync(organization); await organizationRepository.ReplaceAsync(organization);
@@ -227,15 +220,15 @@ public class ProviderBillingService(
if (!string.IsNullOrEmpty(organization.GatewayCustomerId)) if (!string.IsNullOrEmpty(organization.GatewayCustomerId))
{ {
logger.LogWarning("Client organization ({ID}) already has a populated {FieldName}", organization.Id, nameof(organization.GatewayCustomerId)); logger.LogWarning("Client organization ({ID}) already has a populated {FieldName}", organization.Id,
nameof(organization.GatewayCustomerId));
return; return;
} }
var providerCustomer = await subscriberService.GetCustomerOrThrow(provider, new CustomerGetOptions var providerCustomer =
{ await subscriberService.GetCustomerOrThrow(provider,
Expand = ["tax", "tax_ids"] new CustomerGetOptions { Expand = ["tax", "tax_ids"] });
});
var providerTaxId = providerCustomer.TaxIds.FirstOrDefault(); var providerTaxId = providerCustomer.TaxIds.FirstOrDefault();
@@ -268,23 +261,18 @@ public class ProviderBillingService(
} }
] ]
}, },
Metadata = new Dictionary<string, string> Metadata = new Dictionary<string, string> { { "region", globalSettings.BaseServiceUri.CloudRegion } },
{ TaxIdData = providerTaxId == null
{ "region", globalSettings.BaseServiceUri.CloudRegion } ? null
}, :
TaxIdData = providerTaxId == null ? null :
[ [
new CustomerTaxIdDataOptions new CustomerTaxIdDataOptions { Type = providerTaxId.Type, Value = providerTaxId.Value }
{
Type = providerTaxId.Type,
Value = providerTaxId.Value
}
] ]
}; };
if (providerCustomer.Address is not { Country: "US" }) if (providerCustomer.Address is not { Country: CountryAbbreviations.UnitedStates })
{ {
customerCreateOptions.TaxExempt = StripeConstants.TaxExempt.Reverse; customerCreateOptions.TaxExempt = TaxExempt.Reverse;
} }
var customer = await stripeAdapter.CustomerCreateAsync(customerCreateOptions); var customer = await stripeAdapter.CustomerCreateAsync(customerCreateOptions);
@@ -346,9 +334,9 @@ public class ProviderBillingService(
.Where(pair => pair.subscription is .Where(pair => pair.subscription is
{ {
Status: Status:
StripeConstants.SubscriptionStatus.Active or SubscriptionStatus.Active or
StripeConstants.SubscriptionStatus.Trialing or SubscriptionStatus.Trialing or
StripeConstants.SubscriptionStatus.PastDue SubscriptionStatus.PastDue
}).ToList(); }).ToList();
if (active.Count == 0) if (active.Count == 0)
@@ -473,36 +461,25 @@ public class ProviderBillingService(
// Below the limit to above the limit // Below the limit to above the limit
(currentlyAssignedSeatTotal <= seatMinimum && newlyAssignedSeatTotal > seatMinimum) || (currentlyAssignedSeatTotal <= seatMinimum && newlyAssignedSeatTotal > seatMinimum) ||
// Above the limit to further above the limit // Above the limit to further above the limit
(currentlyAssignedSeatTotal > seatMinimum && newlyAssignedSeatTotal > seatMinimum && newlyAssignedSeatTotal > currentlyAssignedSeatTotal); (currentlyAssignedSeatTotal > seatMinimum && newlyAssignedSeatTotal > seatMinimum &&
newlyAssignedSeatTotal > currentlyAssignedSeatTotal);
} }
public async Task<Customer> SetupCustomer( public async Task<Customer> SetupCustomer(
Provider provider, Provider provider,
TaxInfo taxInfo, TokenizedPaymentMethod paymentMethod,
TokenizedPaymentSource tokenizedPaymentSource) BillingAddress billingAddress)
{ {
ArgumentNullException.ThrowIfNull(tokenizedPaymentSource);
if (taxInfo is not
{
BillingAddressCountry: not null and not "",
BillingAddressPostalCode: not null and not ""
})
{
logger.LogError("Cannot create customer for provider ({ProviderID}) without both a country and postal code", provider.Id);
throw new BillingException();
}
var options = new CustomerCreateOptions var options = new CustomerCreateOptions
{ {
Address = new AddressOptions Address = new AddressOptions
{ {
Country = taxInfo.BillingAddressCountry, Country = billingAddress.Country,
PostalCode = taxInfo.BillingAddressPostalCode, PostalCode = billingAddress.PostalCode,
Line1 = taxInfo.BillingAddressLine1, Line1 = billingAddress.Line1,
Line2 = taxInfo.BillingAddressLine2, Line2 = billingAddress.Line2,
City = taxInfo.BillingAddressCity, City = billingAddress.City,
State = taxInfo.BillingAddressState State = billingAddress.State
}, },
Description = provider.DisplayBusinessName(), Description = provider.DisplayBusinessName(),
Email = provider.BillingEmail, Email = provider.BillingEmail,
@@ -519,93 +496,61 @@ public class ProviderBillingService(
} }
] ]
}, },
Metadata = new Dictionary<string, string> Metadata = new Dictionary<string, string> { { "region", globalSettings.BaseServiceUri.CloudRegion } },
{ TaxExempt = billingAddress.Country != CountryAbbreviations.UnitedStates ? TaxExempt.Reverse : TaxExempt.None
{ "region", globalSettings.BaseServiceUri.CloudRegion }
}
}; };
if (taxInfo.BillingAddressCountry is not "US") if (billingAddress.TaxId != null)
{ {
options.TaxExempt = StripeConstants.TaxExempt.Reverse;
}
if (!string.IsNullOrEmpty(taxInfo.TaxIdNumber))
{
var taxIdType = taxService.GetStripeTaxCode(
taxInfo.BillingAddressCountry,
taxInfo.TaxIdNumber);
if (taxIdType == null)
{
logger.LogWarning("Could not infer tax ID type in country '{Country}' with tax ID '{TaxID}'.",
taxInfo.BillingAddressCountry,
taxInfo.TaxIdNumber);
throw new BadRequestException("billingTaxIdTypeInferenceError");
}
options.TaxIdData = options.TaxIdData =
[ [
new CustomerTaxIdDataOptions { Type = taxIdType, Value = taxInfo.TaxIdNumber } new CustomerTaxIdDataOptions { Type = billingAddress.TaxId.Code, Value = billingAddress.TaxId.Value }
]; ];
if (taxIdType == StripeConstants.TaxIdType.SpanishNIF) if (billingAddress.TaxId.Code == TaxIdType.SpanishNIF)
{ {
options.TaxIdData.Add(new CustomerTaxIdDataOptions options.TaxIdData.Add(new CustomerTaxIdDataOptions
{ {
Type = StripeConstants.TaxIdType.EUVAT, Type = TaxIdType.EUVAT,
Value = $"ES{taxInfo.TaxIdNumber}" Value = $"ES{billingAddress.TaxId.Value}"
}); });
} }
} }
if (!string.IsNullOrEmpty(provider.DiscountId))
{
options.Coupon = provider.DiscountId;
}
var braintreeCustomerId = ""; var braintreeCustomerId = "";
if (tokenizedPaymentSource is not
{
Type: PaymentMethodType.BankAccount or PaymentMethodType.Card or PaymentMethodType.PayPal,
Token: not null and not ""
})
{
logger.LogError("Cannot create customer for provider ({ProviderID}) with invalid payment method", provider.Id);
throw new BillingException();
}
var (type, token) = tokenizedPaymentSource;
// ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault // ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault
switch (type) switch (paymentMethod.Type)
{ {
case PaymentMethodType.BankAccount: case TokenizablePaymentMethodType.BankAccount:
{ {
var setupIntent = var setupIntent =
(await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = token })) (await stripeAdapter.SetupIntentList(new SetupIntentListOptions
{
PaymentMethod = paymentMethod.Token
}))
.FirstOrDefault(); .FirstOrDefault();
if (setupIntent == null) if (setupIntent == null)
{ {
logger.LogError("Cannot create customer for provider ({ProviderID}) without a setup intent for their bank account", provider.Id); logger.LogError(
"Cannot create customer for provider ({ProviderID}) without a setup intent for their bank account",
provider.Id);
throw new BillingException(); throw new BillingException();
} }
await setupIntentCache.Set(provider.Id, setupIntent.Id); await setupIntentCache.Set(provider.Id, setupIntent.Id);
break; break;
} }
case PaymentMethodType.Card: case TokenizablePaymentMethodType.Card:
{ {
options.PaymentMethod = token; options.PaymentMethod = paymentMethod.Token;
options.InvoiceSettings.DefaultPaymentMethod = token; options.InvoiceSettings.DefaultPaymentMethod = paymentMethod.Token;
break; break;
} }
case PaymentMethodType.PayPal: case TokenizablePaymentMethodType.PayPal:
{ {
braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(provider, token); braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(provider, paymentMethod.Token);
options.Metadata[BraintreeCustomerIdKey] = braintreeCustomerId; options.Metadata[BraintreeCustomerIdKey] = braintreeCustomerId;
break; break;
} }
@@ -615,8 +560,7 @@ public class ProviderBillingService(
{ {
return await stripeAdapter.CustomerCreateAsync(options); return await stripeAdapter.CustomerCreateAsync(options);
} }
catch (StripeException stripeException) when (stripeException.StripeError?.Code == catch (StripeException stripeException) when (stripeException.StripeError?.Code == ErrorCodes.TaxIdInvalid)
StripeConstants.ErrorCodes.TaxIdInvalid)
{ {
await Revert(); await Revert();
throw new BadRequestException( throw new BadRequestException(
@@ -631,17 +575,17 @@ public class ProviderBillingService(
async Task Revert() async Task Revert()
{ {
// ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault // ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault
switch (tokenizedPaymentSource.Type) switch (paymentMethod.Type)
{ {
case PaymentMethodType.BankAccount: case TokenizablePaymentMethodType.BankAccount:
{ {
var setupIntentId = await setupIntentCache.Get(provider.Id); var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(provider.Id);
await stripeAdapter.SetupIntentCancel(setupIntentId, await stripeAdapter.SetupIntentCancel(setupIntentId,
new SetupIntentCancelOptions { CancellationReason = "abandoned" }); new SetupIntentCancelOptions { CancellationReason = "abandoned" });
await setupIntentCache.Remove(provider.Id); await setupIntentCache.RemoveSetupIntentForSubscriber(provider.Id);
break; break;
} }
case PaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId): case TokenizablePaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId):
{ {
await braintreeGateway.Customer.DeleteAsync(braintreeCustomerId); await braintreeGateway.Customer.DeleteAsync(braintreeCustomerId);
break; break;
@@ -660,9 +604,10 @@ public class ProviderBillingService(
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id); var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
if (providerPlans == null || providerPlans.Count == 0) if (providerPlans.Count == 0)
{ {
logger.LogError("Cannot start subscription for provider ({ProviderID}) that has no configured plans", provider.Id); logger.LogError("Cannot start subscription for provider ({ProviderID}) that has no configured plans",
provider.Id);
throw new BillingException(); throw new BillingException();
} }
@@ -675,7 +620,9 @@ public class ProviderBillingService(
if (!providerPlan.IsConfigured()) if (!providerPlan.IsConfigured())
{ {
logger.LogError("Cannot start subscription for provider ({ProviderID}) that has no configured {ProviderName} plan", provider.Id, plan.Name); logger.LogError(
"Cannot start subscription for provider ({ProviderID}) that has no configured {ProviderName} plan",
provider.Id, plan.Name);
throw new BillingException(); throw new BillingException();
} }
@@ -688,19 +635,17 @@ public class ProviderBillingService(
}); });
} }
var setupIntentId = await setupIntentCache.Get(provider.Id); var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(provider.Id);
var setupIntent = !string.IsNullOrEmpty(setupIntentId) var setupIntent = !string.IsNullOrEmpty(setupIntentId)
? await stripeAdapter.SetupIntentGet(setupIntentId, new SetupIntentGetOptions ? await stripeAdapter.SetupIntentGet(setupIntentId,
{ new SetupIntentGetOptions { Expand = ["payment_method"] })
Expand = ["payment_method"]
})
: null; : null;
var usePaymentMethod = var usePaymentMethod =
!string.IsNullOrEmpty(customer.InvoiceSettings?.DefaultPaymentMethodId) || !string.IsNullOrEmpty(customer.InvoiceSettings?.DefaultPaymentMethodId) ||
(customer.Metadata?.ContainsKey(BraintreeCustomerIdKey) == true) || customer.Metadata?.ContainsKey(BraintreeCustomerIdKey) == true ||
(setupIntent?.IsUnverifiedBankAccount() == true); setupIntent?.IsUnverifiedBankAccount() == true;
int? trialPeriodDays = provider.Type switch int? trialPeriodDays = provider.Type switch
{ {
@@ -711,30 +656,28 @@ public class ProviderBillingService(
var subscriptionCreateOptions = new SubscriptionCreateOptions var subscriptionCreateOptions = new SubscriptionCreateOptions
{ {
CollectionMethod = usePaymentMethod ? CollectionMethod =
StripeConstants.CollectionMethod.ChargeAutomatically : StripeConstants.CollectionMethod.SendInvoice, usePaymentMethod
? CollectionMethod.ChargeAutomatically
: CollectionMethod.SendInvoice,
Customer = customer.Id, Customer = customer.Id,
DaysUntilDue = usePaymentMethod ? null : 30, DaysUntilDue = usePaymentMethod ? null : 30,
Discounts = !string.IsNullOrEmpty(provider.DiscountId) ? [new SubscriptionDiscountOptions { Coupon = provider.DiscountId }] : null,
Items = subscriptionItemOptionsList, Items = subscriptionItemOptionsList,
Metadata = new Dictionary<string, string> Metadata = new Dictionary<string, string> { { "providerId", provider.Id.ToString() } },
{
{ "providerId", provider.Id.ToString() }
},
OffSession = true, OffSession = true,
ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations, ProrationBehavior = ProrationBehavior.CreateProrations,
TrialPeriodDays = trialPeriodDays TrialPeriodDays = trialPeriodDays,
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
}; };
subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true };
try try
{ {
var subscription = await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions); var subscription = await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
if (subscription is if (subscription is
{ {
Status: StripeConstants.SubscriptionStatus.Active or StripeConstants.SubscriptionStatus.Trialing Status: SubscriptionStatus.Active or SubscriptionStatus.Trialing
}) })
{ {
return subscription; return subscription;
@@ -748,9 +691,11 @@ public class ProviderBillingService(
throw new BillingException(); throw new BillingException();
} }
catch (StripeException stripeException) when (stripeException.StripeError?.Code == StripeConstants.ErrorCodes.CustomerTaxLocationInvalid) catch (StripeException stripeException) when (stripeException.StripeError?.Code ==
ErrorCodes.CustomerTaxLocationInvalid)
{ {
throw new BadRequestException("Your location wasn't recognized. Please ensure your country and postal code are valid."); throw new BadRequestException(
"Your location wasn't recognized. Please ensure your country and postal code are valid.");
} }
} }
@@ -764,7 +709,7 @@ public class ProviderBillingService(
subscriberService.UpdateTaxInformation(provider, taxInformation)); subscriberService.UpdateTaxInformation(provider, taxInformation));
await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId, await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId,
new SubscriptionUpdateOptions { CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically }); new SubscriptionUpdateOptions { CollectionMethod = CollectionMethod.ChargeAutomatically });
} }
public async Task UpdateSeatMinimums(UpdateProviderSeatMinimumsCommand command) public async Task UpdateSeatMinimums(UpdateProviderSeatMinimumsCommand command)
@@ -864,13 +809,9 @@ public class ProviderBillingService(
await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId, new SubscriptionUpdateOptions await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId, new SubscriptionUpdateOptions
{ {
Items = [ Items =
new SubscriptionItemOptions [
{ new SubscriptionItemOptions { Id = item.Id, Price = priceId, Quantity = newlySubscribedSeats }
Id = item.Id,
Price = priceId,
Quantity = newlySubscribedSeats
}
] ]
}); });
@@ -893,7 +834,8 @@ public class ProviderBillingService(
var plan = await pricingClient.GetPlanOrThrow(planType); var plan = await pricingClient.GetPlanOrThrow(planType);
return providerOrganizations return providerOrganizations
.Where(providerOrganization => providerOrganization.Plan == plan.Name && providerOrganization.Status == OrganizationStatusType.Managed) .Where(providerOrganization => providerOrganization.Plan == plan.Name &&
providerOrganization.Status == OrganizationStatusType.Managed)
.Sum(providerOrganization => providerOrganization.Seats ?? 0); .Sum(providerOrganization => providerOrganization.Seats ?? 0);
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,4 @@
// FIXME: Update this file to be null safe and then delete the line below using System.Security.Claims;
#nullable disable
using System.Security.Claims;
using Bit.Core; using Bit.Core;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums;
@@ -57,6 +54,7 @@ public class AccountController : Controller
private readonly IDataProtectorTokenFactory<SsoTokenable> _dataProtector; private readonly IDataProtectorTokenFactory<SsoTokenable> _dataProtector;
private readonly IOrganizationDomainRepository _organizationDomainRepository; private readonly IOrganizationDomainRepository _organizationDomainRepository;
private readonly IRegisterUserCommand _registerUserCommand; private readonly IRegisterUserCommand _registerUserCommand;
private readonly IFeatureService _featureService;
public AccountController( public AccountController(
IAuthenticationSchemeProvider schemeProvider, IAuthenticationSchemeProvider schemeProvider,
@@ -77,7 +75,8 @@ public class AccountController : Controller
Core.Services.IEventService eventService, Core.Services.IEventService eventService,
IDataProtectorTokenFactory<SsoTokenable> dataProtector, IDataProtectorTokenFactory<SsoTokenable> dataProtector,
IOrganizationDomainRepository organizationDomainRepository, IOrganizationDomainRepository organizationDomainRepository,
IRegisterUserCommand registerUserCommand) IRegisterUserCommand registerUserCommand,
IFeatureService featureService)
{ {
_schemeProvider = schemeProvider; _schemeProvider = schemeProvider;
_clientStore = clientStore; _clientStore = clientStore;
@@ -98,46 +97,43 @@ public class AccountController : Controller
_dataProtector = dataProtector; _dataProtector = dataProtector;
_organizationDomainRepository = organizationDomainRepository; _organizationDomainRepository = organizationDomainRepository;
_registerUserCommand = registerUserCommand; _registerUserCommand = registerUserCommand;
_featureService = featureService;
} }
[HttpGet] [HttpGet]
public async Task<IActionResult> PreValidate(string domainHint) public async Task<IActionResult> PreValidateAsync(string domainHint)
{ {
try try
{ {
// Validate domain_hint provided // Validate domain_hint provided
if (string.IsNullOrWhiteSpace(domainHint)) if (string.IsNullOrWhiteSpace(domainHint))
{ {
return InvalidJson("NoOrganizationIdentifierProvidedError"); _logger.LogError(new ArgumentException("domainHint is required."), "domainHint not specified.");
return InvalidJson("SsoInvalidIdentifierError");
} }
// Validate organization exists from domain_hint // Validate organization exists from domain_hint
var organization = await _organizationRepository.GetByIdentifierAsync(domainHint); var organization = await _organizationRepository.GetByIdentifierAsync(domainHint);
if (organization == null) if (organization is not { UseSso: true })
{ {
return InvalidJson("OrganizationNotFoundByIdentifierError"); _logger.LogError("Organization not configured to use SSO.");
} return InvalidJson("SsoInvalidIdentifierError");
if (!organization.UseSso)
{
return InvalidJson("SsoNotAllowedForOrganizationError");
} }
// Validate SsoConfig exists and is Enabled // Validate SsoConfig exists and is Enabled
var ssoConfig = await _ssoConfigRepository.GetByIdentifierAsync(domainHint); var ssoConfig = await _ssoConfigRepository.GetByIdentifierAsync(domainHint);
if (ssoConfig == null) if (ssoConfig is not { Enabled: true })
{ {
return InvalidJson("SsoConfigurationNotFoundForOrganizationError"); _logger.LogError("SsoConfig not enabled.");
} return InvalidJson("SsoInvalidIdentifierError");
if (!ssoConfig.Enabled)
{
return InvalidJson("SsoNotEnabledForOrganizationError");
} }
// Validate Authentication Scheme exists and is loaded (cache) // Validate Authentication Scheme exists and is loaded (cache)
var scheme = await _schemeProvider.GetSchemeAsync(organization.Id.ToString()); var scheme = await _schemeProvider.GetSchemeAsync(organization.Id.ToString());
if (scheme == null || !(scheme is IDynamicAuthenticationScheme dynamicScheme)) if (scheme is not IDynamicAuthenticationScheme dynamicScheme)
{ {
return InvalidJson("NoSchemeOrHandlerForSsoConfigurationFoundError"); _logger.LogError("Invalid authentication scheme for organization.");
return InvalidJson("SsoInvalidIdentifierError");
} }
// Run scheme validation // Run scheme validation
@@ -147,13 +143,8 @@ public class AccountController : Controller
} }
catch (Exception ex) catch (Exception ex)
{ {
var translatedException = _i18nService.GetLocalizedHtmlString(ex.Message); _logger.LogError(ex, "An error occurred while validating SSO dynamic scheme.");
var errorKey = "InvalidSchemeConfigurationError"; return InvalidJson("SsoInvalidIdentifierError");
if (!translatedException.ResourceNotFound)
{
errorKey = ex.Message;
}
return InvalidJson(errorKey, translatedException.ResourceNotFound ? ex : null);
} }
var tokenable = new SsoTokenable(organization, _globalSettings.Sso.SsoTokenLifetimeInSeconds); var tokenable = new SsoTokenable(organization, _globalSettings.Sso.SsoTokenLifetimeInSeconds);
@@ -163,15 +154,18 @@ public class AccountController : Controller
} }
catch (Exception ex) catch (Exception ex)
{ {
return InvalidJson("PreValidationError", ex); _logger.LogError(ex, "An error occurred during SSO prevalidation.");
return InvalidJson("SsoInvalidIdentifierError");
} }
} }
[HttpGet] [HttpGet]
public async Task<IActionResult> Login(string returnUrl) public async Task<IActionResult> LoginAsync(string returnUrl)
{ {
var context = await _interaction.GetAuthorizationContextAsync(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") || if (!context.Parameters.AllKeys.Contains("domain_hint") ||
string.IsNullOrWhiteSpace(context.Parameters["domain_hint"])) string.IsNullOrWhiteSpace(context.Parameters["domain_hint"]))
{ {
@@ -187,6 +181,7 @@ public class AccountController : Controller
var domainHint = context.Parameters["domain_hint"]; var domainHint = context.Parameters["domain_hint"];
var organization = await _organizationRepository.GetByIdentifierAsync(domainHint); var organization = await _organizationRepository.GetByIdentifierAsync(domainHint);
#nullable restore
if (organization == null) if (organization == null)
{ {
@@ -243,36 +238,100 @@ public class AccountController : Controller
[HttpGet] [HttpGet]
public async Task<IActionResult> ExternalCallback() 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 // Read external identity from the temporary cookie
var result = await HttpContext.AuthenticateAsync( var result = await HttpContext.AuthenticateAsync(
AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme); AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme);
if (preventOrgUserLoginIfStatusInvalid)
{
if (!result.Succeeded)
{
throw new Exception(_i18nService.T("ExternalAuthenticationError"));
}
}
else
{
if (result?.Succeeded != true) if (result?.Succeeded != true)
{ {
throw new Exception(_i18nService.T("ExternalAuthenticationError")); throw new Exception(_i18nService.T("ExternalAuthenticationError"));
} }
}
// Debugging
var externalClaims = result.Principal.Claims.Select(c => $"{c.Type}: {c.Value}");
_logger.LogDebug("External claims: {@claims}", externalClaims);
// See if the user has logged in with this SSO provider before and has already been provisioned. // 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. // This is signified by the user existing in the User table and the SSOUser table for the SSO provider they're using.
var (user, provider, providerUserId, claims, ssoConfigData) = await FindUserFromExternalProviderAsync(result); var (possibleSsoLinkedUser, provider, providerUserId, claims, ssoConfigData) = await FindUserFromExternalProviderAsync(result);
// 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. // The user has not authenticated with this SSO provider before.
// They could have an existing Bitwarden account in the User table though. // They could have an existing Bitwarden account in the User table though.
if (user == null) if (possibleSsoLinkedUser == 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. // 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") ? var userIdentifier = result.Properties.Items.Keys.Contains("user_identifier")
result.Properties.Items["user_identifier"] : null; ? result.Properties.Items["user_identifier"]
user = await AutoProvisionUserAsync(provider, providerUserId, claims, userIdentifier, ssoConfigData); : null;
var (resolvedUser, foundOrganization, foundOrCreatedOrgUser) =
await CreateUserAndOrgUserConditionallyAsync(
provider,
providerUserId,
claims,
userIdentifier,
ssoConfigData);
#nullable restore
possibleSsoLinkedUser = resolvedUser;
if (preventOrgUserLoginIfStatusInvalid)
{
organization = foundOrganization;
orgUser = foundOrCreatedOrgUser;
}
} }
if (preventOrgUserLoginIfStatusInvalid)
{
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.
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(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 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. // Either way, we have associated the SSO login with a Bitwarden user.
// We will now sign the Bitwarden user in. // We will now sign the Bitwarden user in.
if (user != null) if (possibleSsoLinkedUser != null)
{ {
// This allows us to collect any additional claims or properties // This allows us to collect any additional claims or properties
// for the specific protocols used and store them in the local auth cookie. // for the specific protocols used and store them in the local auth cookie.
@@ -286,19 +345,24 @@ public class AccountController : Controller
ProcessLoginCallback(result, additionalLocalClaims, localSignInProps); ProcessLoginCallback(result, additionalLocalClaims, localSignInProps);
// Issue authentication cookie for user // Issue authentication cookie for user
await HttpContext.SignInAsync(new IdentityServerUser(user.Id.ToString()) await HttpContext.SignInAsync(
new IdentityServerUser(possibleSsoLinkedUser.Id.ToString())
{ {
DisplayName = user.Email, DisplayName = possibleSsoLinkedUser.Email,
IdentityProvider = provider, IdentityProvider = provider,
AdditionalClaims = additionalLocalClaims.ToArray() AdditionalClaims = additionalLocalClaims.ToArray()
}, localSignInProps); }, localSignInProps);
} }
}
// Delete temporary cookie used during external authentication // Delete temporary cookie used during external authentication
await HttpContext.SignOutAsync(AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme); await HttpContext.SignOutAsync(AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme);
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
// Retrieve return URL // Retrieve return URL
var returnUrl = result.Properties.Items["return_url"] ?? "~/"; var returnUrl = result.Properties.Items["return_url"] ?? "~/";
#nullable restore
// Check if external login is in the context of an OIDC request // Check if external login is in the context of an OIDC request
var context = await _interaction.GetAuthorizationContextAsync(returnUrl); var context = await _interaction.GetAuthorizationContextAsync(returnUrl);
@@ -317,8 +381,10 @@ public class AccountController : Controller
return Redirect(returnUrl); return Redirect(returnUrl);
} }
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
[HttpGet] [HttpGet]
public async Task<IActionResult> Logout(string logoutId) public async Task<IActionResult> LogoutAsync(string logoutId)
{ {
// Build a model so the logged out page knows what to display // Build a model so the logged out page knows what to display
var (updatedLogoutId, redirectUri, externalAuthenticationScheme) = await GetLoggedOutDataAsync(logoutId); var (updatedLogoutId, redirectUri, externalAuthenticationScheme) = await GetLoggedOutDataAsync(logoutId);
@@ -341,6 +407,7 @@ public class AccountController : Controller
// This triggers a redirect to the external provider for sign-out // This triggers a redirect to the external provider for sign-out
return SignOut(new AuthenticationProperties { RedirectUri = url }, externalAuthenticationScheme); return SignOut(new AuthenticationProperties { RedirectUri = url }, externalAuthenticationScheme);
} }
if (redirectUri != null) if (redirectUri != null)
{ {
return View("Redirect", new RedirectViewModel { RedirectUrl = redirectUri }); return View("Redirect", new RedirectViewModel { RedirectUrl = redirectUri });
@@ -350,14 +417,22 @@ public class AccountController : Controller
return Redirect("~/"); return Redirect("~/");
} }
} }
#nullable restore
/// <summary> /// <summary>
/// Attempts to map the external identity to a Bitwarden user, through the SsoUser table, which holds the `externalId`. /// 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. /// 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> /// </summary>
private async Task<(User user, string provider, string providerUserId, IEnumerable<Claim> claims, SsoConfigurationData config)> private async Task<(
FindUserFromExternalProviderAsync(AuthenticateResult result) User? possibleSsoUser,
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 provider = result.Properties.Items["scheme"];
var orgId = new Guid(provider); var orgId = new Guid(provider);
var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(orgId); var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(orgId);
@@ -383,7 +458,8 @@ public class AccountController : Controller
// for the user identifier. // for the user identifier.
static bool nameIdIsNotTransient(Claim c) => c.Type == ClaimTypes.NameIdentifier static bool nameIdIsNotTransient(Claim c) => c.Type == ClaimTypes.NameIdentifier
&& (c.Properties == null && (c.Properties == null
|| !c.Properties.TryGetValue(SamlPropertyKeys.ClaimFormat, out var claimFormat) || !c.Properties.TryGetValue(SamlPropertyKeys.ClaimFormat,
out var claimFormat)
|| claimFormat != SamlNameIdFormats.Transient); || claimFormat != SamlNameIdFormats.Transient);
// Try to determine the unique id of the external user (issued by the provider) // Try to determine the unique id of the external user (issued by the provider)
@@ -399,6 +475,7 @@ public class AccountController : Controller
externalUser.FindFirst("upn") ?? externalUser.FindFirst("upn") ??
externalUser.FindFirst("eppn") ?? externalUser.FindFirst("eppn") ??
throw new Exception(_i18nService.T("UnknownUserId")); 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 // 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(); var claims = externalUser.Claims.ToList();
@@ -407,13 +484,15 @@ public class AccountController : Controller
// find external user // find external user
var providerUserId = userIdClaim.Value; var providerUserId = userIdClaim.Value;
var user = await _userRepository.GetBySsoUserAsync(providerUserId, orgId); var possibleSsoUser = await _userRepository.GetBySsoUserAsync(providerUserId, orgId);
return (user, provider, providerUserId, claims, ssoConfigData); return (possibleSsoUser, provider, providerUserId, claims, ssoConfigData);
} }
/// <summary> /// <summary>
/// Provision an SSO-linked Bitwarden user. /// 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: /// This handles three different scenarios:
/// 1. Creating an SsoUser link for an existing User and OrganizationUser /// 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. /// - User is a member of the organization, but hasn't authenticated with the org's SSO provider before.
@@ -426,77 +505,100 @@ public class AccountController : Controller
/// <param name="providerUserId">The external identity provider's user identifier.</param> /// <param name="providerUserId">The external identity provider's user identifier.</param>
/// <param name="claims">The claims from the external IdP.</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="userIdentifier">The user identifier used for manual SSO linking.</param>
/// <param name="config">The SSO configuration for the organization.</param> /// <param name="ssoConfigData">The SSO configuration for the organization.</param>
/// <returns>The User to sign in.</returns> /// <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> /// <exception cref="Exception">An exception if the user cannot be provisioned as requested.</exception>
private async Task<User> AutoProvisionUserAsync(string provider, string providerUserId, private async Task<(User resolvedUser, Organization foundOrganization, OrganizationUser foundOrgUser)> CreateUserAndOrgUserConditionallyAsync(
IEnumerable<Claim> claims, string userIdentifier, SsoConfigurationData config) string provider,
string providerUserId,
IEnumerable<Claim> claims,
string userIdentifier,
SsoConfigurationData ssoConfigData
)
{ {
var name = GetName(claims, config.GetAdditionalNameClaimTypes()); // Try to get the email from the claims as we don't know if we have a user record yet.
var email = GetEmailAddress(claims, config.GetAdditionalEmailClaimTypes()); var name = GetName(claims, ssoConfigData.GetAdditionalNameClaimTypes());
if (string.IsNullOrWhiteSpace(email) && providerUserId.Contains("@")) var email = TryGetEmailAddress(claims, ssoConfigData, providerUserId);
{
email = providerUserId;
}
if (!Guid.TryParse(provider, out var orgId)) User? possibleExistingUser;
{
// 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(userIdentifier))
{ {
if (string.IsNullOrWhiteSpace(email)) if (string.IsNullOrWhiteSpace(email))
{ {
throw new Exception(_i18nService.T("CannotFindEmailClaim")); throw new Exception(_i18nService.T("CannotFindEmailClaim"));
} }
existingUser = await _userRepository.GetByEmailAsync(email);
possibleExistingUser = await _userRepository.GetByEmailAsync(email);
} }
else else
{ {
existingUser = await GetUserFromManualLinkingData(userIdentifier); possibleExistingUser = await GetUserFromManualLinkingDataAsync(userIdentifier);
} }
// Try to find the OrganizationUser if it exists. // Find the org (we error if we can't find an org because no org is not valid)
var (organization, orgUser) = await FindOrganizationUser(existingUser, email, orgId); 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 // Scenario 1: We've found the user in the User table
//---------------------------------------------------- //----------------------------------------------------
if (existingUser != null) if (possibleExistingUser != null)
{ {
if (existingUser.UsesKeyConnector && User guaranteedExistingUser = possibleExistingUser;
(orgUser == null || orgUser.Status == OrganizationUserStatusType.Invited))
if (guaranteedExistingUser.UsesKeyConnector &&
(possibleOrgUser == null || possibleOrgUser.Status == OrganizationUserStatusType.Invited))
{ {
throw new Exception(_i18nService.T("UserAlreadyExistsKeyConnector")); throw new Exception(_i18nService.T("UserAlreadyExistsKeyConnector"));
} }
// If the user already exists in Bitwarden, we require that the user already be in the org, OrganizationUser guaranteedOrgUser = possibleOrgUser ?? throw new Exception(_i18nService.T("UserAlreadyExistsInviteProcess"));
// and that they are either Accepted or Confirmed.
if (orgUser == null) /*
* ----------------------------------------------------
* 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)
{ {
// Org User is not created - no invite has been sent // Org User is invited must accept via email first
throw new Exception(_i18nService.T("UserAlreadyExistsInviteProcess")); throw new Exception(
_i18nService.T("AcceptInviteBeforeUsingSSO", organization.DisplayName()));
} }
EnsureOrgUserStatusAllowed(orgUser.Status, organization.DisplayName(), // If the user already exists in Bitwarden, we require that the user already be in the org,
allowedStatuses: [OrganizationUserStatusType.Accepted, OrganizationUserStatusType.Confirmed]); // and that they are either Accepted or Confirmed.
EnforceAllowedOrgUserStatus(
guaranteedOrgUser.Status,
allowedStatuses: [
OrganizationUserStatusType.Accepted,
OrganizationUserStatusType.Confirmed
],
organization.DisplayName());
// Since we're in the auto-provisioning logic, this means that the user exists, but they have not // 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). // 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 // We've verified that the user is Accepted or Confnirmed, so we can create an SsoUser link and proceed
// with authentication. // with authentication.
await CreateSsoUserRecord(providerUserId, existingUser.Id, orgId, orgUser); await CreateSsoUserRecordAsync(providerUserId, guaranteedExistingUser.Id, organization.Id, guaranteedOrgUser);
return existingUser;
return (guaranteedExistingUser, organization, guaranteedOrgUser);
} }
// Before any user creation - if Org User doesn't exist at this point - make sure there are enough seats to add one // Before any user creation - if Org User doesn't exist at this point - make sure there are enough seats to add one
if (orgUser == null && organization.Seats.HasValue) if (possibleOrgUser == null && organization.Seats.HasValue)
{ {
var occupiedSeats = await _organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); var occupiedSeats =
await _organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
var initialSeatCount = organization.Seats.Value; var initialSeatCount = organization.Seats.Value;
var availableSeats = initialSeatCount - occupiedSeats.Total; var availableSeats = initialSeatCount - occupiedSeats.Total;
if (availableSeats < 1) if (availableSeats < 1)
@@ -514,8 +616,10 @@ public class AccountController : Controller
{ {
if (organization.Seats.Value != initialSeatCount) if (organization.Seats.Value != initialSeatCount)
{ {
await _organizationService.AdjustSeatsAsync(orgId, initialSeatCount - organization.Seats.Value); await _organizationService.AdjustSeatsAsync(organization.Id,
initialSeatCount - organization.Seats.Value);
} }
_logger.LogInformation(e, "SSO auto provisioning failed"); _logger.LogInformation(e, "SSO auto provisioning failed");
throw new Exception(_i18nService.T("NoSeatsAvailable", organization.DisplayName())); throw new Exception(_i18nService.T("NoSeatsAvailable", organization.DisplayName()));
} }
@@ -523,40 +627,62 @@ public class AccountController : Controller
} }
// If the email domain is verified, we can mark the email as verified // 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 emailVerified = false;
var emailDomain = CoreHelpers.GetEmailDomain(email); var emailDomain = CoreHelpers.GetEmailDomain(email);
if (!string.IsNullOrWhiteSpace(emailDomain)) if (!string.IsNullOrWhiteSpace(emailDomain))
{ {
var organizationDomain = await _organizationDomainRepository.GetDomainByOrgIdAndDomainNameAsync(orgId, emailDomain); var organizationDomain =
await _organizationDomainRepository.GetDomainByOrgIdAndDomainNameAsync(organization.Id, emailDomain);
emailVerified = organizationDomain?.VerifiedDate.HasValue ?? false; emailVerified = organizationDomain?.VerifiedDate.HasValue ?? false;
} }
//-------------------------------------------------- //--------------------------------------------------
// Scenarios 2 and 3: We need to register a new user // Scenarios 2 and 3: We need to register a new user
//-------------------------------------------------- //--------------------------------------------------
var user = new User var newUser = new User
{ {
Name = name, Name = name,
Email = email, Email = email,
EmailVerified = emailVerified, EmailVerified = emailVerified,
ApiKey = CoreHelpers.SecureRandomString(30) ApiKey = CoreHelpers.SecureRandomString(30)
}; };
await _registerUserCommand.RegisterUser(user);
/*
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);
}
// If the organization has 2fa policy enabled, make sure to default jit user 2fa to email // If the organization has 2fa policy enabled, make sure to default jit user 2fa to email
var twoFactorPolicy = var twoFactorPolicy =
await _policyRepository.GetByOrganizationIdTypeAsync(orgId, PolicyType.TwoFactorAuthentication); await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.TwoFactorAuthentication);
if (twoFactorPolicy != null && twoFactorPolicy.Enabled) if (twoFactorPolicy != null && twoFactorPolicy.Enabled)
{ {
user.SetTwoFactorProviders(new Dictionary<TwoFactorProviderType, TwoFactorProvider> newUser.SetTwoFactorProviders(new Dictionary<TwoFactorProviderType, TwoFactorProvider>
{ {
[TwoFactorProviderType.Email] = new TwoFactorProvider [TwoFactorProviderType.Email] = new TwoFactorProvider
{ {
MetaData = new Dictionary<string, object> { ["Email"] = user.Email.ToLowerInvariant() }, MetaData = new Dictionary<string, object> { ["Email"] = newUser.Email.ToLowerInvariant() },
Enabled = true Enabled = true
} }
}); });
await _userService.UpdateTwoFactorProviderAsync(user, TwoFactorProviderType.Email); await _userService.UpdateTwoFactorProviderAsync(newUser, TwoFactorProviderType.Email);
} }
//----------------------------------------------------------------- //-----------------------------------------------------------------
@@ -564,17 +690,18 @@ public class AccountController : Controller
// This means that an invitation was not sent for this user and we // This means that an invitation was not sent for this user and we
// need to establish their invited status now. // need to establish their invited status now.
//----------------------------------------------------------------- //-----------------------------------------------------------------
if (orgUser == null) if (possibleOrgUser == null)
{ {
orgUser = new OrganizationUser possibleOrgUser = new OrganizationUser
{ {
OrganizationId = orgId, OrganizationId = organization.Id,
UserId = user.Id, UserId = newUser.Id,
Type = OrganizationUserType.User, Type = OrganizationUserType.User,
Status = OrganizationUserStatusType.Invited Status = OrganizationUserStatusType.Invited
}; };
await _organizationUserRepository.CreateAsync(orgUser); await _organizationUserRepository.CreateAsync(possibleOrgUser);
} }
//----------------------------------------------------------------- //-----------------------------------------------------------------
// Scenario 3: There is already an existing OrganizationUser // Scenario 3: There is already an existing OrganizationUser
// That was established through an invitation. We just need to // That was established through an invitation. We just need to
@@ -582,24 +709,68 @@ public class AccountController : Controller
//----------------------------------------------------------------- //-----------------------------------------------------------------
else else
{ {
orgUser.UserId = user.Id; possibleOrgUser.UserId = newUser.Id;
await _organizationUserRepository.ReplaceAsync(orgUser); await _organizationUserRepository.ReplaceAsync(possibleOrgUser);
} }
// Create the SsoUser record to link the user to the SSO provider. // Create the SsoUser record to link the user to the SSO provider.
await CreateSsoUserRecord(providerUserId, user.Id, orgId, orgUser); await CreateSsoUserRecordAsync(providerUserId, newUser.Id, organization.Id, possibleOrgUser);
return user; return (newUser, organization, possibleOrgUser);
} }
private async Task<User> GetUserFromManualLinkingData(string userIdentifier) /// <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)
{ {
User user = null; // 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());
}
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(","); var split = userIdentifier.Split(",");
if (split.Length < 2) if (split.Length < 2)
{ {
throw new Exception(_i18nService.T("InvalidUserIdentifier")); throw new Exception(_i18nService.T("InvalidUserIdentifier"));
} }
var userId = split[0]; var userId = split[0];
var token = split[1]; var token = split[1];
@@ -619,64 +790,94 @@ public class AccountController : Controller
throw new Exception(_i18nService.T("UserIdAndTokenMismatch")); throw new Exception(_i18nService.T("UserIdAndTokenMismatch"));
} }
} }
return user; return user;
} }
private async Task<(Organization, OrganizationUser)> FindOrganizationUser(User existingUser, string email, Guid orgId) /// <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)
{ {
OrganizationUser orgUser = null; if (!Guid.TryParse(provider, out var organizationId))
var organization = await _organizationRepository.GetByIdAsync(orgId); {
// 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) if (organization == null)
{ {
throw new Exception(_i18nService.T("CouldNotFindOrganization", orgId)); 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. // Try to find OrgUser via existing User Id.
// This covers any OrganizationUser state after they have accepted an invite. // This covers any OrganizationUser state after they have accepted an invite.
if (existingUser != null) if (user != null)
{ {
var orgUsersByUserId = await _organizationUserRepository.GetManyByUserAsync(existingUser.Id); var orgUsersByUserId = await _organizationUserRepository.GetManyByUserAsync(user.Id);
orgUser = orgUsersByUserId.SingleOrDefault(u => u.OrganizationId == orgId); orgUser = orgUsersByUserId.SingleOrDefault(u => u.OrganizationId == organizationId);
} }
// If no Org User found by Existing User Id - search all the organization's users via email. // 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. // This covers users who are Invited but haven't accepted their invite yet.
orgUser ??= await _organizationUserRepository.GetByOrganizationEmailAsync(orgId, email); if (email != null)
{
return (organization, orgUser); orgUser ??= await _organizationUserRepository.GetByOrganizationEmailAsync(organizationId, email);
} }
private void EnsureOrgUserStatusAllowed( return orgUser;
OrganizationUserStatusType status, }
string organizationDisplayName,
params OrganizationUserStatusType[] allowedStatuses) private void EnforceAllowedOrgUserStatus(
OrganizationUserStatusType statusToCheckAgainst,
OrganizationUserStatusType[] allowedStatuses,
string organizationDisplayNameForLogging)
{ {
// if this status is one of the allowed ones, just return // if this status is one of the allowed ones, just return
if (allowedStatuses.Contains(status)) if (allowedStatuses.Contains(statusToCheckAgainst))
{ {
return; return;
} }
// otherwise throw the appropriate exception // otherwise throw the appropriate exception
switch (status) switch (statusToCheckAgainst)
{ {
case OrganizationUserStatusType.Invited:
// Org User is invited must accept via email first
throw new Exception(
_i18nService.T("AcceptInviteBeforeUsingSSO", organizationDisplayName));
case OrganizationUserStatusType.Revoked: case OrganizationUserStatusType.Revoked:
// Revoked users may not be (auto)provisioned // Revoked users may not be (auto)provisioned
throw new Exception( throw new Exception(
_i18nService.T("OrganizationUserAccessRevoked", organizationDisplayName)); _i18nService.T("OrganizationUserAccessRevoked", organizationDisplayNameForLogging));
default: default:
// anything else is “unknown” // anything else is “unknown”
throw new Exception( throw new Exception(
_i18nService.T("OrganizationUserUnknownStatus", organizationDisplayName)); _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; Response.StatusCode = ex == null ? 400 : 500;
return Json(new ErrorResponseModel(_i18nService.T(errorMessageKey)) return Json(new ErrorResponseModel(_i18nService.T(errorMessageKey))
@@ -687,7 +888,7 @@ public class AccountController : Controller
}); });
} }
private string GetEmailAddress(IEnumerable<Claim> claims, IEnumerable<string> additionalClaimTypes) private string? TryGetEmailAddressFromClaims(IEnumerable<Claim> claims, IEnumerable<string> additionalClaimTypes)
{ {
var filteredClaims = claims.Where(c => !string.IsNullOrWhiteSpace(c.Value) && c.Value.Contains("@")); var filteredClaims = claims.Where(c => !string.IsNullOrWhiteSpace(c.Value) && c.Value.Contains("@"));
@@ -709,6 +910,8 @@ public class AccountController : Controller
return null; 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) private string GetName(IEnumerable<Claim> claims, IEnumerable<string> additionalClaimTypes)
{ {
var filteredClaims = claims.Where(c => !string.IsNullOrWhiteSpace(c.Value)); var filteredClaims = claims.Where(c => !string.IsNullOrWhiteSpace(c.Value));
@@ -732,8 +935,10 @@ public class AccountController : Controller
return null; return null;
} }
#nullable restore
private async Task CreateSsoUserRecord(string providerUserId, Guid userId, Guid orgId, OrganizationUser orgUser) private async Task CreateSsoUserRecordAsync(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 // Delete existing SsoUser (if any) - avoids error if providerId has changed and the sso link is stale
var existingSsoUser = await _ssoUserRepository.GetByUserIdOrganizationIdAsync(orgId, userId); var existingSsoUser = await _ssoUserRepository.GetByUserIdOrganizationIdAsync(orgId, userId);
@@ -748,15 +953,12 @@ public class AccountController : Controller
await _eventService.LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_FirstSsoLogin); await _eventService.LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_FirstSsoLogin);
} }
var ssoUser = new SsoUser var ssoUser = new SsoUser { ExternalId = providerUserId, UserId = userId, OrganizationId = orgId, };
{
ExternalId = providerUserId,
UserId = userId,
OrganizationId = orgId,
};
await _ssoUserRepository.CreateAsync(ssoUser); 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, private void ProcessLoginCallback(AuthenticateResult externalResult,
List<Claim> localClaims, AuthenticationProperties localSignInProps) List<Claim> localClaims, AuthenticationProperties localSignInProps)
{ {
@@ -777,18 +979,6 @@ 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) private async Task<(string, string, string)> GetLoggedOutDataAsync(string logoutId)
{ {
// Get context information (client name, post logout redirect URI and iframe for federated signout) // Get context information (client name, post logout redirect URI and iframe for federated signout)
@@ -819,6 +1009,27 @@ public class AccountController : Controller
return (logoutId, logout?.PostLogoutRedirectUri, externalAuthenticationScheme); 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) public bool IsNativeClient(DIM.AuthorizationRequest context)
{ {

View File

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

View File

@@ -157,6 +157,6 @@ public class Startup
app.UseEndpoints(endpoints => endpoints.MapDefaultControllerRoute()); app.UseEndpoints(endpoints => endpoints.MapDefaultControllerRoute());
// Log startup // Log startup
logger.LogInformation(Constants.BypassFiltersEventId, globalSettings.ProjectName + " started."); logger.LogInformation(Constants.BypassFiltersEventId, "{Project} started.", globalSettings.ProjectName);
} }
} }

View File

@@ -4,6 +4,7 @@
using System.Security.Cryptography.X509Certificates; using System.Security.Cryptography.X509Certificates;
using Bit.Core.Auth.Entities; using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Enums; using Bit.Core.Auth.Enums;
using Bit.Core.Auth.IdentityServer;
using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Models.Data;
using Bit.Core.Auth.Repositories; using Bit.Core.Auth.Repositories;
using Bit.Core.Settings; using Bit.Core.Settings;
@@ -416,7 +417,7 @@ public class DynamicAuthenticationSchemeProvider : AuthenticationSchemeProvider
SPOptions = spOptions, SPOptions = spOptions,
SignInScheme = AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme, SignInScheme = AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme,
SignOutScheme = IdentityServerConstants.DefaultCookieAuthenticationScheme, SignOutScheme = IdentityServerConstants.DefaultCookieAuthenticationScheme,
CookieManager = new IdentityServer.DistributedCacheCookieManager(), CookieManager = new DistributedCacheCookieManager(),
}; };
options.IdentityProviders.Add(idp); options.IdentityProviders.Add(idp);

View File

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

View File

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

View File

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

View File

@@ -13,7 +13,7 @@ using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Utilities; using Bit.Core.Test.Billing.Mocks;
using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes; using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute; using NSubstitute;
@@ -156,16 +156,18 @@ public class RemoveOrganizationFromProviderCommandTests
"b@example.com" "b@example.com"
]); ]);
sutProvider.GetDependency<IStripeAdapter>().SubscriptionGetAsync(organization.GatewaySubscriptionId) sutProvider.GetDependency<IStripeAdapter>().SubscriptionGetAsync(organization.GatewaySubscriptionId, Arg.Is<SubscriptionGetOptions>(
.Returns(GetSubscription(organization.GatewaySubscriptionId)); options => options.Expand.Contains("customer")))
.Returns(GetSubscription(organization.GatewaySubscriptionId, organization.GatewayCustomerId));
await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization); await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization);
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>(); var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
await stripeAdapter.Received(1).CustomerUpdateAsync(organization.GatewayCustomerId, await stripeAdapter.Received(1).CustomerUpdateAsync(organization.GatewayCustomerId,
Arg.Is<CustomerUpdateOptions>(options => Arg.Is<CustomerUpdateOptions>(options => options.Email == "a@example.com"));
options.Coupon == string.Empty && options.Email == "a@example.com"));
await stripeAdapter.Received(1).CustomerDeleteDiscountAsync(organization.GatewayCustomerId);
await stripeAdapter.Received(1).SubscriptionUpdateAsync(organization.GatewaySubscriptionId, await stripeAdapter.Received(1).SubscriptionUpdateAsync(organization.GatewaySubscriptionId,
Arg.Is<SubscriptionUpdateOptions>(options => Arg.Is<SubscriptionUpdateOptions>(options =>
@@ -205,7 +207,7 @@ public class RemoveOrganizationFromProviderCommandTests
organization.PlanType = PlanType.TeamsMonthly; organization.PlanType = PlanType.TeamsMonthly;
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); var teamsMonthlyPlan = MockPlans.Get(PlanType.TeamsMonthly);
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(PlanType.TeamsMonthly).Returns(teamsMonthlyPlan); sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(PlanType.TeamsMonthly).Returns(teamsMonthlyPlan);
@@ -294,7 +296,7 @@ public class RemoveOrganizationFromProviderCommandTests
organization.PlanType = PlanType.TeamsMonthly; organization.PlanType = PlanType.TeamsMonthly;
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); var teamsMonthlyPlan = MockPlans.Get(PlanType.TeamsMonthly);
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(PlanType.TeamsMonthly).Returns(teamsMonthlyPlan); sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(PlanType.TeamsMonthly).Returns(teamsMonthlyPlan);
@@ -368,10 +370,21 @@ public class RemoveOrganizationFromProviderCommandTests
Arg.Is<IEnumerable<string>>(emails => emails.FirstOrDefault() == "a@example.com")); Arg.Is<IEnumerable<string>>(emails => emails.FirstOrDefault() == "a@example.com"));
} }
private static Subscription GetSubscription(string subscriptionId) => private static Subscription GetSubscription(string subscriptionId, string customerId) =>
new() new()
{ {
Id = subscriptionId, Id = subscriptionId,
CustomerId = customerId,
Customer = new Customer
{
Discount = new Discount
{
Coupon = new Coupon
{
Id = "coupon-id"
}
}
},
Status = StripeConstants.SubscriptionStatus.Active, Status = StripeConstants.SubscriptionStatus.Active,
Items = new StripeList<SubscriptionItem> Items = new StripeList<SubscriptionItem>
{ {
@@ -403,7 +416,7 @@ public class RemoveOrganizationFromProviderCommandTests
organization.PlanType = PlanType.TeamsMonthly; organization.PlanType = PlanType.TeamsMonthly;
organization.Enabled = false; // Start with a disabled organization organization.Enabled = false; // Start with a disabled organization
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); var teamsMonthlyPlan = MockPlans.Get(PlanType.TeamsMonthly);
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(PlanType.TeamsMonthly).Returns(teamsMonthlyPlan); sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(PlanType.TeamsMonthly).Returns(teamsMonthlyPlan);

View File

@@ -9,7 +9,7 @@ using Bit.Core.AdminConsole.Models.Data.Provider;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Models; using Bit.Core.Billing.Payment.Models;
using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Providers.Services; using Bit.Core.Billing.Providers.Services;
using Bit.Core.Context; using Bit.Core.Context;
@@ -20,6 +20,7 @@ using Bit.Core.Models.Business;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Test.AutoFixture.OrganizationFixtures; using Bit.Core.Test.AutoFixture.OrganizationFixtures;
using Bit.Core.Test.Billing.Mocks;
using Bit.Core.Tokens; using Bit.Core.Tokens;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture;
@@ -41,7 +42,7 @@ public class ProviderServiceTests
public async Task CompleteSetupAsync_UserIdIsInvalid_Throws(SutProvider<ProviderService> sutProvider) public async Task CompleteSetupAsync_UserIdIsInvalid_Throws(SutProvider<ProviderService> sutProvider)
{ {
var exception = await Assert.ThrowsAsync<BadRequestException>( var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.CompleteSetupAsync(default, default, default, default, null)); () => sutProvider.Sut.CompleteSetupAsync(default, default, default, default, null, null));
Assert.Contains("Invalid owner.", exception.Message); Assert.Contains("Invalid owner.", exception.Message);
} }
@@ -53,83 +54,12 @@ public class ProviderServiceTests
userService.GetUserByIdAsync(user.Id).Returns(user); userService.GetUserByIdAsync(user.Id).Returns(user);
var exception = await Assert.ThrowsAsync<BadRequestException>( var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.CompleteSetupAsync(provider, user.Id, default, default, null)); () => sutProvider.Sut.CompleteSetupAsync(provider, user.Id, default, default, null, null));
Assert.Contains("Invalid token.", exception.Message); Assert.Contains("Invalid token.", exception.Message);
} }
[Theory, BitAutoData] [Theory, BitAutoData]
public async Task CompleteSetupAsync_InvalidTaxInfo_ThrowsBadRequestException( public async Task CompleteSetupAsync_Success(User user, Provider provider, string key, TokenizedPaymentMethod tokenizedPaymentMethod, BillingAddress billingAddress,
User user,
Provider provider,
string key,
TaxInfo taxInfo,
TokenizedPaymentSource tokenizedPaymentSource,
[ProviderUser] ProviderUser providerUser,
SutProvider<ProviderService> sutProvider)
{
providerUser.ProviderId = provider.Id;
providerUser.UserId = user.Id;
var userService = sutProvider.GetDependency<IUserService>();
userService.GetUserByIdAsync(user.Id).Returns(user);
var providerUserRepository = sutProvider.GetDependency<IProviderUserRepository>();
providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id).Returns(providerUser);
var dataProtectionProvider = DataProtectionProvider.Create("ApplicationName");
var protector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector");
sutProvider.GetDependency<IDataProtectionProvider>().CreateProtector("ProviderServiceDataProtector")
.Returns(protector);
sutProvider.Create();
var token = protector.Protect($"ProviderSetupInvite {provider.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}");
taxInfo.BillingAddressCountry = null;
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
sutProvider.Sut.CompleteSetupAsync(provider, user.Id, token, key, taxInfo, tokenizedPaymentSource));
Assert.Equal("Both address and postal code are required to set up your provider.", exception.Message);
}
[Theory, BitAutoData]
public async Task CompleteSetupAsync_InvalidTokenizedPaymentSource_ThrowsBadRequestException(
User user,
Provider provider,
string key,
TaxInfo taxInfo,
TokenizedPaymentSource tokenizedPaymentSource,
[ProviderUser] ProviderUser providerUser,
SutProvider<ProviderService> sutProvider)
{
providerUser.ProviderId = provider.Id;
providerUser.UserId = user.Id;
var userService = sutProvider.GetDependency<IUserService>();
userService.GetUserByIdAsync(user.Id).Returns(user);
var providerUserRepository = sutProvider.GetDependency<IProviderUserRepository>();
providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id).Returns(providerUser);
var dataProtectionProvider = DataProtectionProvider.Create("ApplicationName");
var protector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector");
sutProvider.GetDependency<IDataProtectionProvider>().CreateProtector("ProviderServiceDataProtector")
.Returns(protector);
sutProvider.Create();
var token = protector.Protect($"ProviderSetupInvite {provider.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}");
tokenizedPaymentSource = tokenizedPaymentSource with { Type = PaymentMethodType.BitPay };
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
sutProvider.Sut.CompleteSetupAsync(provider, user.Id, token, key, taxInfo, tokenizedPaymentSource));
Assert.Equal("A payment method is required to set up your provider.", exception.Message);
}
[Theory, BitAutoData]
public async Task CompleteSetupAsync_Success(User user, Provider provider, string key, TaxInfo taxInfo, TokenizedPaymentSource tokenizedPaymentSource,
[ProviderUser] ProviderUser providerUser, [ProviderUser] ProviderUser providerUser,
SutProvider<ProviderService> sutProvider) SutProvider<ProviderService> sutProvider)
{ {
@@ -149,7 +79,7 @@ public class ProviderServiceTests
var providerBillingService = sutProvider.GetDependency<IProviderBillingService>(); var providerBillingService = sutProvider.GetDependency<IProviderBillingService>();
var customer = new Customer { Id = "customer_id" }; var customer = new Customer { Id = "customer_id" };
providerBillingService.SetupCustomer(provider, taxInfo, tokenizedPaymentSource).Returns(customer); providerBillingService.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress).Returns(customer);
var subscription = new Subscription { Id = "subscription_id" }; var subscription = new Subscription { Id = "subscription_id" };
providerBillingService.SetupSubscription(provider).Returns(subscription); providerBillingService.SetupSubscription(provider).Returns(subscription);
@@ -158,7 +88,7 @@ public class ProviderServiceTests
var token = protector.Protect($"ProviderSetupInvite {provider.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}"); var token = protector.Protect($"ProviderSetupInvite {provider.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}");
await sutProvider.Sut.CompleteSetupAsync(provider, user.Id, token, key, taxInfo, tokenizedPaymentSource); await sutProvider.Sut.CompleteSetupAsync(provider, user.Id, token, key, tokenizedPaymentMethod, billingAddress);
await sutProvider.GetDependency<IProviderRepository>().Received().UpsertAsync(Arg.Is<Provider>( await sutProvider.GetDependency<IProviderRepository>().Received().UpsertAsync(Arg.Is<Provider>(
p => p =>
@@ -882,12 +812,12 @@ public class ProviderServiceTests
organization.Plan = "Enterprise (Monthly)"; organization.Plan = "Enterprise (Monthly)";
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(organization.PlanType) sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(organization.PlanType)
.Returns(StaticStore.GetPlan(organization.PlanType)); .Returns(MockPlans.Get(organization.PlanType));
var expectedPlanType = PlanType.EnterpriseMonthly2020; var expectedPlanType = PlanType.EnterpriseMonthly2020;
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(expectedPlanType) sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(expectedPlanType)
.Returns(StaticStore.GetPlan(expectedPlanType)); .Returns(MockPlans.Get(expectedPlanType));
var expectedPlanId = "2020-enterprise-org-seat-monthly"; var expectedPlanId = "2020-enterprise-org-seat-monthly";

View File

@@ -58,7 +58,7 @@ public class GetProviderWarningsQueryTests
Customer = new Customer Customer = new Customer
{ {
TaxIds = new StripeList<TaxId> { Data = [] }, TaxIds = new StripeList<TaxId> { Data = [] },
Address = new Address { Country = "US" } Address = new Address { Country = "CA" }
} }
}); });
@@ -90,7 +90,7 @@ public class GetProviderWarningsQueryTests
Customer = new Customer Customer = new Customer
{ {
TaxIds = new StripeList<TaxId> { Data = [] }, TaxIds = new StripeList<TaxId> { Data = [] },
Address = new Address { Country = "US" } Address = new Address { Country = "CA" }
} }
}); });
@@ -124,7 +124,7 @@ public class GetProviderWarningsQueryTests
Customer = new Customer Customer = new Customer
{ {
TaxIds = new StripeList<TaxId> { Data = [] }, TaxIds = new StripeList<TaxId> { Data = [] },
Address = new Address { Country = "US" } Address = new Address { Country = "CA" }
} }
}); });
@@ -158,7 +158,7 @@ public class GetProviderWarningsQueryTests
Customer = new Customer Customer = new Customer
{ {
TaxIds = new StripeList<TaxId> { Data = [] }, TaxIds = new StripeList<TaxId> { Data = [] },
Address = new Address { Country = "US" } Address = new Address { Country = "CA" }
} }
}); });
@@ -191,7 +191,7 @@ public class GetProviderWarningsQueryTests
Customer = new Customer Customer = new Customer
{ {
TaxIds = new StripeList<TaxId> { Data = [] }, TaxIds = new StripeList<TaxId> { Data = [] },
Address = new Address { Country = "US" } Address = new Address { Country = "CA" }
} }
}); });
@@ -219,7 +219,7 @@ public class GetProviderWarningsQueryTests
Customer = new Customer Customer = new Customer
{ {
TaxIds = new StripeList<TaxId> { Data = [] }, TaxIds = new StripeList<TaxId> { Data = [] },
Address = new Address { Country = "US" } Address = new Address { Country = "CA" }
} }
}); });
@@ -227,7 +227,7 @@ public class GetProviderWarningsQueryTests
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>()) sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration> .Returns(new StripeList<Registration>
{ {
Data = [new Registration { Country = "CA" }] Data = [new Registration { Country = "GB" }]
}); });
var response = await sutProvider.Sut.Run(provider); var response = await sutProvider.Sut.Run(provider);
@@ -252,7 +252,7 @@ public class GetProviderWarningsQueryTests
Customer = new Customer Customer = new Customer
{ {
TaxIds = new StripeList<TaxId> { Data = [] }, TaxIds = new StripeList<TaxId> { Data = [] },
Address = new Address { Country = "US" } Address = new Address { Country = "CA" }
} }
}); });
@@ -260,7 +260,7 @@ public class GetProviderWarningsQueryTests
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>()) sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration> .Returns(new StripeList<Registration>
{ {
Data = [new Registration { Country = "US" }] Data = [new Registration { Country = "CA" }]
}); });
var response = await sutProvider.Sut.Run(provider); var response = await sutProvider.Sut.Run(provider);
@@ -291,7 +291,7 @@ public class GetProviderWarningsQueryTests
{ {
Data = [new TaxId { Verification = null }] Data = [new TaxId { Verification = null }]
}, },
Address = new Address { Country = "US" } Address = new Address { Country = "CA" }
} }
}); });
@@ -299,7 +299,7 @@ public class GetProviderWarningsQueryTests
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>()) sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration> .Returns(new StripeList<Registration>
{ {
Data = [new Registration { Country = "US" }] Data = [new Registration { Country = "CA" }]
}); });
var response = await sutProvider.Sut.Run(provider); var response = await sutProvider.Sut.Run(provider);
@@ -333,7 +333,7 @@ public class GetProviderWarningsQueryTests
} }
}] }]
}, },
Address = new Address { Country = "US" } Address = new Address { Country = "CA" }
} }
}); });
@@ -341,7 +341,7 @@ public class GetProviderWarningsQueryTests
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>()) sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration> .Returns(new StripeList<Registration>
{ {
Data = [new Registration { Country = "US" }] Data = [new Registration { Country = "CA" }]
}); });
var response = await sutProvider.Sut.Run(provider); var response = await sutProvider.Sut.Run(provider);
@@ -378,7 +378,7 @@ public class GetProviderWarningsQueryTests
} }
}] }]
}, },
Address = new Address { Country = "US" } Address = new Address { Country = "CA" }
} }
}); });
@@ -386,7 +386,7 @@ public class GetProviderWarningsQueryTests
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>()) sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration> .Returns(new StripeList<Registration>
{ {
Data = [new Registration { Country = "US" }] Data = [new Registration { Country = "CA" }]
}); });
var response = await sutProvider.Sut.Run(provider); var response = await sutProvider.Sut.Run(provider);
@@ -423,7 +423,7 @@ public class GetProviderWarningsQueryTests
} }
}] }]
}, },
Address = new Address { Country = "US" } Address = new Address { Country = "CA" }
} }
}); });
@@ -431,7 +431,7 @@ public class GetProviderWarningsQueryTests
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>()) sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration> .Returns(new StripeList<Registration>
{ {
Data = [new Registration { Country = "US" }] Data = [new Registration { Country = "CA" }]
}); });
var response = await sutProvider.Sut.Run(provider); var response = await sutProvider.Sut.Run(provider);
@@ -498,6 +498,44 @@ public class GetProviderWarningsQueryTests
Status = SubscriptionStatus.Unpaid, Status = SubscriptionStatus.Unpaid,
CancelAt = cancelAt, CancelAt = cancelAt,
Customer = new Customer 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 = [] }, TaxIds = new StripeList<TaxId> { Data = [] },
Address = new Address { Country = "US" } Address = new Address { Country = "US" }
@@ -513,11 +551,6 @@ public class GetProviderWarningsQueryTests
var response = await sutProvider.Sut.Run(provider); var response = await sutProvider.Sut.Run(provider);
Assert.True(response is Assert.Null(response!.TaxId);
{
Suspension.Resolution: "add_payment_method",
TaxId.Type: "tax_id_missing"
});
Assert.Equal(cancelAt, response.Suspension.SubscriptionCancelsAt);
} }
} }

View File

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

View File

@@ -1,5 +1,4 @@
using System.Globalization; using System.Globalization;
using System.Net;
using Bit.Commercial.Core.Billing.Providers.Models; using Bit.Commercial.Core.Billing.Providers.Models;
using Bit.Commercial.Core.Billing.Providers.Services; using Bit.Commercial.Core.Billing.Providers.Services;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
@@ -10,21 +9,20 @@ using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Caches; using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Constants; using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Models; using Bit.Core.Billing.Payment.Models;
using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Providers.Entities; using Bit.Core.Billing.Providers.Entities;
using Bit.Core.Billing.Providers.Models; using Bit.Core.Billing.Providers.Models;
using Bit.Core.Billing.Providers.Repositories; using Bit.Core.Billing.Providers.Repositories;
using Bit.Core.Billing.Providers.Services;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Services;
using Bit.Core.Billing.Tax.Services;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Models.Business;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Settings; using Bit.Core.Settings;
using Bit.Core.Utilities; using Bit.Core.Test.Billing.Mocks;
using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes; using Bit.Test.Common.AutoFixture.Attributes;
using Braintree; using Braintree;
@@ -142,7 +140,7 @@ public class ProviderBillingServiceTests
.Returns(existingPlan); .Returns(existingPlan);
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(existingPlan.PlanType) sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(existingPlan.PlanType)
.Returns(StaticStore.GetPlan(existingPlan.PlanType)); .Returns(MockPlans.Get(existingPlan.PlanType));
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider) sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider)
.Returns(new Subscription .Returns(new Subscription
@@ -157,7 +155,7 @@ public class ProviderBillingServiceTests
Id = "si_ent_annual", Id = "si_ent_annual",
Price = new Price Price = new Price
{ {
Id = StaticStore.GetPlan(PlanType.EnterpriseAnnually).PasswordManager Id = MockPlans.Get(PlanType.EnterpriseAnnually).PasswordManager
.StripeProviderPortalSeatPlanId .StripeProviderPortalSeatPlanId
}, },
Quantity = 10 Quantity = 10
@@ -170,7 +168,7 @@ public class ProviderBillingServiceTests
new ChangeProviderPlanCommand(provider, providerPlanId, PlanType.EnterpriseMonthly); new ChangeProviderPlanCommand(provider, providerPlanId, PlanType.EnterpriseMonthly);
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(command.NewPlan) sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(command.NewPlan)
.Returns(StaticStore.GetPlan(command.NewPlan)); .Returns(MockPlans.Get(command.NewPlan));
// Act // Act
await sutProvider.Sut.ChangePlan(command); await sutProvider.Sut.ChangePlan(command);
@@ -187,7 +185,7 @@ public class ProviderBillingServiceTests
Arg.Is<SubscriptionUpdateOptions>(p => Arg.Is<SubscriptionUpdateOptions>(p =>
p.Items.Count(si => si.Id == "si_ent_annual" && si.Deleted == true) == 1)); p.Items.Count(si => si.Id == "si_ent_annual" && si.Deleted == true) == 1));
var newPlanCfg = StaticStore.GetPlan(command.NewPlan); var newPlanCfg = MockPlans.Get(command.NewPlan);
await stripeAdapter.Received(1) await stripeAdapter.Received(1)
.SubscriptionUpdateAsync( .SubscriptionUpdateAsync(
Arg.Is(provider.GatewaySubscriptionId), Arg.Is(provider.GatewaySubscriptionId),
@@ -493,7 +491,7 @@ public class ProviderBillingServiceTests
foreach (var plan in providerPlans) foreach (var plan in providerPlans)
{ {
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType) sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(StaticStore.GetPlan(plan.PlanType)); .Returns(MockPlans.Get(plan.PlanType));
} }
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id).Returns(providerPlans); sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id).Returns(providerPlans);
@@ -516,7 +514,7 @@ public class ProviderBillingServiceTests
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider).Returns(subscription); sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider).Returns(subscription);
// 50 seats currently assigned with a seat minimum of 100 // 50 seats currently assigned with a seat minimum of 100
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); var teamsMonthlyPlan = MockPlans.Get(PlanType.TeamsMonthly);
sutProvider.GetDependency<IProviderOrganizationRepository>().GetManyDetailsByProviderAsync(provider.Id).Returns( sutProvider.GetDependency<IProviderOrganizationRepository>().GetManyDetailsByProviderAsync(provider.Id).Returns(
[ [
@@ -575,7 +573,7 @@ public class ProviderBillingServiceTests
foreach (var plan in providerPlans) foreach (var plan in providerPlans)
{ {
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType) sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(StaticStore.GetPlan(plan.PlanType)); .Returns(MockPlans.Get(plan.PlanType));
} }
var providerPlan = providerPlans.First(); var providerPlan = providerPlans.First();
@@ -600,7 +598,7 @@ public class ProviderBillingServiceTests
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider).Returns(subscription); sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider).Returns(subscription);
// 95 seats currently assigned with a seat minimum of 100 // 95 seats currently assigned with a seat minimum of 100
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); var teamsMonthlyPlan = MockPlans.Get(PlanType.TeamsMonthly);
sutProvider.GetDependency<IProviderOrganizationRepository>().GetManyDetailsByProviderAsync(provider.Id).Returns( sutProvider.GetDependency<IProviderOrganizationRepository>().GetManyDetailsByProviderAsync(provider.Id).Returns(
[ [
@@ -663,7 +661,7 @@ public class ProviderBillingServiceTests
foreach (var plan in providerPlans) foreach (var plan in providerPlans)
{ {
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType) sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(StaticStore.GetPlan(plan.PlanType)); .Returns(MockPlans.Get(plan.PlanType));
} }
var providerPlan = providerPlans.First(); var providerPlan = providerPlans.First();
@@ -688,7 +686,7 @@ public class ProviderBillingServiceTests
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider).Returns(subscription); sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider).Returns(subscription);
// 110 seats currently assigned with a seat minimum of 100 // 110 seats currently assigned with a seat minimum of 100
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); var teamsMonthlyPlan = MockPlans.Get(PlanType.TeamsMonthly);
sutProvider.GetDependency<IProviderOrganizationRepository>().GetManyDetailsByProviderAsync(provider.Id).Returns( sutProvider.GetDependency<IProviderOrganizationRepository>().GetManyDetailsByProviderAsync(provider.Id).Returns(
[ [
@@ -751,7 +749,7 @@ public class ProviderBillingServiceTests
foreach (var plan in providerPlans) foreach (var plan in providerPlans)
{ {
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType) sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(StaticStore.GetPlan(plan.PlanType)); .Returns(MockPlans.Get(plan.PlanType));
} }
var providerPlan = providerPlans.First(); var providerPlan = providerPlans.First();
@@ -776,7 +774,7 @@ public class ProviderBillingServiceTests
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider).Returns(subscription); sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider).Returns(subscription);
// 110 seats currently assigned with a seat minimum of 100 // 110 seats currently assigned with a seat minimum of 100
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); var teamsMonthlyPlan = MockPlans.Get(PlanType.TeamsMonthly);
sutProvider.GetDependency<IProviderOrganizationRepository>().GetManyDetailsByProviderAsync(provider.Id).Returns( sutProvider.GetDependency<IProviderOrganizationRepository>().GetManyDetailsByProviderAsync(provider.Id).Returns(
[ [
@@ -829,13 +827,13 @@ public class ProviderBillingServiceTests
} }
]); ]);
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(planType).Returns(StaticStore.GetPlan(planType)); sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(planType).Returns(MockPlans.Get(planType));
sutProvider.GetDependency<IProviderOrganizationRepository>().GetManyDetailsByProviderAsync(provider.Id).Returns( sutProvider.GetDependency<IProviderOrganizationRepository>().GetManyDetailsByProviderAsync(provider.Id).Returns(
[ [
new ProviderOrganizationOrganizationDetails new ProviderOrganizationOrganizationDetails
{ {
Plan = StaticStore.GetPlan(planType).Name, Plan = MockPlans.Get(planType).Name,
Status = OrganizationStatusType.Managed, Status = OrganizationStatusType.Managed,
Seats = 5 Seats = 5
} }
@@ -867,13 +865,13 @@ public class ProviderBillingServiceTests
} }
]); ]);
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(planType).Returns(StaticStore.GetPlan(planType)); sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(planType).Returns(MockPlans.Get(planType));
sutProvider.GetDependency<IProviderOrganizationRepository>().GetManyDetailsByProviderAsync(provider.Id).Returns( sutProvider.GetDependency<IProviderOrganizationRepository>().GetManyDetailsByProviderAsync(provider.Id).Returns(
[ [
new ProviderOrganizationOrganizationDetails new ProviderOrganizationOrganizationDetails
{ {
Plan = StaticStore.GetPlan(planType).Name, Plan = MockPlans.Get(planType).Name,
Status = OrganizationStatusType.Managed, Status = OrganizationStatusType.Managed,
Seats = 15 Seats = 15
} }
@@ -894,170 +892,97 @@ public class ProviderBillingServiceTests
#region SetupCustomer #region SetupCustomer
[Theory, BitAutoData] [Theory, BitAutoData]
public async Task SetupCustomer_MissingCountry_ContactSupport( public async Task SetupCustomer_NullPaymentMethod_ThrowsNullReferenceException(
SutProvider<ProviderBillingService> sutProvider, SutProvider<ProviderBillingService> sutProvider,
Provider provider, Provider provider,
TaxInfo taxInfo, BillingAddress billingAddress)
TokenizedPaymentSource tokenizedPaymentSource)
{ {
taxInfo.BillingAddressCountry = null; await Assert.ThrowsAsync<NullReferenceException>(() =>
sutProvider.Sut.SetupCustomer(provider, null, billingAddress));
await ThrowsBillingExceptionAsync(() => sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource));
await sutProvider.GetDependency<IStripeAdapter>()
.DidNotReceiveWithAnyArgs()
.CustomerGetAsync(Arg.Any<string>(), Arg.Any<CustomerGetOptions>());
}
[Theory, BitAutoData]
public async Task SetupCustomer_MissingPostalCode_ContactSupport(
SutProvider<ProviderBillingService> sutProvider,
Provider provider,
TaxInfo taxInfo,
TokenizedPaymentSource tokenizedPaymentSource)
{
taxInfo.BillingAddressCountry = null;
await ThrowsBillingExceptionAsync(() => sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource));
await sutProvider.GetDependency<IStripeAdapter>()
.DidNotReceiveWithAnyArgs()
.CustomerGetAsync(Arg.Any<string>(), Arg.Any<CustomerGetOptions>());
}
[Theory, BitAutoData]
public async Task SetupCustomer_NullPaymentSource_ThrowsArgumentNullException(
SutProvider<ProviderBillingService> sutProvider,
Provider provider,
TaxInfo taxInfo)
{
await Assert.ThrowsAsync<ArgumentNullException>(() =>
sutProvider.Sut.SetupCustomer(provider, taxInfo, null));
}
[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";
tokenizedPaymentSource = tokenizedPaymentSource with { Type = PaymentMethodType.BitPay };
await ThrowsBillingExceptionAsync(() =>
sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource));
} }
[Theory, BitAutoData] [Theory, BitAutoData]
public async Task SetupCustomer_WithBankAccount_Error_Reverts( public async Task SetupCustomer_WithBankAccount_Error_Reverts(
SutProvider<ProviderBillingService> sutProvider, SutProvider<ProviderBillingService> sutProvider,
Provider provider, Provider provider,
TaxInfo taxInfo) BillingAddress billingAddress)
{ {
provider.Name = "MSP"; provider.Name = "MSP";
billingAddress.Country = "AD";
sutProvider.GetDependency<ITaxService>() billingAddress.TaxId = new TaxID("es_nif", "12345678Z");
.GetStripeTaxCode(Arg.Is<string>(
p => p == taxInfo.BillingAddressCountry),
Arg.Is<string>(p => p == taxInfo.TaxIdNumber))
.Returns(taxInfo.TaxIdType);
taxInfo.BillingAddressCountry = "AD";
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>(); var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.BankAccount, Token = "token" };
var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.BankAccount, "token");
stripeAdapter.SetupIntentList(Arg.Is<SetupIntentListOptions>(options => stripeAdapter.SetupIntentList(Arg.Is<SetupIntentListOptions>(options =>
options.PaymentMethod == tokenizedPaymentSource.Token)).Returns([ options.PaymentMethod == tokenizedPaymentMethod.Token)).Returns([
new SetupIntent { Id = "setup_intent_id" } new SetupIntent { Id = "setup_intent_id" }
]); ]);
stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o => stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o =>
o.Address.Country == taxInfo.BillingAddressCountry && o.Address.Country == billingAddress.Country &&
o.Address.PostalCode == taxInfo.BillingAddressPostalCode && o.Address.PostalCode == billingAddress.PostalCode &&
o.Address.Line1 == taxInfo.BillingAddressLine1 && o.Address.Line1 == billingAddress.Line1 &&
o.Address.Line2 == taxInfo.BillingAddressLine2 && o.Address.Line2 == billingAddress.Line2 &&
o.Address.City == taxInfo.BillingAddressCity && o.Address.City == billingAddress.City &&
o.Address.State == taxInfo.BillingAddressState && o.Address.State == billingAddress.State &&
o.Description == WebUtility.HtmlDecode(provider.BusinessName) && o.Description == provider.DisplayBusinessName() &&
o.Email == provider.BillingEmail && o.Email == provider.BillingEmail &&
o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" && o.InvoiceSettings.CustomFields.FirstOrDefault().Name == provider.SubscriberType() &&
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" && o.InvoiceSettings.CustomFields.FirstOrDefault().Value == provider.DisplayName() &&
o.Metadata["region"] == "" && o.Metadata["region"] == "" &&
o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType && o.TaxIdData.FirstOrDefault().Type == billingAddress.TaxId.Code &&
o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber)) o.TaxIdData.FirstOrDefault().Value == billingAddress.TaxId.Value))
.Throws<StripeException>(); .Throws<StripeException>();
sutProvider.GetDependency<ISetupIntentCache>().Get(provider.Id).Returns("setup_intent_id"); sutProvider.GetDependency<ISetupIntentCache>().GetSetupIntentIdForSubscriber(provider.Id).Returns("setup_intent_id");
await Assert.ThrowsAsync<StripeException>(() => await Assert.ThrowsAsync<StripeException>(() =>
sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource)); sutProvider.Sut.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress));
await sutProvider.GetDependency<ISetupIntentCache>().Received(1).Set(provider.Id, "setup_intent_id"); await sutProvider.GetDependency<ISetupIntentCache>().Received(1).Set(provider.Id, "setup_intent_id");
await stripeAdapter.Received(1).SetupIntentCancel("setup_intent_id", Arg.Is<SetupIntentCancelOptions>(options => await stripeAdapter.Received(1).SetupIntentCancel("setup_intent_id", Arg.Is<SetupIntentCancelOptions>(options =>
options.CancellationReason == "abandoned")); options.CancellationReason == "abandoned"));
await sutProvider.GetDependency<ISetupIntentCache>().Received(1).Remove(provider.Id); await sutProvider.GetDependency<ISetupIntentCache>().Received(1).RemoveSetupIntentForSubscriber(provider.Id);
} }
[Theory, BitAutoData] [Theory, BitAutoData]
public async Task SetupCustomer_WithPayPal_Error_Reverts( public async Task SetupCustomer_WithPayPal_Error_Reverts(
SutProvider<ProviderBillingService> sutProvider, SutProvider<ProviderBillingService> sutProvider,
Provider provider, Provider provider,
TaxInfo taxInfo) BillingAddress billingAddress)
{ {
provider.Name = "MSP"; provider.Name = "MSP";
billingAddress.Country = "AD";
sutProvider.GetDependency<ITaxService>() billingAddress.TaxId = new TaxID("es_nif", "12345678Z");
.GetStripeTaxCode(Arg.Is<string>(
p => p == taxInfo.BillingAddressCountry),
Arg.Is<string>(p => p == taxInfo.TaxIdNumber))
.Returns(taxInfo.TaxIdType);
taxInfo.BillingAddressCountry = "AD";
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>(); var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.PayPal, Token = "token" };
var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.PayPal, "token"); sutProvider.GetDependency<ISubscriberService>().CreateBraintreeCustomer(provider, tokenizedPaymentMethod.Token)
sutProvider.GetDependency<ISubscriberService>().CreateBraintreeCustomer(provider, tokenizedPaymentSource.Token)
.Returns("braintree_customer_id"); .Returns("braintree_customer_id");
stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o => stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o =>
o.Address.Country == taxInfo.BillingAddressCountry && o.Address.Country == billingAddress.Country &&
o.Address.PostalCode == taxInfo.BillingAddressPostalCode && o.Address.PostalCode == billingAddress.PostalCode &&
o.Address.Line1 == taxInfo.BillingAddressLine1 && o.Address.Line1 == billingAddress.Line1 &&
o.Address.Line2 == taxInfo.BillingAddressLine2 && o.Address.Line2 == billingAddress.Line2 &&
o.Address.City == taxInfo.BillingAddressCity && o.Address.City == billingAddress.City &&
o.Address.State == taxInfo.BillingAddressState && o.Address.State == billingAddress.State &&
o.Description == WebUtility.HtmlDecode(provider.BusinessName) && o.Description == provider.DisplayBusinessName() &&
o.Email == provider.BillingEmail && o.Email == provider.BillingEmail &&
o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" && o.InvoiceSettings.CustomFields.FirstOrDefault().Name == provider.SubscriberType() &&
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" && o.InvoiceSettings.CustomFields.FirstOrDefault().Value == provider.DisplayName() &&
o.Metadata["region"] == "" && o.Metadata["region"] == "" &&
o.Metadata["btCustomerId"] == "braintree_customer_id" && o.Metadata["btCustomerId"] == "braintree_customer_id" &&
o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType && o.TaxIdData.FirstOrDefault().Type == billingAddress.TaxId.Code &&
o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber)) o.TaxIdData.FirstOrDefault().Value == billingAddress.TaxId.Value))
.Throws<StripeException>(); .Throws<StripeException>();
await Assert.ThrowsAsync<StripeException>(() => await Assert.ThrowsAsync<StripeException>(() =>
sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource)); sutProvider.Sut.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress));
await sutProvider.GetDependency<IBraintreeGateway>().Customer.Received(1).DeleteAsync("braintree_customer_id"); await sutProvider.GetDependency<IBraintreeGateway>().Customer.Received(1).DeleteAsync("braintree_customer_id");
} }
@@ -1066,17 +991,11 @@ public class ProviderBillingServiceTests
public async Task SetupCustomer_WithBankAccount_Success( public async Task SetupCustomer_WithBankAccount_Success(
SutProvider<ProviderBillingService> sutProvider, SutProvider<ProviderBillingService> sutProvider,
Provider provider, Provider provider,
TaxInfo taxInfo) BillingAddress billingAddress)
{ {
provider.Name = "MSP"; provider.Name = "MSP";
billingAddress.Country = "AD";
sutProvider.GetDependency<ITaxService>() billingAddress.TaxId = new TaxID("es_nif", "12345678Z");
.GetStripeTaxCode(Arg.Is<string>(
p => p == taxInfo.BillingAddressCountry),
Arg.Is<string>(p => p == taxInfo.TaxIdNumber))
.Returns(taxInfo.TaxIdType);
taxInfo.BillingAddressCountry = "AD";
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>(); var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
@@ -1086,31 +1005,30 @@ public class ProviderBillingServiceTests
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported } Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
}; };
var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.BankAccount, "token"); var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.BankAccount, Token = "token" };
stripeAdapter.SetupIntentList(Arg.Is<SetupIntentListOptions>(options => stripeAdapter.SetupIntentList(Arg.Is<SetupIntentListOptions>(options =>
options.PaymentMethod == tokenizedPaymentSource.Token)).Returns([ options.PaymentMethod == tokenizedPaymentMethod.Token)).Returns([
new SetupIntent { Id = "setup_intent_id" } new SetupIntent { Id = "setup_intent_id" }
]); ]);
stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o => stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o =>
o.Address.Country == taxInfo.BillingAddressCountry && o.Address.Country == billingAddress.Country &&
o.Address.PostalCode == taxInfo.BillingAddressPostalCode && o.Address.PostalCode == billingAddress.PostalCode &&
o.Address.Line1 == taxInfo.BillingAddressLine1 && o.Address.Line1 == billingAddress.Line1 &&
o.Address.Line2 == taxInfo.BillingAddressLine2 && o.Address.Line2 == billingAddress.Line2 &&
o.Address.City == taxInfo.BillingAddressCity && o.Address.City == billingAddress.City &&
o.Address.State == taxInfo.BillingAddressState && o.Address.State == billingAddress.State &&
o.Description == WebUtility.HtmlDecode(provider.BusinessName) && o.Description == provider.DisplayBusinessName() &&
o.Email == provider.BillingEmail && o.Email == provider.BillingEmail &&
o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" && o.InvoiceSettings.CustomFields.FirstOrDefault().Name == provider.SubscriberType() &&
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" && o.InvoiceSettings.CustomFields.FirstOrDefault().Value == provider.DisplayName() &&
o.Metadata["region"] == "" && o.Metadata["region"] == "" &&
o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType && o.TaxIdData.FirstOrDefault().Type == billingAddress.TaxId.Code &&
o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber)) o.TaxIdData.FirstOrDefault().Value == billingAddress.TaxId.Value))
.Returns(expected); .Returns(expected);
var actual = await sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource); var actual = await sutProvider.Sut.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress);
Assert.Equivalent(expected, actual); Assert.Equivalent(expected, actual);
@@ -1121,17 +1039,11 @@ public class ProviderBillingServiceTests
public async Task SetupCustomer_WithPayPal_Success( public async Task SetupCustomer_WithPayPal_Success(
SutProvider<ProviderBillingService> sutProvider, SutProvider<ProviderBillingService> sutProvider,
Provider provider, Provider provider,
TaxInfo taxInfo) BillingAddress billingAddress)
{ {
provider.Name = "MSP"; provider.Name = "MSP";
billingAddress.Country = "AD";
sutProvider.GetDependency<ITaxService>() billingAddress.TaxId = new TaxID("es_nif", "12345678Z");
.GetStripeTaxCode(Arg.Is<string>(
p => p == taxInfo.BillingAddressCountry),
Arg.Is<string>(p => p == taxInfo.TaxIdNumber))
.Returns(taxInfo.TaxIdType);
taxInfo.BillingAddressCountry = "AD";
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>(); var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
@@ -1141,30 +1053,29 @@ public class ProviderBillingServiceTests
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported } Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
}; };
var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.PayPal, "token"); var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.PayPal, Token = "token" };
sutProvider.GetDependency<ISubscriberService>().CreateBraintreeCustomer(provider, tokenizedPaymentMethod.Token)
sutProvider.GetDependency<ISubscriberService>().CreateBraintreeCustomer(provider, tokenizedPaymentSource.Token)
.Returns("braintree_customer_id"); .Returns("braintree_customer_id");
stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o => stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o =>
o.Address.Country == taxInfo.BillingAddressCountry && o.Address.Country == billingAddress.Country &&
o.Address.PostalCode == taxInfo.BillingAddressPostalCode && o.Address.PostalCode == billingAddress.PostalCode &&
o.Address.Line1 == taxInfo.BillingAddressLine1 && o.Address.Line1 == billingAddress.Line1 &&
o.Address.Line2 == taxInfo.BillingAddressLine2 && o.Address.Line2 == billingAddress.Line2 &&
o.Address.City == taxInfo.BillingAddressCity && o.Address.City == billingAddress.City &&
o.Address.State == taxInfo.BillingAddressState && o.Address.State == billingAddress.State &&
o.Description == WebUtility.HtmlDecode(provider.BusinessName) && o.Description == provider.DisplayBusinessName() &&
o.Email == provider.BillingEmail && o.Email == provider.BillingEmail &&
o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" && o.InvoiceSettings.CustomFields.FirstOrDefault().Name == provider.SubscriberType() &&
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" && o.InvoiceSettings.CustomFields.FirstOrDefault().Value == provider.DisplayName() &&
o.Metadata["region"] == "" && o.Metadata["region"] == "" &&
o.Metadata["btCustomerId"] == "braintree_customer_id" && o.Metadata["btCustomerId"] == "braintree_customer_id" &&
o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType && o.TaxIdData.FirstOrDefault().Type == billingAddress.TaxId.Code &&
o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber)) o.TaxIdData.FirstOrDefault().Value == billingAddress.TaxId.Value))
.Returns(expected); .Returns(expected);
var actual = await sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource); var actual = await sutProvider.Sut.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress);
Assert.Equivalent(expected, actual); Assert.Equivalent(expected, actual);
} }
@@ -1173,17 +1084,11 @@ public class ProviderBillingServiceTests
public async Task SetupCustomer_WithCard_Success( public async Task SetupCustomer_WithCard_Success(
SutProvider<ProviderBillingService> sutProvider, SutProvider<ProviderBillingService> sutProvider,
Provider provider, Provider provider,
TaxInfo taxInfo) BillingAddress billingAddress)
{ {
provider.Name = "MSP"; provider.Name = "MSP";
billingAddress.Country = "AD";
sutProvider.GetDependency<ITaxService>() billingAddress.TaxId = new TaxID("es_nif", "12345678Z");
.GetStripeTaxCode(Arg.Is<string>(
p => p == taxInfo.BillingAddressCountry),
Arg.Is<string>(p => p == taxInfo.TaxIdNumber))
.Returns(taxInfo.TaxIdType);
taxInfo.BillingAddressCountry = "AD";
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>(); var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
@@ -1193,28 +1098,26 @@ public class ProviderBillingServiceTests
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported } Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
}; };
var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.Card, "token"); var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.Card, Token = "token" };
stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o => stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o =>
o.Address.Country == taxInfo.BillingAddressCountry && o.Address.Country == billingAddress.Country &&
o.Address.PostalCode == taxInfo.BillingAddressPostalCode && o.Address.PostalCode == billingAddress.PostalCode &&
o.Address.Line1 == taxInfo.BillingAddressLine1 && o.Address.Line1 == billingAddress.Line1 &&
o.Address.Line2 == taxInfo.BillingAddressLine2 && o.Address.Line2 == billingAddress.Line2 &&
o.Address.City == taxInfo.BillingAddressCity && o.Address.City == billingAddress.City &&
o.Address.State == taxInfo.BillingAddressState && o.Address.State == billingAddress.State &&
o.Description == WebUtility.HtmlDecode(provider.BusinessName) && o.Description == provider.DisplayBusinessName() &&
o.Email == provider.BillingEmail && o.Email == provider.BillingEmail &&
o.PaymentMethod == tokenizedPaymentSource.Token && o.InvoiceSettings.DefaultPaymentMethod == tokenizedPaymentMethod.Token &&
o.InvoiceSettings.DefaultPaymentMethod == tokenizedPaymentSource.Token && o.InvoiceSettings.CustomFields.FirstOrDefault().Name == provider.SubscriberType() &&
o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" && o.InvoiceSettings.CustomFields.FirstOrDefault().Value == provider.DisplayName() &&
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" &&
o.Metadata["region"] == "" && o.Metadata["region"] == "" &&
o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType && o.TaxIdData.FirstOrDefault().Type == billingAddress.TaxId.Code &&
o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber)) o.TaxIdData.FirstOrDefault().Value == billingAddress.TaxId.Value))
.Returns(expected); .Returns(expected);
var actual = await sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource); var actual = await sutProvider.Sut.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress);
Assert.Equivalent(expected, actual); Assert.Equivalent(expected, actual);
} }
@@ -1223,17 +1126,11 @@ public class ProviderBillingServiceTests
public async Task SetupCustomer_WithCard_ReverseCharge_Success( public async Task SetupCustomer_WithCard_ReverseCharge_Success(
SutProvider<ProviderBillingService> sutProvider, SutProvider<ProviderBillingService> sutProvider,
Provider provider, Provider provider,
TaxInfo taxInfo) BillingAddress billingAddress)
{ {
provider.Name = "MSP"; provider.Name = "MSP";
billingAddress.Country = "FR"; // Non-US country to trigger reverse charge
sutProvider.GetDependency<ITaxService>() billingAddress.TaxId = new TaxID("fr_siren", "123456789");
.GetStripeTaxCode(Arg.Is<string>(
p => p == taxInfo.BillingAddressCountry),
Arg.Is<string>(p => p == taxInfo.TaxIdNumber))
.Returns(taxInfo.TaxIdType);
taxInfo.BillingAddressCountry = "AD";
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>(); var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
@@ -1243,55 +1140,51 @@ public class ProviderBillingServiceTests
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported } Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
}; };
var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.Card, "token"); var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.Card, Token = "token" };
stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o => stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o =>
o.Address.Country == taxInfo.BillingAddressCountry && o.Address.Country == billingAddress.Country &&
o.Address.PostalCode == taxInfo.BillingAddressPostalCode && o.Address.PostalCode == billingAddress.PostalCode &&
o.Address.Line1 == taxInfo.BillingAddressLine1 && o.Address.Line1 == billingAddress.Line1 &&
o.Address.Line2 == taxInfo.BillingAddressLine2 && o.Address.Line2 == billingAddress.Line2 &&
o.Address.City == taxInfo.BillingAddressCity && o.Address.City == billingAddress.City &&
o.Address.State == taxInfo.BillingAddressState && o.Address.State == billingAddress.State &&
o.Description == WebUtility.HtmlDecode(provider.BusinessName) && o.Description == provider.DisplayBusinessName() &&
o.Email == provider.BillingEmail && o.Email == provider.BillingEmail &&
o.PaymentMethod == tokenizedPaymentSource.Token && o.InvoiceSettings.DefaultPaymentMethod == tokenizedPaymentMethod.Token &&
o.InvoiceSettings.DefaultPaymentMethod == tokenizedPaymentSource.Token && o.InvoiceSettings.CustomFields.FirstOrDefault().Name == provider.SubscriberType() &&
o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" && o.InvoiceSettings.CustomFields.FirstOrDefault().Value == provider.DisplayName() &&
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" &&
o.Metadata["region"] == "" && o.Metadata["region"] == "" &&
o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType && o.TaxIdData.FirstOrDefault().Type == billingAddress.TaxId.Code &&
o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber && o.TaxIdData.FirstOrDefault().Value == billingAddress.TaxId.Value &&
o.TaxExempt == StripeConstants.TaxExempt.Reverse)) o.TaxExempt == StripeConstants.TaxExempt.Reverse))
.Returns(expected); .Returns(expected);
var actual = await sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource); var actual = await sutProvider.Sut.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress);
Assert.Equivalent(expected, actual); Assert.Equivalent(expected, actual);
} }
[Theory, BitAutoData] [Theory, BitAutoData]
public async Task SetupCustomer_Throws_BadRequestException_WhenTaxIdIsInvalid( public async Task SetupCustomer_WithInvalidTaxId_ThrowsBadRequestException(
SutProvider<ProviderBillingService> sutProvider, SutProvider<ProviderBillingService> sutProvider,
Provider provider, Provider provider,
TaxInfo taxInfo, BillingAddress billingAddress)
TokenizedPaymentSource tokenizedPaymentSource)
{ {
provider.Name = "MSP"; provider.Name = "MSP";
billingAddress.Country = "AD";
billingAddress.TaxId = new TaxID("es_nif", "invalid_tax_id");
taxInfo.BillingAddressCountry = "AD"; var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.Card, Token = "token" };
sutProvider.GetDependency<ITaxService>() stripeAdapter.CustomerCreateAsync(Arg.Any<CustomerCreateOptions>())
.GetStripeTaxCode(Arg.Is<string>( .Throws(new StripeException("Invalid tax ID") { StripeError = new StripeError { Code = "tax_id_invalid" } });
p => p == taxInfo.BillingAddressCountry),
Arg.Is<string>(p => p == taxInfo.TaxIdNumber))
.Returns((string)null);
var actual = await Assert.ThrowsAsync<BadRequestException>(async () => var actual = await Assert.ThrowsAsync<BadRequestException>(async () =>
await sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource)); await sutProvider.Sut.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress));
Assert.IsType<BadRequestException>(actual); Assert.Equal("Your tax ID wasn't recognized for your selected country. Please ensure your country and tax ID are valid.", actual.Message);
Assert.Equal("billingTaxIdTypeInferenceError", actual.Message);
} }
#endregion #endregion
@@ -1345,7 +1238,7 @@ public class ProviderBillingServiceTests
.Returns(providerPlans); .Returns(providerPlans);
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(PlanType.EnterpriseMonthly) sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(PlanType.EnterpriseMonthly)
.Returns(StaticStore.GetPlan(PlanType.EnterpriseMonthly)); .Returns(MockPlans.Get(PlanType.EnterpriseMonthly));
await ThrowsBillingExceptionAsync(() => sutProvider.Sut.SetupSubscription(provider)); await ThrowsBillingExceptionAsync(() => sutProvider.Sut.SetupSubscription(provider));
@@ -1373,7 +1266,7 @@ public class ProviderBillingServiceTests
.Returns(providerPlans); .Returns(providerPlans);
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(PlanType.TeamsMonthly) sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(PlanType.TeamsMonthly)
.Returns(StaticStore.GetPlan(PlanType.TeamsMonthly)); .Returns(MockPlans.Get(PlanType.TeamsMonthly));
await ThrowsBillingExceptionAsync(() => sutProvider.Sut.SetupSubscription(provider)); await ThrowsBillingExceptionAsync(() => sutProvider.Sut.SetupSubscription(provider));
@@ -1424,7 +1317,7 @@ public class ProviderBillingServiceTests
foreach (var plan in providerPlans) foreach (var plan in providerPlans)
{ {
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType) sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(StaticStore.GetPlan(plan.PlanType)); .Returns(MockPlans.Get(plan.PlanType));
} }
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id) sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
@@ -1480,7 +1373,7 @@ public class ProviderBillingServiceTests
foreach (var plan in providerPlans) foreach (var plan in providerPlans)
{ {
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType) sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(StaticStore.GetPlan(plan.PlanType)); .Returns(MockPlans.Get(plan.PlanType));
} }
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id) sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
@@ -1556,7 +1449,7 @@ public class ProviderBillingServiceTests
foreach (var plan in providerPlans) foreach (var plan in providerPlans)
{ {
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType) sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(StaticStore.GetPlan(plan.PlanType)); .Returns(MockPlans.Get(plan.PlanType));
} }
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id) sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
@@ -1632,7 +1525,7 @@ public class ProviderBillingServiceTests
foreach (var plan in providerPlans) foreach (var plan in providerPlans)
{ {
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType) sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(StaticStore.GetPlan(plan.PlanType)); .Returns(MockPlans.Get(plan.PlanType));
} }
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id) sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
@@ -1643,7 +1536,7 @@ public class ProviderBillingServiceTests
const string setupIntentId = "seti_123"; const string setupIntentId = "seti_123";
sutProvider.GetDependency<ISetupIntentCache>().Get(provider.Id).Returns(setupIntentId); sutProvider.GetDependency<ISetupIntentCache>().GetSetupIntentIdForSubscriber(provider.Id).Returns(setupIntentId);
sutProvider.GetDependency<IStripeAdapter>().SetupIntentGet(setupIntentId, Arg.Is<SetupIntentGetOptions>(options => sutProvider.GetDependency<IStripeAdapter>().SetupIntentGet(setupIntentId, Arg.Is<SetupIntentGetOptions>(options =>
options.Expand.Contains("payment_method"))).Returns(new SetupIntent options.Expand.Contains("payment_method"))).Returns(new SetupIntent
@@ -1733,7 +1626,7 @@ public class ProviderBillingServiceTests
foreach (var plan in providerPlans) foreach (var plan in providerPlans)
{ {
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType) sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(StaticStore.GetPlan(plan.PlanType)); .Returns(MockPlans.Get(plan.PlanType));
} }
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id) sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
@@ -1811,7 +1704,7 @@ public class ProviderBillingServiceTests
foreach (var plan in providerPlans) foreach (var plan in providerPlans)
{ {
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType) sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(StaticStore.GetPlan(plan.PlanType)); .Returns(MockPlans.Get(plan.PlanType));
} }
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id) sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
@@ -1879,8 +1772,8 @@ public class ProviderBillingServiceTests
const string enterpriseLineItemId = "enterprise_line_item_id"; const string enterpriseLineItemId = "enterprise_line_item_id";
const string teamsLineItemId = "teams_line_item_id"; const string teamsLineItemId = "teams_line_item_id";
var enterprisePriceId = StaticStore.GetPlan(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId; var enterprisePriceId = MockPlans.Get(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
var teamsPriceId = StaticStore.GetPlan(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId; var teamsPriceId = MockPlans.Get(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
var subscription = new Subscription var subscription = new Subscription
{ {
@@ -1913,7 +1806,7 @@ public class ProviderBillingServiceTests
foreach (var plan in providerPlans) foreach (var plan in providerPlans)
{ {
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType) sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(StaticStore.GetPlan(plan.PlanType)); .Returns(MockPlans.Get(plan.PlanType));
} }
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans); providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
@@ -1959,8 +1852,8 @@ public class ProviderBillingServiceTests
const string enterpriseLineItemId = "enterprise_line_item_id"; const string enterpriseLineItemId = "enterprise_line_item_id";
const string teamsLineItemId = "teams_line_item_id"; const string teamsLineItemId = "teams_line_item_id";
var enterprisePriceId = StaticStore.GetPlan(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId; var enterprisePriceId = MockPlans.Get(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
var teamsPriceId = StaticStore.GetPlan(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId; var teamsPriceId = MockPlans.Get(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
var subscription = new Subscription var subscription = new Subscription
{ {
@@ -1993,7 +1886,7 @@ public class ProviderBillingServiceTests
foreach (var plan in providerPlans) foreach (var plan in providerPlans)
{ {
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType) sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(StaticStore.GetPlan(plan.PlanType)); .Returns(MockPlans.Get(plan.PlanType));
} }
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans); providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
@@ -2039,8 +1932,8 @@ public class ProviderBillingServiceTests
const string enterpriseLineItemId = "enterprise_line_item_id"; const string enterpriseLineItemId = "enterprise_line_item_id";
const string teamsLineItemId = "teams_line_item_id"; const string teamsLineItemId = "teams_line_item_id";
var enterprisePriceId = StaticStore.GetPlan(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId; var enterprisePriceId = MockPlans.Get(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
var teamsPriceId = StaticStore.GetPlan(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId; var teamsPriceId = MockPlans.Get(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
var subscription = new Subscription var subscription = new Subscription
{ {
@@ -2073,7 +1966,7 @@ public class ProviderBillingServiceTests
foreach (var plan in providerPlans) foreach (var plan in providerPlans)
{ {
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType) sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(StaticStore.GetPlan(plan.PlanType)); .Returns(MockPlans.Get(plan.PlanType));
} }
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans); providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
@@ -2113,8 +2006,8 @@ public class ProviderBillingServiceTests
const string enterpriseLineItemId = "enterprise_line_item_id"; const string enterpriseLineItemId = "enterprise_line_item_id";
const string teamsLineItemId = "teams_line_item_id"; const string teamsLineItemId = "teams_line_item_id";
var enterprisePriceId = StaticStore.GetPlan(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId; var enterprisePriceId = MockPlans.Get(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
var teamsPriceId = StaticStore.GetPlan(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId; var teamsPriceId = MockPlans.Get(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
var subscription = new Subscription var subscription = new Subscription
{ {
@@ -2147,7 +2040,7 @@ public class ProviderBillingServiceTests
foreach (var plan in providerPlans) foreach (var plan in providerPlans)
{ {
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType) sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(StaticStore.GetPlan(plan.PlanType)); .Returns(MockPlans.Get(plan.PlanType));
} }
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans); providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
@@ -2193,8 +2086,8 @@ public class ProviderBillingServiceTests
const string enterpriseLineItemId = "enterprise_line_item_id"; const string enterpriseLineItemId = "enterprise_line_item_id";
const string teamsLineItemId = "teams_line_item_id"; const string teamsLineItemId = "teams_line_item_id";
var enterprisePriceId = StaticStore.GetPlan(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId; var enterprisePriceId = MockPlans.Get(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
var teamsPriceId = StaticStore.GetPlan(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId; var teamsPriceId = MockPlans.Get(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
var subscription = new Subscription var subscription = new Subscription
{ {
@@ -2227,7 +2120,7 @@ public class ProviderBillingServiceTests
foreach (var plan in providerPlans) foreach (var plan in providerPlans)
{ {
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType) sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(StaticStore.GetPlan(plan.PlanType)); .Returns(MockPlans.Get(plan.PlanType));
} }
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans); providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);

View File

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

View File

@@ -6,7 +6,7 @@ using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.SecretsManager.Repositories; using Bit.Core.SecretsManager.Repositories;
using Bit.Core.Settings; using Bit.Core.Settings;
using Bit.Core.Utilities; using Bit.Core.Test.Billing.Mocks;
using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes; using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute; using NSubstitute;
@@ -69,7 +69,7 @@ public class MaxProjectsQueryTests
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization); sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
sutProvider.GetDependency<IPricingClient>().GetPlan(organization.PlanType) sutProvider.GetDependency<IPricingClient>().GetPlan(organization.PlanType)
.Returns(StaticStore.GetPlan(organization.PlanType)); .Returns(MockPlans.Get(organization.PlanType));
var (limit, overLimit) = await sutProvider.Sut.GetByOrgIdAsync(organization.Id, 1); var (limit, overLimit) = await sutProvider.Sut.GetByOrgIdAsync(organization.Id, 1);
@@ -114,7 +114,7 @@ public class MaxProjectsQueryTests
.Returns(projects); .Returns(projects);
sutProvider.GetDependency<IPricingClient>().GetPlan(organization.PlanType) sutProvider.GetDependency<IPricingClient>().GetPlan(organization.PlanType)
.Returns(StaticStore.GetPlan(organization.PlanType)); .Returns(MockPlans.Get(organization.PlanType));
var (max, overMax) = await sutProvider.Sut.GetByOrgIdAsync(organization.Id, projectsToAdd); var (max, overMax) = await sutProvider.Sut.GetByOrgIdAsync(organization.Id, projectsToAdd);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="$(MicrosoftNetTestSdkVersion)" />
<PackageReference Include="xunit" Version="$(XUnitVersion)" />
<PackageReference Include="xunit.runner.visualstudio" Version="$(XUnitRunnerVisualStudioVersion)">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="$(CoverletCollectorVersion)">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="NSubstitute" Version="$(NSubstituteVersion)" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Sso\Sso.csproj" />
<ProjectReference Include="..\..\..\test\Common\Common.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,36 @@
{
"globalSettings": {
"baseServiceUri": {
"vault": "https://localhost:8080",
"api": "http://localhost:4000",
"identity": "http://localhost:33656",
"admin": "http://localhost:62911",
"notifications": "http://localhost:61840",
"sso": "http://localhost:51822",
"internalNotifications": "http://localhost:61840",
"internalAdmin": "http://localhost:62911",
"internalIdentity": "http://localhost:33656",
"internalApi": "http://localhost:4000",
"internalVault": "https://localhost:8080",
"internalSso": "http://localhost:51822",
"internalScim": "http://localhost:44559"
},
"mail": {
"smtp": {
"host": "localhost",
"port": 10250
}
},
"attachment": {
"connectionString": "UseDevelopmentStorage=true",
"baseUrl": "http://localhost:4000/attachments/"
},
"events": {
"connectionString": "UseDevelopmentStorage=true"
},
"storage": {
"connectionString": "UseDevelopmentStorage=true"
},
"pricingUri": "https://billingpricing.qa.bitwarden.pw"
}
}

View File

@@ -436,7 +436,7 @@ public class PatchGroupCommandTests
await sutProvider.GetDependency<IGroupService>().DidNotReceiveWithAnyArgs().DeleteUserAsync(default, default); await sutProvider.GetDependency<IGroupService>().DidNotReceiveWithAnyArgs().DeleteUserAsync(default, default);
// Assert: logging // Assert: logging
sutProvider.GetDependency<ILogger<PatchGroupCommand>>().ReceivedWithAnyArgs().LogWarning(default); sutProvider.GetDependency<ILogger<PatchGroupCommand>>().ReceivedWithAnyArgs().LogWarning("");
} }
[Theory] [Theory]

View File

@@ -53,10 +53,10 @@ services:
- ./.data/postgres/log:/var/log/postgresql - ./.data/postgres/log:/var/log/postgresql
profiles: profiles:
- postgres - postgres
- ef
mysql: mysql:
image: mysql:8.0 image: mysql:8.0
container_name: bw-mysql
ports: ports:
- "3306:3306" - "3306:3306"
command: command:
@@ -69,6 +69,7 @@ services:
- mysql_dev_data:/var/lib/mysql - mysql_dev_data:/var/lib/mysql
profiles: profiles:
- mysql - mysql
- ef
mariadb: mariadb:
image: mariadb:10 image: mariadb:10
@@ -76,17 +77,16 @@ services:
- 4306:3306 - 4306:3306
environment: environment:
MARIADB_USER: maria MARIADB_USER: maria
MARIADB_PASSWORD: ${MARIADB_ROOT_PASSWORD}
MARIADB_DATABASE: vault_dev MARIADB_DATABASE: vault_dev
MARIADB_RANDOM_ROOT_PASSWORD: "true" MARIADB_RANDOM_ROOT_PASSWORD: "true"
volumes: volumes:
- mariadb_dev_data:/var/lib/mysql - mariadb_dev_data:/var/lib/mysql
profiles: profiles:
- mariadb - mariadb
- ef
idp: idp:
image: kenchan0130/simplesamlphp:1.19.8 image: kenchan0130/simplesamlphp:1.19.8
container_name: idp
ports: ports:
- "8090:8080" - "8090:8080"
environment: environment:
@@ -99,8 +99,7 @@ services:
- idp - idp
rabbitmq: rabbitmq:
image: rabbitmq:4.1.0-management image: rabbitmq:4.1.3-management
container_name: rabbitmq
ports: ports:
- "5672:5672" - "5672:5672"
- "15672:15672" - "15672:15672"
@@ -114,7 +113,6 @@ services:
reverse-proxy: reverse-proxy:
image: nginx:alpine image: nginx:alpine
container_name: reverse-proxy
volumes: volumes:
- "./reverse-proxy.conf:/etc/nginx/conf.d/default.conf" - "./reverse-proxy.conf:/etc/nginx/conf.d/default.conf"
ports: ports:
@@ -124,7 +122,6 @@ services:
- proxy - proxy
service-bus: service-bus:
container_name: service-bus
image: mcr.microsoft.com/azure-messaging/servicebus-emulator:latest image: mcr.microsoft.com/azure-messaging/servicebus-emulator:latest
pull_policy: always pull_policy: always
volumes: volumes:
@@ -140,7 +137,6 @@ services:
redis: redis:
image: redis:alpine image: redis:alpine
container_name: bw-redis
ports: ports:
- "6379:6379" - "6379:6379"
volumes: volumes:
@@ -153,5 +149,6 @@ volumes:
mssql_dev_data: mssql_dev_data:
postgres_dev_data: postgres_dev_data:
mysql_dev_data: mysql_dev_data:
mariadb_dev_data:
rabbitmq_data: rabbitmq_data:
redis_data: redis_data:

View File

@@ -11,9 +11,18 @@ dotnet tool restore
Set-Location "./src/Identity" Set-Location "./src/Identity"
dotnet build dotnet build
dotnet swagger tofile --output "../../identity.json" --host "https://identity.bitwarden.com" "./bin/Debug/net8.0/Identity.dll" "v1" dotnet swagger tofile --output "../../identity.json" --host "https://identity.bitwarden.com" "./bin/Debug/net8.0/Identity.dll" "v1"
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
# Api internal & public # Api internal & public
Set-Location "../../src/Api" Set-Location "../../src/Api"
dotnet build dotnet build
dotnet swagger tofile --output "../../api.json" --host "https://api.bitwarden.com" "./bin/Debug/net8.0/Api.dll" "internal" dotnet swagger tofile --output "../../api.json" --host "https://api.bitwarden.com" "./bin/Debug/net8.0/Api.dll" "internal"
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
dotnet swagger tofile --output "../../api.public.json" --host "https://api.bitwarden.com" "./bin/Debug/net8.0/Api.dll" "public" dotnet swagger tofile --output "../../api.public.json" --host "https://api.bitwarden.com" "./bin/Debug/net8.0/Api.dll" "public"
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}

View File

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

View File

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

View File

@@ -3,22 +3,6 @@
"Namespaces": [ "Namespaces": [
{ {
"Name": "sbemulatorns", "Name": "sbemulatorns",
"Queues": [
{
"Name": "queue.1",
"Properties": {
"DeadLetteringOnMessageExpiration": false,
"DefaultMessageTimeToLive": "PT1H",
"DuplicateDetectionHistoryTimeWindow": "PT20S",
"ForwardDeadLetteredMessagesTo": "",
"ForwardTo": "",
"LockDuration": "PT1M",
"MaxDeliveryCount": 3,
"RequiresDuplicateDetection": false,
"RequiresSession": false
}
}
],
"Topics": [ "Topics": [
{ {
"Name": "event-logging", "Name": "event-logging",
@@ -34,6 +18,12 @@
}, },
{ {
"Name": "events-hec-subscription" "Name": "events-hec-subscription"
},
{
"Name": "events-datadog-subscription"
},
{
"Name": "events-teams-subscription"
} }
] ]
}, },
@@ -81,6 +71,34 @@
} }
} }
] ]
},
{
"Name": "integration-datadog-subscription",
"Rules": [
{
"Name": "datadog-integration-filter",
"Properties": {
"FilterType": "Correlation",
"CorrelationFilter": {
"Label": "datadog"
}
}
}
]
},
{
"Name": "integration-teams-subscription",
"Rules": [
{
"Name": "teams-integration-filter",
"Properties": {
"FilterType": "Correlation",
"CorrelationFilter": {
"Label": "teams"
}
}
}
]
} }
] ]
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -472,6 +472,7 @@ public class OrganizationsController : Controller
organization.UseRiskInsights = model.UseRiskInsights; organization.UseRiskInsights = model.UseRiskInsights;
organization.UseOrganizationDomains = model.UseOrganizationDomains; organization.UseOrganizationDomains = model.UseOrganizationDomains;
organization.UseAdminSponsoredFamilies = model.UseAdminSponsoredFamilies; organization.UseAdminSponsoredFamilies = model.UseAdminSponsoredFamilies;
organization.UseAutomaticUserConfirmation = model.UseAutomaticUserConfirmation;
//secrets //secrets
organization.SmSeats = model.SmSeats; organization.SmSeats = model.SmSeats;

View File

@@ -106,6 +106,8 @@ public class OrganizationEditModel : OrganizationViewModel
SmServiceAccounts = org.SmServiceAccounts; SmServiceAccounts = org.SmServiceAccounts;
MaxAutoscaleSmServiceAccounts = org.MaxAutoscaleSmServiceAccounts; MaxAutoscaleSmServiceAccounts = org.MaxAutoscaleSmServiceAccounts;
UseOrganizationDomains = org.UseOrganizationDomains; UseOrganizationDomains = org.UseOrganizationDomains;
UseAutomaticUserConfirmation = org.UseAutomaticUserConfirmation;
_plans = plans; _plans = plans;
} }
@@ -192,6 +194,8 @@ public class OrganizationEditModel : OrganizationViewModel
[Display(Name = "Use Organization Domains")] [Display(Name = "Use Organization Domains")]
public bool UseOrganizationDomains { get; set; } public bool UseOrganizationDomains { get; set; }
[Display(Name = "Automatic User Confirmation")]
public bool UseAutomaticUserConfirmation { get; set; }
/** /**
* Creates a Plan[] object for use in Javascript * Creates a Plan[] object for use in Javascript
* This is mapped manually below to provide some type safety in case the plan objects change * This is mapped manually below to provide some type safety in case the plan objects change
@@ -231,6 +235,7 @@ public class OrganizationEditModel : OrganizationViewModel
LegacyYear = p.LegacyYear, LegacyYear = p.LegacyYear,
Disabled = p.Disabled, Disabled = p.Disabled,
SupportsSecretsManager = p.SupportsSecretsManager, SupportsSecretsManager = p.SupportsSecretsManager,
AutomaticUserConfirmation = p.AutomaticUserConfirmation,
PasswordManager = PasswordManager =
new new
{ {

View File

@@ -152,12 +152,16 @@
<input type="checkbox" class="form-check-input" asp-for="UseCustomPermissions" disabled='@(canEditPlan ? null : "disabled")'> <input type="checkbox" class="form-check-input" asp-for="UseCustomPermissions" disabled='@(canEditPlan ? null : "disabled")'>
<label class="form-check-label" asp-for="UseCustomPermissions"></label> <label class="form-check-label" asp-for="UseCustomPermissions"></label>
</div> </div>
@if(FeatureService.IsEnabled(FeatureFlagKeys.PM17772_AdminInitiatedSponsorships))
{
<div class="form-check"> <div class="form-check">
<input type="checkbox" class="form-check-input" asp-for="UseAdminSponsoredFamilies" disabled='@(canEditPlan ? null : "disabled")'> <input type="checkbox" class="form-check-input" asp-for="UseAdminSponsoredFamilies" disabled='@(canEditPlan ? null : "disabled")'>
<label class="form-check-label" asp-for="UseAdminSponsoredFamilies"></label> <label class="form-check-label" asp-for="UseAdminSponsoredFamilies"></label>
</div> </div>
@if(FeatureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers))
{
<div class="form-check">
<input type="checkbox" class="form-check-input" asp-for="UseAutomaticUserConfirmation" disabled='@(canEditPlan ? null : "disabled")'>
<label class="form-check-label" asp-for="UseAutomaticUserConfirmation"></label>
</div>
} }
</div> </div>
<div class="col-3"> <div class="col-3">

View File

@@ -1,83 +0,0 @@
using Bit.Admin.Billing.Models;
using Bit.Admin.Enums;
using Bit.Admin.Utilities;
using Bit.Core.Billing.Providers.Migration.Models;
using Bit.Core.Billing.Providers.Migration.Services;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Admin.Billing.Controllers;
[Authorize]
[Route("migrate-providers")]
[SelfHosted(NotSelfHostedOnly = true)]
public class MigrateProvidersController(
IProviderMigrator providerMigrator) : Controller
{
[HttpGet]
[RequirePermission(Permission.Tools_MigrateProviders)]
public IActionResult Index()
{
return View(new MigrateProvidersRequestModel());
}
[HttpPost]
[RequirePermission(Permission.Tools_MigrateProviders)]
[ValidateAntiForgeryToken]
public async Task<IActionResult> PostAsync(MigrateProvidersRequestModel request)
{
var providerIds = GetProviderIdsFromInput(request.ProviderIds);
if (providerIds.Count == 0)
{
return RedirectToAction("Index");
}
foreach (var providerId in providerIds)
{
await providerMigrator.Migrate(providerId);
}
return RedirectToAction("Results", new { ProviderIds = string.Join("\r\n", providerIds) });
}
[HttpGet("results")]
[RequirePermission(Permission.Tools_MigrateProviders)]
public async Task<IActionResult> ResultsAsync(MigrateProvidersRequestModel request)
{
var providerIds = GetProviderIdsFromInput(request.ProviderIds);
if (providerIds.Count == 0)
{
return View(Array.Empty<ProviderMigrationResult>());
}
var results = await Task.WhenAll(providerIds.Select(providerMigrator.GetResult));
return View(results);
}
[HttpGet("results/{providerId:guid}")]
[RequirePermission(Permission.Tools_MigrateProviders)]
public async Task<IActionResult> DetailsAsync([FromRoute] Guid providerId)
{
var result = await providerMigrator.GetResult(providerId);
if (result == null)
{
return RedirectToAction("Index");
}
return View(result);
}
private static List<Guid> GetProviderIdsFromInput(string text) => !string.IsNullOrEmpty(text)
? text.Split(
["\r\n", "\r", "\n"],
StringSplitOptions.TrimEntries
)
.Select(id => new Guid(id))
.ToList()
: [];
}

View File

@@ -1,13 +0,0 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.ComponentModel.DataAnnotations;
namespace Bit.Admin.Billing.Models;
public class MigrateProvidersRequestModel
{
[Required]
[Display(Name = "Provider IDs")]
public string ProviderIds { get; set; }
}

View File

@@ -1,39 +0,0 @@
@using System.Text.Json
@model Bit.Core.Billing.Providers.Migration.Models.ProviderMigrationResult
@{
ViewData["Title"] = "Results";
}
<h1>Migrate Providers</h1>
<h2>Migration Details: @Model.ProviderName</h2>
<dl class="row">
<dt class="col-sm-4 col-lg-3">Id</dt>
<dd class="col-sm-8 col-lg-9"><code>@Model.ProviderId</code></dd>
<dt class="col-sm-4 col-lg-3">Result</dt>
<dd class="col-sm-8 col-lg-9">@Model.Result</dd>
</dl>
<h3>Client Organizations</h3>
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Result</th>
<th>Previous State</th>
</tr>
</thead>
<tbody>
@foreach (var clientResult in Model.Clients)
{
<tr>
<td>@clientResult.OrganizationId</td>
<td>@clientResult.OrganizationName</td>
<td>@clientResult.Result</td>
<td><pre>@Html.Raw(JsonSerializer.Serialize(clientResult.PreviousState))</pre></td>
</tr>
}
</tbody>
</table>
</div>

View File

@@ -1,46 +0,0 @@
@model Bit.Admin.Billing.Models.MigrateProvidersRequestModel;
@{
ViewData["Title"] = "Migrate Providers";
}
<h1>Migrate Providers</h1>
<h2>Bulk Consolidated Billing Migration Tool</h2>
<section>
<p>
This tool allows you to provide a list of IDs for Providers that you would like to migrate to Consolidated Billing.
Because of the expensive nature of the operation, you can only migrate 10 Providers at a time.
</p>
<p class="alert alert-warning">
Updates made through this tool are irreversible without manual intervention.
</p>
<p>Example Input (Please enter each Provider ID separated by a new line):</p>
<div class="card">
<div class="card-body">
<pre class="mb-0">f513affc-2290-4336-879e-21ec3ecf3e78
f7a5cb0d-4b74-445c-8d8c-232d1d32bbe2
bf82d3cf-0e21-4f39-b81b-ef52b2fc6a3a
174e82fc-70c3-448d-9fe7-00bad2a3ab00
22a4bbbf-58e3-4e4c-a86a-a0d7caf4ff14</pre>
</div>
</div>
<form method="post" asp-controller="MigrateProviders" asp-action="Post" class="mt-2">
<div asp-validation-summary="All" class="text-danger"></div>
<div class="mb-3">
<label class="form-label" asp-for="ProviderIds"></label>
<textarea rows="10" class="form-control" type="text" asp-for="ProviderIds"></textarea>
</div>
<div class="mb-3">
<input type="submit" value="Run" class="btn btn-primary"/>
</div>
</form>
<form method="get" asp-controller="MigrateProviders" asp-action="Results" class="mt-2">
<div asp-validation-summary="All" class="text-danger"></div>
<div class="mb-3">
<label class="form-label" asp-for="ProviderIds"></label>
<textarea rows="10" class="form-control" type="text" asp-for="ProviderIds"></textarea>
</div>
<div class="mb-3">
<input type="submit" value="See Previous Results" class="btn btn-primary"/>
</div>
</form>
</section>

View File

@@ -1,28 +0,0 @@
@model Bit.Core.Billing.Providers.Migration.Models.ProviderMigrationResult[]
@{
ViewData["Title"] = "Results";
}
<h1>Migrate Providers</h1>
<h2>Results</h2>
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Result</th>
</tr>
</thead>
<tbody>
@foreach (var result in Model)
{
<tr>
<td><a href="@Url.Action("Details", "MigrateProviders", new { providerId = result.ProviderId })">@result.ProviderId</a></td>
<td>@result.ProviderName</td>
<td>@result.Result</td>
</tr>
}
</tbody>
</table>
</div>

View File

@@ -61,7 +61,7 @@ public class HomeController : Controller
} }
catch (HttpRequestException e) catch (HttpRequestException e)
{ {
_logger.LogError(e, $"Error encountered while sending GET request to {requestUri}"); _logger.LogError(e, "Error encountered while sending GET request to {RequestUri}", requestUri);
return new JsonResult("Unable to fetch latest version") { StatusCode = StatusCodes.Status500InternalServerError }; return new JsonResult("Unable to fetch latest version") { StatusCode = StatusCodes.Status500InternalServerError };
} }
@@ -83,7 +83,7 @@ public class HomeController : Controller
} }
catch (HttpRequestException e) catch (HttpRequestException e)
{ {
_logger.LogError(e, $"Error encountered while sending GET request to {requestUri}"); _logger.LogError(e, "Error encountered while sending GET request to {RequestUri}", requestUri);
return new JsonResult("Unable to fetch installed version") { StatusCode = StatusCodes.Status500InternalServerError }; return new JsonResult("Unable to fetch installed version") { StatusCode = StatusCodes.Status500InternalServerError };
} }

View File

@@ -1,7 +1,6 @@
// FIXME: Update this file to be null safe and then delete the line below // FIXME: Update this file to be null safe and then delete the line below
#nullable disable #nullable disable
using System.Text;
using System.Text.Json; using System.Text.Json;
using Bit.Admin.Enums; using Bit.Admin.Enums;
using Bit.Admin.Models; using Bit.Admin.Models;
@@ -10,7 +9,6 @@ using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Organizations.Queries; using Bit.Core.Billing.Organizations.Queries;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Models.BitStripe;
using Bit.Core.Platform.Installations; using Bit.Core.Platform.Installations;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
@@ -33,7 +31,6 @@ public class ToolsController : Controller
private readonly IInstallationRepository _installationRepository; private readonly IInstallationRepository _installationRepository;
private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IProviderUserRepository _providerUserRepository; private readonly IProviderUserRepository _providerUserRepository;
private readonly IPaymentService _paymentService;
private readonly IStripeAdapter _stripeAdapter; private readonly IStripeAdapter _stripeAdapter;
private readonly IWebHostEnvironment _environment; private readonly IWebHostEnvironment _environment;
@@ -46,7 +43,6 @@ public class ToolsController : Controller
IInstallationRepository installationRepository, IInstallationRepository installationRepository,
IOrganizationUserRepository organizationUserRepository, IOrganizationUserRepository organizationUserRepository,
IProviderUserRepository providerUserRepository, IProviderUserRepository providerUserRepository,
IPaymentService paymentService,
IStripeAdapter stripeAdapter, IStripeAdapter stripeAdapter,
IWebHostEnvironment environment) IWebHostEnvironment environment)
{ {
@@ -58,7 +54,6 @@ public class ToolsController : Controller
_installationRepository = installationRepository; _installationRepository = installationRepository;
_organizationUserRepository = organizationUserRepository; _organizationUserRepository = organizationUserRepository;
_providerUserRepository = providerUserRepository; _providerUserRepository = providerUserRepository;
_paymentService = paymentService;
_stripeAdapter = stripeAdapter; _stripeAdapter = stripeAdapter;
_environment = environment; _environment = environment;
} }
@@ -341,138 +336,4 @@ public class ToolsController : Controller
throw new Exception("No license to generate."); throw new Exception("No license to generate.");
} }
} }
[RequirePermission(Permission.Tools_ManageStripeSubscriptions)]
public async Task<IActionResult> StripeSubscriptions(StripeSubscriptionListOptions options)
{
options = options ?? new StripeSubscriptionListOptions();
options.Limit = 10;
options.Expand = new List<string>() { "data.customer", "data.latest_invoice" };
options.SelectAll = false;
var subscriptions = await _stripeAdapter.SubscriptionListAsync(options);
options.StartingAfter = subscriptions.LastOrDefault()?.Id;
options.EndingBefore = await StripeSubscriptionsGetHasPreviousPage(subscriptions, options) ?
subscriptions.FirstOrDefault()?.Id :
null;
var isProduction = _environment.IsProduction();
var model = new StripeSubscriptionsModel()
{
Items = subscriptions.Select(s => new StripeSubscriptionRowModel(s)).ToList(),
Prices = (await _stripeAdapter.PriceListAsync(new Stripe.PriceListOptions() { Limit = 100 })).Data,
TestClocks = isProduction ? new List<Stripe.TestHelpers.TestClock>() : await _stripeAdapter.TestClockListAsync(),
Filter = options
};
return View(model);
}
[HttpPost]
[RequirePermission(Permission.Tools_ManageStripeSubscriptions)]
public async Task<IActionResult> StripeSubscriptions([FromForm] StripeSubscriptionsModel model)
{
if (!ModelState.IsValid)
{
var isProduction = _environment.IsProduction();
model.Prices = (await _stripeAdapter.PriceListAsync(new Stripe.PriceListOptions() { Limit = 100 })).Data;
model.TestClocks = isProduction ? new List<Stripe.TestHelpers.TestClock>() : await _stripeAdapter.TestClockListAsync();
return View(model);
}
if (model.Action == StripeSubscriptionsAction.Export || model.Action == StripeSubscriptionsAction.BulkCancel)
{
var subscriptions = model.Filter.SelectAll ?
await _stripeAdapter.SubscriptionListAsync(model.Filter) :
model.Items.Where(x => x.Selected).Select(x => x.Subscription);
if (model.Action == StripeSubscriptionsAction.Export)
{
return StripeSubscriptionsExport(subscriptions);
}
if (model.Action == StripeSubscriptionsAction.BulkCancel)
{
await StripeSubscriptionsCancel(subscriptions);
}
}
else
{
if (model.Action == StripeSubscriptionsAction.PreviousPage || model.Action == StripeSubscriptionsAction.Search)
{
model.Filter.StartingAfter = null;
}
if (model.Action == StripeSubscriptionsAction.NextPage || model.Action == StripeSubscriptionsAction.Search)
{
if (!string.IsNullOrEmpty(model.Filter.StartingAfter))
{
var subscription = await _stripeAdapter.SubscriptionGetAsync(model.Filter.StartingAfter);
if (subscription.Status == "canceled")
{
model.Filter.StartingAfter = null;
}
}
model.Filter.EndingBefore = null;
}
}
return RedirectToAction("StripeSubscriptions", model.Filter);
}
// This requires a redundant API call to Stripe because of the way they handle pagination.
// The StartingBefore value has to be inferred from the list we get, and isn't supplied by Stripe.
private async Task<bool> StripeSubscriptionsGetHasPreviousPage(List<Stripe.Subscription> subscriptions, StripeSubscriptionListOptions options)
{
var hasPreviousPage = false;
if (subscriptions.FirstOrDefault()?.Id != null)
{
var previousPageSearchOptions = new StripeSubscriptionListOptions()
{
EndingBefore = subscriptions.FirstOrDefault().Id,
Limit = 1,
Status = options.Status,
CurrentPeriodEndDate = options.CurrentPeriodEndDate,
CurrentPeriodEndRange = options.CurrentPeriodEndRange,
Price = options.Price
};
hasPreviousPage = (await _stripeAdapter.SubscriptionListAsync(previousPageSearchOptions)).Count > 0;
}
return hasPreviousPage;
}
private async Task StripeSubscriptionsCancel(IEnumerable<Stripe.Subscription> subscriptions)
{
foreach (var s in subscriptions)
{
await _stripeAdapter.SubscriptionCancelAsync(s.Id);
if (s.LatestInvoice?.Status == "open")
{
await _stripeAdapter.InvoiceVoidInvoiceAsync(s.LatestInvoiceId);
}
}
}
private FileResult StripeSubscriptionsExport(IEnumerable<Stripe.Subscription> subscriptions)
{
var fieldsToExport = subscriptions.Select(s => new
{
StripeId = s.Id,
CustomerEmail = s.Customer?.Email,
SubscriptionStatus = s.Status,
InvoiceDueDate = s.CurrentPeriodEnd,
SubscriptionProducts = s.Items?.Data.Select(p => p.Plan.Id)
});
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
};
var result = System.Text.Json.JsonSerializer.Serialize(fieldsToExport, options);
var bytes = Encoding.UTF8.GetBytes(result);
return File(bytes, "application/json", "StripeSubscriptionsSearch.json");
}
} }

View File

@@ -52,8 +52,6 @@ public enum Permission
Tools_PromoteProviderServiceUser, Tools_PromoteProviderServiceUser,
Tools_GenerateLicenseFile, Tools_GenerateLicenseFile,
Tools_ManageTaxRates, Tools_ManageTaxRates,
Tools_ManageStripeSubscriptions,
Tools_CreateEditTransaction, Tools_CreateEditTransaction,
Tools_ProcessStripeEvents, Tools_ProcessStripeEvents
Tools_MigrateProviders
} }

View File

@@ -22,7 +22,7 @@ public class AliveJob : BaseJob
{ {
_logger.LogInformation(Constants.BypassFiltersEventId, "Execute job task: Keep alive"); _logger.LogInformation(Constants.BypassFiltersEventId, "Execute job task: Keep alive");
var response = await _httpClient.GetAsync(_globalSettings.BaseServiceUri.Admin); var response = await _httpClient.GetAsync(_globalSettings.BaseServiceUri.Admin);
_logger.LogInformation(Constants.BypassFiltersEventId, "Finished job task: Keep alive, " + _logger.LogInformation(Constants.BypassFiltersEventId, "Finished job task: Keep alive, {StatusCode}",
response.StatusCode); response.StatusCode);
} }
} }

View File

@@ -1,45 +0,0 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.ComponentModel.DataAnnotations;
using Bit.Core.Models.BitStripe;
namespace Bit.Admin.Models;
public class StripeSubscriptionRowModel
{
public Stripe.Subscription Subscription { get; set; }
public bool Selected { get; set; }
public StripeSubscriptionRowModel() { }
public StripeSubscriptionRowModel(Stripe.Subscription subscription)
{
Subscription = subscription;
}
}
public enum StripeSubscriptionsAction
{
Search,
PreviousPage,
NextPage,
Export,
BulkCancel
}
public class StripeSubscriptionsModel : IValidatableObject
{
public List<StripeSubscriptionRowModel> Items { get; set; }
public StripeSubscriptionsAction Action { get; set; } = StripeSubscriptionsAction.Search;
public string Message { get; set; }
public List<Stripe.Price> Prices { get; set; }
public List<Stripe.TestHelpers.TestClock> TestClocks { get; set; }
public StripeSubscriptionListOptions Filter { get; set; } = new StripeSubscriptionListOptions();
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (Action == StripeSubscriptionsAction.BulkCancel && Filter.Status != "unpaid")
{
yield return new ValidationResult("Bulk cancel is currently only supported for unpaid subscriptions");
}
}
}

View File

@@ -10,7 +10,6 @@ using Microsoft.AspNetCore.Mvc.Razor;
using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.DependencyInjection.Extensions;
using Bit.Admin.Services; using Bit.Admin.Services;
using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Providers.Migration;
#if !OSS #if !OSS
using Bit.Commercial.Core.Utilities; using Bit.Commercial.Core.Utilities;
@@ -92,7 +91,6 @@ public class Startup
services.AddDistributedCache(globalSettings); services.AddDistributedCache(globalSettings);
services.AddBillingOperations(); services.AddBillingOperations();
services.AddHttpClient(); services.AddHttpClient();
services.AddProviderMigration();
#if OSS #if OSS
services.AddOosServices(); services.AddOosServices();

View File

@@ -52,8 +52,7 @@ public static class RolePermissionMapping
Permission.Tools_PromoteAdmin, Permission.Tools_PromoteAdmin,
Permission.Tools_PromoteProviderServiceUser, Permission.Tools_PromoteProviderServiceUser,
Permission.Tools_GenerateLicenseFile, Permission.Tools_GenerateLicenseFile,
Permission.Tools_ManageTaxRates, Permission.Tools_ManageTaxRates
Permission.Tools_ManageStripeSubscriptions
} }
}, },
{ "admin", new List<Permission> { "admin", new List<Permission>
@@ -105,7 +104,6 @@ public static class RolePermissionMapping
Permission.Tools_PromoteProviderServiceUser, Permission.Tools_PromoteProviderServiceUser,
Permission.Tools_GenerateLicenseFile, Permission.Tools_GenerateLicenseFile,
Permission.Tools_ManageTaxRates, Permission.Tools_ManageTaxRates,
Permission.Tools_ManageStripeSubscriptions,
Permission.Tools_CreateEditTransaction Permission.Tools_CreateEditTransaction
} }
}, },
@@ -180,10 +178,8 @@ public static class RolePermissionMapping
Permission.Tools_ChargeBrainTreeCustomer, Permission.Tools_ChargeBrainTreeCustomer,
Permission.Tools_GenerateLicenseFile, Permission.Tools_GenerateLicenseFile,
Permission.Tools_ManageTaxRates, Permission.Tools_ManageTaxRates,
Permission.Tools_ManageStripeSubscriptions,
Permission.Tools_CreateEditTransaction, Permission.Tools_CreateEditTransaction,
Permission.Tools_ProcessStripeEvents, Permission.Tools_ProcessStripeEvents
Permission.Tools_MigrateProviders
} }
}, },
{ "sales", new List<Permission> { "sales", new List<Permission>

View File

@@ -13,12 +13,10 @@
var canPromoteAdmin = AccessControlService.UserHasPermission(Permission.Tools_PromoteAdmin); var canPromoteAdmin = AccessControlService.UserHasPermission(Permission.Tools_PromoteAdmin);
var canPromoteProviderServiceUser = AccessControlService.UserHasPermission(Permission.Tools_PromoteProviderServiceUser); var canPromoteProviderServiceUser = AccessControlService.UserHasPermission(Permission.Tools_PromoteProviderServiceUser);
var canGenerateLicense = AccessControlService.UserHasPermission(Permission.Tools_GenerateLicenseFile); var canGenerateLicense = AccessControlService.UserHasPermission(Permission.Tools_GenerateLicenseFile);
var canManageStripeSubscriptions = AccessControlService.UserHasPermission(Permission.Tools_ManageStripeSubscriptions);
var canProcessStripeEvents = AccessControlService.UserHasPermission(Permission.Tools_ProcessStripeEvents); var canProcessStripeEvents = AccessControlService.UserHasPermission(Permission.Tools_ProcessStripeEvents);
var canMigrateProviders = AccessControlService.UserHasPermission(Permission.Tools_MigrateProviders);
var canViewTools = canChargeBraintree || canCreateTransaction || canPromoteAdmin || canPromoteProviderServiceUser || var canViewTools = canChargeBraintree || canCreateTransaction || canPromoteAdmin || canPromoteProviderServiceUser ||
canGenerateLicense || canManageStripeSubscriptions; canGenerateLicense;
} }
<!DOCTYPE html> <!DOCTYPE html>
@@ -102,12 +100,6 @@
<a class="dropdown-item" asp-controller="Tools" asp-action="GenerateLicense"> <a class="dropdown-item" asp-controller="Tools" asp-action="GenerateLicense">
Generate License Generate License
</a> </a>
}
@if (canManageStripeSubscriptions)
{
<a class="dropdown-item" asp-controller="Tools" asp-action="StripeSubscriptions">
Manage Stripe Subscriptions
</a>
} }
@if (canProcessStripeEvents) @if (canProcessStripeEvents)
{ {
@@ -115,12 +107,6 @@
Process Stripe Events Process Stripe Events
</a> </a>
} }
@if (canMigrateProviders)
{
<a class="dropdown-item" asp-controller="MigrateProviders" asp-action="index">
Migrate Providers
</a>
}
</ul> </ul>
</li> </li>
} }

View File

@@ -1,277 +0,0 @@
@model StripeSubscriptionsModel
@{
ViewData["Title"] = "Stripe Subscriptions";
}
@section Scripts {
<script>
function onRowSelect(selectingPage = false) {
let checkboxes = document.getElementsByClassName('row-check');
let checkedCheckboxCount = 0;
let bulkActions = document.getElementById('bulkActions');
let selectPage = document.getElementById('selectPage');
for(let i = 0; i < checkboxes.length; i++){
if((checkboxes[i].checked && !selectingPage) || selectingPage && selectPage.checked) {
checkboxes[i].checked = true;
checkedCheckboxCount += 1;
} else {
checkboxes[i].checked = false;
}
}
if(checkedCheckboxCount > 0) {
bulkActions.classList.remove("d-none");
} else {
bulkActions.classList.add("d-none");
}
let selectAll = document.getElementById('selectAll');
if (checkedCheckboxCount === checkboxes.length) {
selectPage.checked = true;
selectAll.classList.remove("d-none");
let selectAllElement = document.getElementById('selectAllElement');
selectAllElement.classList.remove('d-none');
let selectedAllConfirmation = document.getElementById('selectedAllConfirmation');
selectedAllConfirmation.classList.add('d-none');
} else {
selectPage.checked = false;
selectAll.classList.add("d-none");
let selectAllInput = document.getElementById('selectAllInput');
selectAllInput.checked = false;
}
}
function onSelectAll() {
let selectAllInput = document.getElementById('selectAllInput');
selectAllInput.checked = true;
let selectAllElement = document.getElementById('selectAllElement');
selectAllElement.classList.add('d-none');
let selectedAllConfirmation = document.getElementById('selectedAllConfirmation');
selectedAllConfirmation.classList.remove('d-none');
}
function exportSelectedSubscriptions() {
let selectAll = document.getElementById('selectAll');
let httpRequest = new XMLHttpRequest();
httpRequest.open("POST");
httpRequest.send();
}
function cancelSelectedSubscriptions() {
}
</script>
}
<h2>Manage Stripe Subscriptions</h2>
@if (!string.IsNullOrWhiteSpace(Model.Message))
{
<div class="alert alert-success"></div>
}
<form method="post">
<div asp-validation-summary="All" class="alert alert-danger"></div>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label" asp-for="Filter.Status">Status</label>
<select asp-for="Filter.Status" name="filter.Status" class="form-select">
<option asp-selected="Model.Filter.Status == null" value="all">All</option>
<option asp-selected='Model.Filter.Status == "active"' value="active">Active</option>
<option asp-selected='Model.Filter.Status == "unpaid"' value="unpaid">Unpaid</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label" asp-for="Filter.CurrentPeriodEnd">Current Period End</label>
<div class="input-group">
<div class="input-group-text">
<div class="form-check form-check-inline mb-0">
<input type="radio" class="form-check-input" asp-for="Filter.CurrentPeriodEndRange" value="lt" id="beforeRadio">
<label class="form-check-label me-2" for="beforeRadio">Before</label>
</div>
<div class="form-check form-check-inline mb-0">
<input type="radio" class="form-check-input" asp-for="Filter.CurrentPeriodEndRange" value="gt" id="afterRadio">
<label class="form-check-label" for="afterRadio">After</label>
</div>
</div>
@{
var date = @Model.Filter.CurrentPeriodEndDate.HasValue ? @Model.Filter.CurrentPeriodEndDate.Value.ToString("yyyy-MM-dd") : string.Empty;
}
<input type="date" class="form-control" asp-for="Filter.CurrentPeriodEndDate" name="filter.CurrentPeriodEndDate" value="@date">
</div>
</div>
<div class="col-md-6">
<label class="form-label" asp-for="Filter.Price">Price ID</label>
<select asp-for="Filter.Price" name="filter.Price" class="form-select">
<option asp-selected="Model.Filter.Price == null" value="@null">All</option>
@foreach (var price in Model.Prices)
{
<option asp-selected='@(Model.Filter.Price == price.Id)' value="@price.Id">@price.Id</option>
}
</select>
</div>
<div class="col-md-6">
<label class="form-label" asp-for="Filter.TestClock">Test Clock</label>
<select asp-for="Filter.TestClock" name="filter.TestClock" class="form-select">
<option asp-selected="Model.Filter.TestClock == null" value="@null">All</option>
@foreach (var clock in Model.TestClocks)
{
<option asp-selected='@(Model.Filter.TestClock == clock.Id)' value="@clock.Id">@clock.Name</option>
}
</select>
</div>
<div class="col-12 text-end">
<button type="submit" class="btn btn-primary" title="Search" name="action" asp-for="Action" value="@StripeSubscriptionsAction.Search">
<i class="fa fa-search"></i> Search
</button>
</div>
</div>
<hr/>
<input type="checkbox" class="d-none" name="filter.SelectAll" id="selectAllInput" asp-for="@Model.Filter.SelectAll">
<div class="text-center row d-flex justify-content-center">
<div id="selectAll" class="d-none col-8">
All @Model.Items.Count subscriptions on this page are selected.<br/>
<button type="button" id="selectAllElement" class="btn btn-link p-0 pb-1" onclick="onSelectAll()">Click here to select all subscriptions for this search.</button>
<span id="selectedAllConfirmation" class="d-none text-body-secondary">
<i class="fa fa-check"></i> All subscriptions for this search are selected.
</span>
<div class="alert alert-warning mt-2" role="alert">
Please be aware that bulk operations may take several minutes to complete.
</div>
</div>
</div>
<div class="table-responsive">
<table class="table table-striped table-hover align-middle">
<thead>
<tr>
<th>
<div class="form-check">
<input id="selectPage" class="form-check-input" type="checkbox" onchange="onRowSelect(true)">
</div>
</th>
<th>Id</th>
<th>Customer Email</th>
<th>Status</th>
<th>Product Tier</th>
<th>Current Period End</th>
</tr>
</thead>
<tbody>
@if (!Model.Items.Any())
{
<tr>
<td colspan="6">No results to list.</td>
</tr>
}
else
{
@for (var i = 0; i < Model.Items.Count; i++)
{
<tr>
<td>
@{
var i0 = i;
}
<input type="hidden" asp-for="@Model.Items[i0].Subscription.Id" value="@Model.Items[i].Subscription.Id">
<input type="hidden" asp-for="@Model.Items[i0].Subscription.Status" value="@Model.Items[i].Subscription.Status">
<input type="hidden" asp-for="@Model.Items[i0].Subscription.CurrentPeriodEnd" value="@Model.Items[i].Subscription.CurrentPeriodEnd">
<input type="hidden" asp-for="@Model.Items[i0].Subscription.Customer.Email" value="@Model.Items[i].Subscription.Customer.Email">
<input type="hidden" asp-for="@Model.Items[i0].Subscription.LatestInvoice.Status" value="@Model.Items[i].Subscription.LatestInvoice.Status">
<input type="hidden" asp-for="@Model.Items[i0].Subscription.LatestInvoice.Id" value="@Model.Items[i].Subscription.LatestInvoice.Id">
@for (var j = 0; j < Model.Items[i].Subscription.Items.Data.Count; j++)
{
var i1 = i;
var j1 = j;
<input
type="hidden"
asp-for="@Model.Items[i1].Subscription.Items.Data[j1].Plan.Id"
value="@Model.Items[i].Subscription.Items.Data[j].Plan.Id">
}
<div class="form-check">
@{
var i2 = i;
}
<input class="form-check-input row-check mt-0" onchange="onRowSelect()" asp-for="@Model.Items[i2].Selected">
</div>
</td>
<td>
@Model.Items[i].Subscription.Id
</td>
<td>
@Model.Items[i].Subscription.Customer?.Email
</td>
<td>
@Model.Items[i].Subscription.Status
</td>
<td>
@string.Join(",", Model.Items[i].Subscription.Items.Data.Select(product => product.Plan.Id).ToArray())
</td>
<td>
@Model.Items[i].Subscription.CurrentPeriodEnd.ToShortDateString()
</td>
</tr>
}
}
</tbody>
</table>
</div>
<nav class="d-inline-flex align-items-center">
<ul class="pagination mb-0">
@if (!string.IsNullOrWhiteSpace(Model.Filter.EndingBefore))
{
<input type="hidden" asp-for="@Model.Filter.EndingBefore" value="@Model.Filter.EndingBefore">
<li class="page-item">
<button
type="submit"
class="page-link"
name="action"
asp-for="Action"
value="@StripeSubscriptionsAction.PreviousPage">
Previous
</button>
</li>
}
else
{
<li class="page-item disabled">
<a class="page-link" href="#" tabindex="-1">Previous</a>
</li>
}
@if (!string.IsNullOrWhiteSpace(Model.Filter.StartingAfter))
{
<input type="hidden" asp-for="@Model.Filter.StartingAfter" value="@Model.Filter.StartingAfter">
<li class="page-item">
<button class="page-link"
type="submit"
name="action"
asp-for="Action"
value="@StripeSubscriptionsAction.NextPage">
Next
</button>
</li>
}
else
{
<li class="page-item disabled">
<a class="page-link" href="#" tabindex="-1">Next</a>
</li>
}
</ul>
<span id="bulkActions" class="d-none ms-3">
<span class="d-inline-flex gap-2">
<button type="submit" class="btn btn-primary" name="action" asp-for="Action" value="@StripeSubscriptionsAction.Export">
Export
</button>
<button type="submit" class="btn btn-danger" name="action" asp-for="Action" value="@StripeSubscriptionsAction.BulkCancel">
Bulk Cancel
</button>
</span>
</span>
</nav>
</form>

View File

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

View File

@@ -18,9 +18,9 @@
"css-loader": "7.1.2", "css-loader": "7.1.2",
"expose-loader": "5.0.1", "expose-loader": "5.0.1",
"mini-css-extract-plugin": "2.9.2", "mini-css-extract-plugin": "2.9.2",
"sass": "1.89.2", "sass": "1.93.2",
"sass-loader": "16.0.5", "sass-loader": "16.0.5",
"webpack": "5.99.8", "webpack": "5.102.1",
"webpack-cli": "5.1.4" "webpack-cli": "5.1.4"
} }
}, },
@@ -35,18 +35,14 @@
} }
}, },
"node_modules/@jridgewell/gen-mapping": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.8", "version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.5.0",
"@jridgewell/sourcemap-codec": "^1.4.10",
"@jridgewell/trace-mapping": "^0.3.24" "@jridgewell/trace-mapping": "^0.3.24"
},
"engines": {
"node": ">=6.0.0"
} }
}, },
"node_modules/@jridgewell/resolve-uri": { "node_modules/@jridgewell/resolve-uri": {
@@ -59,20 +55,10 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/@jridgewell/set-array": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz",
"integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/source-map": { "node_modules/@jridgewell/source-map": {
"version": "0.3.6", "version": "0.3.11",
"resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz",
"integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -81,16 +67,16 @@
} }
}, },
"node_modules/@jridgewell/sourcemap-codec": { "node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.0", "version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@jridgewell/trace-mapping": { "node_modules/@jridgewell/trace-mapping": {
"version": "0.3.25", "version": "0.3.30",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz",
"integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", "integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -442,9 +428,9 @@
} }
}, },
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.7", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@@ -456,13 +442,13 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.15.21", "version": "24.3.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.21.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.1.tgz",
"integrity": "sha512-EV/37Td6c+MgKAbkcLG6vqZ2zEYHD7bvSrzqqs2RIhbA6w3x+Dqz8MZM3sP6kGTeLrdoOgKZe+Xja7tUB2DNkQ==", "integrity": "sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~7.10.0"
} }
}, },
"node_modules/@webassemblyjs/ast": { "node_modules/@webassemblyjs/ast": {
@@ -688,11 +674,12 @@
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.14.1", "version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -700,12 +687,26 @@
"node": ">=0.4.0" "node": ">=0.4.0"
} }
}, },
"node_modules/acorn-import-phases": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz",
"integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.13.0"
},
"peerDependencies": {
"acorn": "^8.14.0"
}
},
"node_modules/ajv": { "node_modules/ajv": {
"version": "8.17.1", "version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1", "fast-uri": "^3.0.1",
@@ -748,6 +749,16 @@
"ajv": "^8.8.2" "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": { "node_modules/bootstrap": {
"version": "5.3.6", "version": "5.3.6",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.6.tgz", "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.6.tgz",
@@ -782,9 +793,9 @@
} }
}, },
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.24.5", "version": "4.26.3",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.5.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz",
"integrity": "sha512-FDToo4Wo82hIdgc1CQ+NQD0hEhmpPjrZ3hiUgwgOG6IuTdlpr8jdjyG24P6cNP1yJpTLzS5OcGgSw0xmDU1/Tw==", "integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -801,10 +812,12 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"caniuse-lite": "^1.0.30001716", "baseline-browser-mapping": "^2.8.9",
"electron-to-chromium": "^1.5.149", "caniuse-lite": "^1.0.30001746",
"node-releases": "^2.0.19", "electron-to-chromium": "^1.5.227",
"node-releases": "^2.0.21",
"update-browserslist-db": "^1.1.3" "update-browserslist-db": "^1.1.3"
}, },
"bin": { "bin": {
@@ -822,9 +835,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001718", "version": "1.0.30001751",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001718.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz",
"integrity": "sha512-AflseV1ahcSunK53NfEs9gFWgOEmzr0f+kaMFA4xiLZlr9Hzt7HxcSpIFcnNCUkz6R6dWKa54rUz3HUmI3nVcw==", "integrity": "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -976,16 +989,16 @@
} }
}, },
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.155", "version": "1.5.237",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.155.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.237.tgz",
"integrity": "sha512-ps5KcGGmwL8VaeJlvlDlu4fORQpv3+GIcF5I3f9tUKUlJ/wsysh6HU8P5L1XWRYeXfA0oJd4PyM8ds8zTFf6Ng==", "integrity": "sha512-icUt1NvfhGLar5lSWH3tHNzablaA5js3HVHacQimfP8ViEBOQv+L7DKEuHdbTZ0SKCO1ogTJTIL1Gwk9S6Qvcg==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/enhanced-resolve": { "node_modules/enhanced-resolve": {
"version": "5.18.1", "version": "5.18.3",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
"integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==", "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -1108,9 +1121,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-uri": { "node_modules/fast-uri": {
"version": "3.0.6", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
"integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==", "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -1242,9 +1255,9 @@
} }
}, },
"node_modules/immutable": { "node_modules/immutable": {
"version": "5.1.2", "version": "5.1.3",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.2.tgz", "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.3.tgz",
"integrity": "sha512-qHKXW1q6liAk1Oys6umoaZbDRqjcjgSrbnrifHsfsttza7zcvRAsL7mMV6xWcyhwQy7Xj5v4hhbr6b+iDYwlmQ==", "integrity": "sha512-+chQdDfvscSF1SJqv2gn4SRO2ZyS3xL3r7IW/wWEEzrzLisnOlKiQu5ytC/BVNcS15C39WT2Hg/bjKjDMcu+zg==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@@ -1529,9 +1542,9 @@
"optional": true "optional": true
}, },
"node_modules/node-releases": { "node_modules/node-releases": {
"version": "2.0.19", "version": "2.0.26",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.26.tgz",
"integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", "integrity": "sha512-S2M9YimhSjBSvYnlr5/+umAnPHE++ODwt5e2Ij6FoX45HA/s4vHdkDx1eax2pAPeAOqu4s9b7ppahsyEFdVqQA==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@@ -1636,9 +1649,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.3", "version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -1655,8 +1668,9 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.8", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
"source-map-js": "^1.2.1" "source-map-js": "^1.2.1"
}, },
@@ -1861,11 +1875,12 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/sass": { "node_modules/sass": {
"version": "1.89.2", "version": "1.93.2",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.89.2.tgz", "resolved": "https://registry.npmjs.org/sass/-/sass-1.93.2.tgz",
"integrity": "sha512-xCmtksBKd/jdJ9Bt9p7nPKiuqrlBMBuuGkQlkhZjjQk3Ty48lv93k5Dq6OPkKt4XwxDJ7tvlfrTa1MPA9bf+QA==", "integrity": "sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"chokidar": "^4.0.0", "chokidar": "^4.0.0",
"immutable": "^5.0.2", "immutable": "^5.0.2",
@@ -1923,9 +1938,9 @@
} }
}, },
"node_modules/schema-utils": { "node_modules/schema-utils": {
"version": "4.3.2", "version": "4.3.3",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz",
"integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==", "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -2062,24 +2077,28 @@
} }
}, },
"node_modules/tapable": { "node_modules/tapable": {
"version": "2.2.2", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.2.tgz", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
"integrity": "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==", "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6" "node": ">=6"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
} }
}, },
"node_modules/terser": { "node_modules/terser": {
"version": "5.39.2", "version": "5.44.0",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.39.2.tgz", "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.0.tgz",
"integrity": "sha512-yEPUmWve+VA78bI71BW70Dh0TuV4HHd+I5SHOAfS1+QBOmvmCiiffgjR8ryyEd3KIfvPGFqoADt8LdQ6XpXIvg==", "integrity": "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==",
"dev": true, "dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"dependencies": { "dependencies": {
"@jridgewell/source-map": "^0.3.3", "@jridgewell/source-map": "^0.3.3",
"acorn": "^8.14.0", "acorn": "^8.15.0",
"commander": "^2.20.0", "commander": "^2.20.0",
"source-map-support": "~0.5.20" "source-map-support": "~0.5.20"
}, },
@@ -2148,9 +2167,9 @@
} }
}, },
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "6.21.0", "version": "7.10.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@@ -2207,22 +2226,24 @@
} }
}, },
"node_modules/webpack": { "node_modules/webpack": {
"version": "5.99.8", "version": "5.102.1",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.99.8.tgz", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.102.1.tgz",
"integrity": "sha512-lQ3CPiSTpfOnrEGeXDwoq5hIGzSjmwD72GdfVzF7CQAI7t47rJG9eDWvcEkEn3CUQymAElVvDg3YNTlCYj+qUQ==", "integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/eslint-scope": "^3.7.7", "@types/eslint-scope": "^3.7.7",
"@types/estree": "^1.0.6", "@types/estree": "^1.0.8",
"@types/json-schema": "^7.0.15", "@types/json-schema": "^7.0.15",
"@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/ast": "^1.14.1",
"@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1",
"@webassemblyjs/wasm-parser": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1",
"acorn": "^8.14.0", "acorn": "^8.15.0",
"browserslist": "^4.24.0", "acorn-import-phases": "^1.0.3",
"browserslist": "^4.26.3",
"chrome-trace-event": "^1.0.2", "chrome-trace-event": "^1.0.2",
"enhanced-resolve": "^5.17.1", "enhanced-resolve": "^5.17.3",
"es-module-lexer": "^1.2.1", "es-module-lexer": "^1.2.1",
"eslint-scope": "5.1.1", "eslint-scope": "5.1.1",
"events": "^3.2.0", "events": "^3.2.0",
@@ -2232,11 +2253,11 @@
"loader-runner": "^4.2.0", "loader-runner": "^4.2.0",
"mime-types": "^2.1.27", "mime-types": "^2.1.27",
"neo-async": "^2.6.2", "neo-async": "^2.6.2",
"schema-utils": "^4.3.2", "schema-utils": "^4.3.3",
"tapable": "^2.1.1", "tapable": "^2.3.0",
"terser-webpack-plugin": "^5.3.11", "terser-webpack-plugin": "^5.3.11",
"watchpack": "^2.4.1", "watchpack": "^2.4.4",
"webpack-sources": "^3.2.3" "webpack-sources": "^3.3.3"
}, },
"bin": { "bin": {
"webpack": "bin/webpack.js" "webpack": "bin/webpack.js"
@@ -2260,6 +2281,7 @@
"integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@discoveryjs/json-ext": "^0.5.0", "@discoveryjs/json-ext": "^0.5.0",
"@webpack-cli/configtest": "^2.1.1", "@webpack-cli/configtest": "^2.1.1",
@@ -2326,9 +2348,9 @@
} }
}, },
"node_modules/webpack-sources": { "node_modules/webpack-sources": {
"version": "3.2.3", "version": "3.3.3",
"resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.3.tgz",
"integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", "integrity": "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {

View File

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

View File

@@ -0,0 +1,22 @@
using Bit.Api.Vault.AuthorizationHandlers.Collections;
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace Bit.Api.AdminConsole.Authorization;
public static class AuthorizationHandlerCollectionExtensions
{
public static void AddAdminConsoleAuthorizationHandlers(this IServiceCollection services)
{
services.TryAddScoped<IOrganizationContext, OrganizationContext>();
services.TryAddEnumerable([
ServiceDescriptor.Scoped<IAuthorizationHandler, BulkCollectionAuthorizationHandler>(),
ServiceDescriptor.Scoped<IAuthorizationHandler, CollectionAuthorizationHandler>(),
ServiceDescriptor.Scoped<IAuthorizationHandler, GroupAuthorizationHandler>(),
ServiceDescriptor.Scoped<IAuthorizationHandler, OrganizationRequirementHandler>(),
ServiceDescriptor.Scoped<IAuthorizationHandler, RecoverAccountAuthorizationHandler>(),
]);
}
}

View File

@@ -1,6 +1,4 @@
#nullable enable using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Models.Data.Provider; using Bit.Core.AdminConsole.Models.Data.Provider;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;

View File

@@ -1,9 +1,7 @@
#nullable enable using System.Security.Claims;
using Bit.Core.Auth.Identity;
using System.Security.Claims;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Identity;
using Bit.Core.Models.Data; using Bit.Core.Models.Data;
namespace Bit.Api.AdminConsole.Authorization; namespace Bit.Api.AdminConsole.Authorization;

View File

@@ -0,0 +1,84 @@
using System.Security.Claims;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Models.Data.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Context;
using Bit.Core.Services;
// Note: do not move this into Core! See remarks below.
namespace Bit.Api.AdminConsole.Authorization;
/// <summary>
/// Provides information about a user's membership or provider relationship with an organization.
/// Used for authorization decisions in the API layer, usually called by a controller or authorization handler or attribute.
/// </summary>
/// <remarks>
/// This is intended to deprecate organization-related methods in <see cref="ICurrentContext"/>.
/// It should remain in the API layer (not Core) because it is closely tied to user claims and authentication.
/// </remarks>
public interface IOrganizationContext
{
/// <summary>
/// Parses the provided <see cref="ClaimsPrincipal"/> for claims relating to the specified organization.
/// A user will have organization claims if they are a confirmed member of the organization.
/// </summary>
/// <param name="user">The claims for the user.</param>
/// <param name="organizationId">The organization to extract claims for.</param>
/// <returns>
/// A <see cref="CurrentContextOrganization"/> representing the user's claims for the organization,
/// or null if the user has no claims.
/// </returns>
public CurrentContextOrganization? GetOrganizationClaims(ClaimsPrincipal user, Guid organizationId);
/// <summary>
/// Used to determine whether the user is a ProviderUser for the specified organization.
/// </summary>
/// <param name="user">The claims for the user.</param>
/// <param name="organizationId">The organization to check the provider relationship for.</param>
/// <returns>True if the user is a ProviderUser for the specified organization, otherwise false.</returns>
/// <remarks>
/// This requires a database call, but the results are cached for the lifetime of the service instance.
/// Try to check purely claims-based sources of authorization first (such as organization membership with
/// <see cref="GetOrganizationClaims"/>) to avoid unnecessary database calls.
/// </remarks>
public Task<bool> IsProviderUserForOrganization(ClaimsPrincipal user, Guid organizationId);
}
public class OrganizationContext(
IUserService userService,
IProviderUserRepository providerUserRepository) : IOrganizationContext
{
public const string NoUserIdError = "This method should only be called on the private api with a logged in user.";
/// <summary>
/// Caches provider relationships by UserId.
/// In practice this should only have 1 entry (for the current user), but this approach ensures that a mix-up
/// between users cannot occur if <see cref="IsProviderUserForOrganization"/> is called with a different
/// ClaimsPrincipal for any reason.
/// </summary>
private readonly Dictionary<Guid, IEnumerable<ProviderUserOrganizationDetails>> _providerUserOrganizationsCache = new();
public CurrentContextOrganization? GetOrganizationClaims(ClaimsPrincipal user, Guid organizationId)
{
return user.GetCurrentContextOrganization(organizationId);
}
public async Task<bool> IsProviderUserForOrganization(ClaimsPrincipal user, Guid organizationId)
{
var userId = userService.GetProperUserId(user);
if (!userId.HasValue)
{
throw new InvalidOperationException(NoUserIdError);
}
if (!_providerUserOrganizationsCache.TryGetValue(userId.Value, out var providerUserOrganizations))
{
providerUserOrganizations =
await providerUserRepository.GetManyOrganizationDetailsByUserAsync(userId.Value,
ProviderUserStatusType.Confirmed);
providerUserOrganizations = providerUserOrganizations.ToList();
_providerUserOrganizationsCache[userId.Value] = providerUserOrganizations;
}
return providerUserOrganizations.Any(o => o.OrganizationId == organizationId);
}
}

View File

@@ -0,0 +1,110 @@
using System.Security.Claims;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Microsoft.AspNetCore.Authorization;
namespace Bit.Api.AdminConsole.Authorization;
/// <summary>
/// An authorization requirement for recovering an organization member's account.
/// </summary>
/// <remarks>
/// Note: this is different to simply being able to manage account recovery. The user must be recovering
/// a member who has equal or lesser permissions than them.
/// </remarks>
public class RecoverAccountAuthorizationRequirement : IAuthorizationRequirement;
/// <summary>
/// Authorizes members and providers to recover a target OrganizationUser's account.
/// </summary>
/// <remarks>
/// This prevents privilege escalation by ensuring that a user cannot recover the account of
/// another user with a higher role or with provider membership.
/// </remarks>
public class RecoverAccountAuthorizationHandler(
IOrganizationContext organizationContext,
ICurrentContext currentContext,
IProviderUserRepository providerUserRepository)
: AuthorizationHandler<RecoverAccountAuthorizationRequirement, OrganizationUser>
{
public const string FailureReason = "You are not permitted to recover this user's account.";
public const string ProviderFailureReason = "You are not permitted to recover a Provider member's account.";
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context,
RecoverAccountAuthorizationRequirement requirement,
OrganizationUser targetOrganizationUser)
{
// Step 1: check that the User has permissions with respect to the organization.
// This may come from their role in the organization or their provider relationship.
var canRecoverOrganizationMember =
AuthorizeMember(context.User, targetOrganizationUser) ||
await AuthorizeProviderAsync(context.User, targetOrganizationUser);
if (!canRecoverOrganizationMember)
{
context.Fail(new AuthorizationFailureReason(this, FailureReason));
return;
}
// Step 2: check that the User has permissions with respect to any provider the target user is a member of.
// This prevents an organization admin performing privilege escalation into an unrelated provider.
var canRecoverProviderMember = await CanRecoverProviderAsync(targetOrganizationUser);
if (!canRecoverProviderMember)
{
context.Fail(new AuthorizationFailureReason(this, ProviderFailureReason));
return;
}
context.Succeed(requirement);
}
private async Task<bool> AuthorizeProviderAsync(ClaimsPrincipal currentUser, OrganizationUser targetOrganizationUser)
{
return await organizationContext.IsProviderUserForOrganization(currentUser, targetOrganizationUser.OrganizationId);
}
private bool AuthorizeMember(ClaimsPrincipal currentUser, OrganizationUser targetOrganizationUser)
{
var currentContextOrganization = organizationContext.GetOrganizationClaims(currentUser, targetOrganizationUser.OrganizationId);
if (currentContextOrganization == null)
{
return false;
}
// Current user must have equal or greater permissions than the user account being recovered
var authorized = targetOrganizationUser.Type switch
{
OrganizationUserType.Owner => currentContextOrganization.Type is OrganizationUserType.Owner,
OrganizationUserType.Admin => currentContextOrganization.Type is OrganizationUserType.Owner or OrganizationUserType.Admin,
_ => currentContextOrganization is
{ Type: OrganizationUserType.Owner or OrganizationUserType.Admin }
or { Type: OrganizationUserType.Custom, Permissions.ManageResetPassword: true }
};
return authorized;
}
private async Task<bool> CanRecoverProviderAsync(OrganizationUser targetOrganizationUser)
{
if (!targetOrganizationUser.UserId.HasValue)
{
// If an OrganizationUser is not linked to a User then it can't be linked to a Provider either.
// This is invalid but does not pose a privilege escalation risk. Return early and let the command
// handle the invalid input.
return true;
}
var targetUserProviderUsers =
await providerUserRepository.GetManyByUserAsync(targetOrganizationUser.UserId.Value);
// If the target user belongs to any provider that the current user is not a member of,
// deny the action to prevent privilege escalation from organization to provider.
// Note: we do not expect that a user is a member of more than 1 provider, but there is also no guarantee
// against it; this returns a sequence, so we handle the possibility.
var authorized = targetUserProviderUsers.All(providerUser => currentContext.ProviderUser(providerUser.ProviderId));
return authorized;
}
}

View File

@@ -0,0 +1,26 @@
using Bit.Core.AdminConsole.Utilities.v2;
using Bit.Core.AdminConsole.Utilities.v2.Results;
using Bit.Core.Models.Api;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.AdminConsole.Controllers;
public abstract class BaseAdminConsoleController : Controller
{
protected static IResult Handle(CommandResult commandResult) =>
commandResult.Match<IResult>(
error => error switch
{
BadRequestError badRequest => TypedResults.BadRequest(new ErrorResponseModel(badRequest.Message)),
NotFoundError notFound => TypedResults.NotFound(new ErrorResponseModel(notFound.Message)),
InternalError internalError => TypedResults.Json(
new ErrorResponseModel(internalError.Message),
statusCode: StatusCodes.Status500InternalServerError),
_ => TypedResults.Json(
new ErrorResponseModel(error.Message),
statusCode: StatusCodes.Status500InternalServerError
)
},
_ => TypedResults.NoContent()
);
}

View File

@@ -3,6 +3,7 @@
using Bit.Api.Models.Response; using Bit.Api.Models.Response;
using Bit.Api.Utilities; using Bit.Api.Utilities;
using Bit.Api.Utilities.DiagnosticTools;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Enums; using Bit.Core.Enums;
@@ -30,16 +31,22 @@ public class EventsController : Controller
private readonly ICurrentContext _currentContext; private readonly ICurrentContext _currentContext;
private readonly ISecretRepository _secretRepository; private readonly ISecretRepository _secretRepository;
private readonly IProjectRepository _projectRepository; private readonly IProjectRepository _projectRepository;
private readonly IServiceAccountRepository _serviceAccountRepository;
private readonly ILogger<EventsController> _logger;
private readonly IFeatureService _featureService;
public EventsController(
IUserService userService, public EventsController(IUserService userService,
ICipherRepository cipherRepository, ICipherRepository cipherRepository,
IOrganizationUserRepository organizationUserRepository, IOrganizationUserRepository organizationUserRepository,
IProviderUserRepository providerUserRepository, IProviderUserRepository providerUserRepository,
IEventRepository eventRepository, IEventRepository eventRepository,
ICurrentContext currentContext, ICurrentContext currentContext,
ISecretRepository secretRepository, ISecretRepository secretRepository,
IProjectRepository projectRepository) IProjectRepository projectRepository,
IServiceAccountRepository serviceAccountRepository,
ILogger<EventsController> logger,
IFeatureService featureService)
{ {
_userService = userService; _userService = userService;
_cipherRepository = cipherRepository; _cipherRepository = cipherRepository;
@@ -49,6 +56,9 @@ public class EventsController : Controller
_currentContext = currentContext; _currentContext = currentContext;
_secretRepository = secretRepository; _secretRepository = secretRepository;
_projectRepository = projectRepository; _projectRepository = projectRepository;
_serviceAccountRepository = serviceAccountRepository;
_logger = logger;
_featureService = featureService;
} }
[HttpGet("")] [HttpGet("")]
@@ -110,6 +120,9 @@ public class EventsController : Controller
var result = await _eventRepository.GetManyByOrganizationAsync(orgId, dateRange.Item1, dateRange.Item2, var result = await _eventRepository.GetManyByOrganizationAsync(orgId, dateRange.Item1, dateRange.Item2,
new PageOptions { ContinuationToken = continuationToken }); new PageOptions { ContinuationToken = continuationToken });
var responses = result.Data.Select(e => new EventResponseModel(e)); var responses = result.Data.Select(e => new EventResponseModel(e));
_logger.LogAggregateData(_featureService, orgId, responses, continuationToken, start, end);
return new ListResponseModel<EventResponseModel>(responses, result.ContinuationToken); return new ListResponseModel<EventResponseModel>(responses, result.ContinuationToken);
} }
@@ -184,6 +197,57 @@ public class EventsController : Controller
return new ListResponseModel<EventResponseModel>(responses, result.ContinuationToken); return new ListResponseModel<EventResponseModel>(responses, result.ContinuationToken);
} }
[HttpGet("~/organization/{orgId}/service-account/{id}/events")]
public async Task<ListResponseModel<EventResponseModel>> GetServiceAccounts(
Guid orgId,
Guid id,
[FromQuery] DateTime? start = null,
[FromQuery] DateTime? end = null,
[FromQuery] string continuationToken = null)
{
if (id == Guid.Empty || orgId == Guid.Empty)
{
throw new NotFoundException();
}
var serviceAccount = await GetServiceAccount(id, orgId);
var org = _currentContext.GetOrganization(orgId);
if (org == null || !await _currentContext.AccessEventLogs(org.Id))
{
throw new NotFoundException();
}
var (fromDate, toDate) = ApiHelpers.GetDateRange(start, end);
var result = await _eventRepository.GetManyByOrganizationServiceAccountAsync(
serviceAccount.OrganizationId,
serviceAccount.Id,
fromDate,
toDate,
new PageOptions { ContinuationToken = continuationToken });
var responses = result.Data.Select(e => new EventResponseModel(e));
return new ListResponseModel<EventResponseModel>(responses, result.ContinuationToken);
}
[ApiExplorerSettings(IgnoreApi = true)]
private async Task<ServiceAccount> GetServiceAccount(Guid serviceAccountId, Guid orgId)
{
var serviceAccount = await _serviceAccountRepository.GetByIdAsync(serviceAccountId);
if (serviceAccount != null)
{
return serviceAccount;
}
var fallbackServiceAccount = new ServiceAccount
{
Id = serviceAccountId,
OrganizationId = orgId
};
return fallbackServiceAccount;
}
[HttpGet("~/organizations/{orgId}/users/{id}/events")] [HttpGet("~/organizations/{orgId}/users/{id}/events")]
public async Task<ListResponseModel<EventResponseModel>> GetOrganizationUser(string orgId, string id, public async Task<ListResponseModel<EventResponseModel>> GetOrganizationUser(string orgId, string id,
[FromQuery] DateTime? start = null, [FromQuery] DateTime? end = null, [FromQuery] string continuationToken = null) [FromQuery] DateTime? start = null, [FromQuery] DateTime? end = null, [FromQuery] string continuationToken = null)

View File

@@ -163,7 +163,6 @@ public class GroupsController : Controller
} }
[HttpPut("{id}")] [HttpPut("{id}")]
[HttpPost("{id}")]
public async Task<GroupResponseModel> Put(Guid orgId, Guid id, [FromBody] GroupRequestModel model) public async Task<GroupResponseModel> Put(Guid orgId, Guid id, [FromBody] GroupRequestModel model)
{ {
if (!await _currentContext.ManageGroups(orgId)) if (!await _currentContext.ManageGroups(orgId))
@@ -237,8 +236,14 @@ public class GroupsController : Controller
return new GroupResponseModel(group); return new GroupResponseModel(group);
} }
[HttpPost("{id}")]
[Obsolete("This endpoint is deprecated. Use PUT method instead")]
public async Task<GroupResponseModel> PostPut(Guid orgId, Guid id, [FromBody] GroupRequestModel model)
{
return await Put(orgId, id, model);
}
[HttpDelete("{id}")] [HttpDelete("{id}")]
[HttpPost("{id}/delete")]
public async Task Delete(string orgId, string id) public async Task Delete(string orgId, string id)
{ {
var group = await _groupRepository.GetByIdAsync(new Guid(id)); var group = await _groupRepository.GetByIdAsync(new Guid(id));
@@ -250,8 +255,14 @@ public class GroupsController : Controller
await _deleteGroupCommand.DeleteAsync(group); await _deleteGroupCommand.DeleteAsync(group);
} }
[HttpPost("{id}/delete")]
[Obsolete("This endpoint is deprecated. Use DELETE method instead")]
public async Task PostDelete(string orgId, string id)
{
await Delete(orgId, id);
}
[HttpDelete("")] [HttpDelete("")]
[HttpPost("delete")]
public async Task BulkDelete([FromBody] GroupBulkRequestModel model) public async Task BulkDelete([FromBody] GroupBulkRequestModel model)
{ {
var groups = await _groupRepository.GetManyByManyIds(model.Ids); var groups = await _groupRepository.GetManyByManyIds(model.Ids);
@@ -267,9 +278,15 @@ public class GroupsController : Controller
await _deleteGroupCommand.DeleteManyAsync(groups); await _deleteGroupCommand.DeleteManyAsync(groups);
} }
[HttpPost("delete")]
[Obsolete("This endpoint is deprecated. Use DELETE method instead")]
public async Task PostBulkDelete([FromBody] GroupBulkRequestModel model)
{
await BulkDelete(model);
}
[HttpDelete("{id}/user/{orgUserId}")] [HttpDelete("{id}/user/{orgUserId}")]
[HttpPost("{id}/delete-user/{orgUserId}")] public async Task DeleteUser(string orgId, string id, string orgUserId)
public async Task Delete(string orgId, string id, string orgUserId)
{ {
var group = await _groupRepository.GetByIdAsync(new Guid(id)); var group = await _groupRepository.GetByIdAsync(new Guid(id));
if (group == null || !await _currentContext.ManageGroups(group.OrganizationId)) if (group == null || !await _currentContext.ManageGroups(group.OrganizationId))
@@ -279,4 +296,11 @@ public class GroupsController : Controller
await _groupService.DeleteUserAsync(group, new Guid(orgUserId)); await _groupService.DeleteUserAsync(group, new Guid(orgUserId));
} }
[HttpPost("{id}/delete-user/{orgUserId}")]
[Obsolete("This endpoint is deprecated. Use DELETE method instead")]
public async Task PostDeleteUser(string orgId, string id, string orgUserId)
{
await DeleteUser(orgId, id, orgUserId);
}
} }

View File

@@ -140,7 +140,6 @@ public class OrganizationConnectionsController : Controller
} }
[HttpDelete("{organizationConnectionId}")] [HttpDelete("{organizationConnectionId}")]
[HttpPost("{organizationConnectionId}/delete")]
public async Task DeleteConnection(Guid organizationConnectionId) public async Task DeleteConnection(Guid organizationConnectionId)
{ {
var connection = await _organizationConnectionRepository.GetByIdAsync(organizationConnectionId); var connection = await _organizationConnectionRepository.GetByIdAsync(organizationConnectionId);
@@ -158,6 +157,13 @@ public class OrganizationConnectionsController : Controller
await _deleteOrganizationConnectionCommand.DeleteAsync(connection); await _deleteOrganizationConnectionCommand.DeleteAsync(connection);
} }
[HttpPost("{organizationConnectionId}/delete")]
[Obsolete("This endpoint is deprecated. Use DELETE method instead")]
public async Task PostDeleteConnection(Guid organizationConnectionId)
{
await DeleteConnection(organizationConnectionId);
}
private async Task<ICollection<OrganizationConnection>> GetConnectionsAsync(Guid organizationId, OrganizationConnectionType type) => private async Task<ICollection<OrganizationConnection>> GetConnectionsAsync(Guid organizationId, OrganizationConnectionType type) =>
await _organizationConnectionRepository.GetByOrganizationIdTypeAsync(organizationId, type); await _organizationConnectionRepository.GetByOrganizationIdTypeAsync(organizationId, type);

View File

@@ -46,7 +46,7 @@ public class OrganizationDomainController : Controller
} }
[HttpGet("{orgId}/domain")] [HttpGet("{orgId}/domain")]
public async Task<ListResponseModel<OrganizationDomainResponseModel>> Get(Guid orgId) public async Task<ListResponseModel<OrganizationDomainResponseModel>> GetAll(Guid orgId)
{ {
await ValidateOrganizationAccessAsync(orgId); await ValidateOrganizationAccessAsync(orgId);
@@ -105,7 +105,6 @@ public class OrganizationDomainController : Controller
} }
[HttpDelete("{orgId}/domain/{id}")] [HttpDelete("{orgId}/domain/{id}")]
[HttpPost("{orgId}/domain/{id}/remove")]
public async Task RemoveDomain(Guid orgId, Guid id) public async Task RemoveDomain(Guid orgId, Guid id)
{ {
await ValidateOrganizationAccessAsync(orgId); await ValidateOrganizationAccessAsync(orgId);
@@ -119,6 +118,13 @@ public class OrganizationDomainController : Controller
await _deleteOrganizationDomainCommand.DeleteAsync(domain); await _deleteOrganizationDomainCommand.DeleteAsync(domain);
} }
[HttpPost("{orgId}/domain/{id}/remove")]
[Obsolete("This endpoint is deprecated. Use DELETE method instead")]
public async Task PostRemoveDomain(Guid orgId, Guid id)
{
await RemoveDomain(orgId, id);
}
[AllowAnonymous] [AllowAnonymous]
[HttpPost("domain/sso/details")] // must be post to accept email cleanly [HttpPost("domain/sso/details")] // must be post to accept email cleanly
public async Task<OrganizationDomainSsoDetailsResponseModel> GetOrgDomainSsoDetails( public async Task<OrganizationDomainSsoDetailsResponseModel> GetOrgDomainSsoDetails(

View File

@@ -1,16 +1,13 @@
using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Core;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.AdminConsole.Controllers; namespace Bit.Api.AdminConsole.Controllers;
[RequireFeature(FeatureFlagKeys.EventBasedOrganizationIntegrations)]
[Route("organizations/{organizationId:guid}/integrations/{integrationId:guid}/configurations")] [Route("organizations/{organizationId:guid}/integrations/{integrationId:guid}/configurations")]
[Authorize("Application")] [Authorize("Application")]
public class OrganizationIntegrationConfigurationController( public class OrganizationIntegrationConfigurationController(
@@ -98,7 +95,6 @@ public class OrganizationIntegrationConfigurationController(
} }
[HttpDelete("{configurationId:guid}")] [HttpDelete("{configurationId:guid}")]
[HttpPost("{configurationId:guid}/delete")]
public async Task DeleteAsync(Guid organizationId, Guid integrationId, Guid configurationId) public async Task DeleteAsync(Guid organizationId, Guid integrationId, Guid configurationId)
{ {
if (!await HasPermission(organizationId)) if (!await HasPermission(organizationId))
@@ -120,6 +116,13 @@ public class OrganizationIntegrationConfigurationController(
await integrationConfigurationRepository.DeleteAsync(configuration); await integrationConfigurationRepository.DeleteAsync(configuration);
} }
[HttpPost("{configurationId:guid}/delete")]
[Obsolete("This endpoint is deprecated. Use DELETE method instead")]
public async Task PostDeleteAsync(Guid organizationId, Guid integrationId, Guid configurationId)
{
await DeleteAsync(organizationId, integrationId, configurationId);
}
private async Task<bool> HasPermission(Guid organizationId) private async Task<bool> HasPermission(Guid organizationId)
{ {
return await currentContext.OrganizationOwner(organizationId); return await currentContext.OrganizationOwner(organizationId);

View File

@@ -1,18 +1,13 @@
using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Core;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
#nullable enable
namespace Bit.Api.AdminConsole.Controllers; namespace Bit.Api.AdminConsole.Controllers;
[RequireFeature(FeatureFlagKeys.EventBasedOrganizationIntegrations)]
[Route("organizations/{organizationId:guid}/integrations")] [Route("organizations/{organizationId:guid}/integrations")]
[Authorize("Application")] [Authorize("Application")]
public class OrganizationIntegrationController( public class OrganizationIntegrationController(
@@ -64,7 +59,6 @@ public class OrganizationIntegrationController(
} }
[HttpDelete("{integrationId:guid}")] [HttpDelete("{integrationId:guid}")]
[HttpPost("{integrationId:guid}/delete")]
public async Task DeleteAsync(Guid organizationId, Guid integrationId) public async Task DeleteAsync(Guid organizationId, Guid integrationId)
{ {
if (!await HasPermission(organizationId)) if (!await HasPermission(organizationId))
@@ -81,6 +75,13 @@ public class OrganizationIntegrationController(
await integrationRepository.DeleteAsync(integration); await integrationRepository.DeleteAsync(integration);
} }
[HttpPost("{integrationId:guid}/delete")]
[Obsolete("This endpoint is deprecated. Use DELETE method instead")]
public async Task PostDeleteAsync(Guid organizationId, Guid integrationId)
{
await DeleteAsync(organizationId, integrationId);
}
private async Task<bool> HasPermission(Guid organizationId) private async Task<bool> HasPermission(Guid organizationId)
{ {
return await currentContext.OrganizationOwner(organizationId); return await currentContext.OrganizationOwner(organizationId);

View File

@@ -1,4 +1,5 @@
// FIXME: Update this file to be null safe and then delete the line below // FIXME: Update this file to be null safe and then delete the line below
// NOTE: This file is partially migrated to nullable reference types. Remove inline #nullable directives when addressing the FIXME.
#nullable disable #nullable disable
using Bit.Api.AdminConsole.Authorization; using Bit.Api.AdminConsole.Authorization;
@@ -10,19 +11,25 @@ using Bit.Api.Models.Response;
using Bit.Api.Vault.AuthorizationHandlers.Collections; using Bit.Api.Vault.AuthorizationHandlers.Collections;
using Bit.Core; using Bit.Core;
using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.AccountRecovery;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Utilities.v2;
using Bit.Core.Auth.Enums; using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Repositories; using Bit.Core.Auth.Repositories;
using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Pricing;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Models.Api;
using Bit.Core.Models.Business; using Bit.Core.Models.Business;
using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
@@ -39,7 +46,7 @@ namespace Bit.Api.AdminConsole.Controllers;
[Route("organizations/{orgId}/users")] [Route("organizations/{orgId}/users")]
[Authorize("Application")] [Authorize("Application")]
public class OrganizationUsersController : Controller public class OrganizationUsersController : BaseAdminConsoleController
{ {
private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationRepository _organizationRepository;
private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IOrganizationUserRepository _organizationUserRepository;
@@ -64,10 +71,12 @@ public class OrganizationUsersController : Controller
private readonly IFeatureService _featureService; private readonly IFeatureService _featureService;
private readonly IPricingClient _pricingClient; private readonly IPricingClient _pricingClient;
private readonly IResendOrganizationInviteCommand _resendOrganizationInviteCommand; private readonly IResendOrganizationInviteCommand _resendOrganizationInviteCommand;
private readonly IAutomaticallyConfirmOrganizationUserCommand _automaticallyConfirmOrganizationUserCommand;
private readonly IConfirmOrganizationUserCommand _confirmOrganizationUserCommand; private readonly IConfirmOrganizationUserCommand _confirmOrganizationUserCommand;
private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand; private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand;
private readonly IInitPendingOrganizationCommand _initPendingOrganizationCommand; private readonly IInitPendingOrganizationCommand _initPendingOrganizationCommand;
private readonly IRevokeOrganizationUserCommand _revokeOrganizationUserCommand; private readonly IRevokeOrganizationUserCommand _revokeOrganizationUserCommand;
private readonly IAdminRecoverAccountCommand _adminRecoverAccountCommand;
public OrganizationUsersController(IOrganizationRepository organizationRepository, public OrganizationUsersController(IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository, IOrganizationUserRepository organizationUserRepository,
@@ -95,7 +104,9 @@ public class OrganizationUsersController : Controller
IRestoreOrganizationUserCommand restoreOrganizationUserCommand, IRestoreOrganizationUserCommand restoreOrganizationUserCommand,
IInitPendingOrganizationCommand initPendingOrganizationCommand, IInitPendingOrganizationCommand initPendingOrganizationCommand,
IRevokeOrganizationUserCommand revokeOrganizationUserCommand, IRevokeOrganizationUserCommand revokeOrganizationUserCommand,
IResendOrganizationInviteCommand resendOrganizationInviteCommand) IResendOrganizationInviteCommand resendOrganizationInviteCommand,
IAdminRecoverAccountCommand adminRecoverAccountCommand,
IAutomaticallyConfirmOrganizationUserCommand automaticallyConfirmOrganizationUserCommand)
{ {
_organizationRepository = organizationRepository; _organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository; _organizationUserRepository = organizationUserRepository;
@@ -120,10 +131,12 @@ public class OrganizationUsersController : Controller
_featureService = featureService; _featureService = featureService;
_pricingClient = pricingClient; _pricingClient = pricingClient;
_resendOrganizationInviteCommand = resendOrganizationInviteCommand; _resendOrganizationInviteCommand = resendOrganizationInviteCommand;
_automaticallyConfirmOrganizationUserCommand = automaticallyConfirmOrganizationUserCommand;
_confirmOrganizationUserCommand = confirmOrganizationUserCommand; _confirmOrganizationUserCommand = confirmOrganizationUserCommand;
_restoreOrganizationUserCommand = restoreOrganizationUserCommand; _restoreOrganizationUserCommand = restoreOrganizationUserCommand;
_initPendingOrganizationCommand = initPendingOrganizationCommand; _initPendingOrganizationCommand = initPendingOrganizationCommand;
_revokeOrganizationUserCommand = revokeOrganizationUserCommand; _revokeOrganizationUserCommand = revokeOrganizationUserCommand;
_adminRecoverAccountCommand = adminRecoverAccountCommand;
} }
[HttpGet("{id}")] [HttpGet("{id}")]
@@ -167,7 +180,7 @@ public class OrganizationUsersController : Controller
} }
[HttpGet("")] [HttpGet("")]
public async Task<ListResponseModel<OrganizationUserUserDetailsResponseModel>> Get(Guid orgId, bool includeGroups = false, bool includeCollections = false) public async Task<ListResponseModel<OrganizationUserUserDetailsResponseModel>> GetAll(Guid orgId, bool includeGroups = false, bool includeCollections = false)
{ {
var request = new OrganizationUserUserDetailsQueryRequest var request = new OrganizationUserUserDetailsQueryRequest
{ {
@@ -360,7 +373,6 @@ public class OrganizationUsersController : Controller
} }
[HttpPut("{id}")] [HttpPut("{id}")]
[HttpPost("{id}")]
[Authorize<ManageUsersRequirement>] [Authorize<ManageUsersRequirement>]
public async Task Put(Guid orgId, Guid id, [FromBody] OrganizationUserUpdateRequestModel model) public async Task Put(Guid orgId, Guid id, [FromBody] OrganizationUserUpdateRequestModel model)
{ {
@@ -436,6 +448,14 @@ public class OrganizationUsersController : Controller
collectionsToSave, groupsToSave); collectionsToSave, groupsToSave);
} }
[HttpPost("{id}")]
[Obsolete("This endpoint is deprecated. Use PUT method instead")]
[Authorize<ManageUsersRequirement>]
public async Task PostPut(Guid orgId, Guid id, [FromBody] OrganizationUserUpdateRequestModel model)
{
await Put(orgId, id, model);
}
[HttpPut("{userId}/reset-password-enrollment")] [HttpPut("{userId}/reset-password-enrollment")]
public async Task PutResetPasswordEnrollment(Guid orgId, Guid userId, [FromBody] OrganizationUserResetPasswordEnrollmentRequestModel model) public async Task PutResetPasswordEnrollment(Guid orgId, Guid userId, [FromBody] OrganizationUserResetPasswordEnrollmentRequestModel model)
{ {
@@ -465,21 +485,27 @@ public class OrganizationUsersController : Controller
[HttpPut("{id}/reset-password")] [HttpPut("{id}/reset-password")]
[Authorize<ManageAccountRecoveryRequirement>] [Authorize<ManageAccountRecoveryRequirement>]
public async Task PutResetPassword(Guid orgId, Guid id, [FromBody] OrganizationUserResetPasswordRequestModel model) public async Task<IResult> PutResetPassword(Guid orgId, Guid id, [FromBody] OrganizationUserResetPasswordRequestModel model)
{ {
if (_featureService.IsEnabled(FeatureFlagKeys.AccountRecoveryCommand))
{
// TODO: remove legacy implementation after feature flag is enabled.
return await PutResetPasswordNew(orgId, id, model);
}
// Get the users role, since provider users aren't a member of the organization we use the owner check // Get the users role, since provider users aren't a member of the organization we use the owner check
var orgUserType = await _currentContext.OrganizationOwner(orgId) var orgUserType = await _currentContext.OrganizationOwner(orgId)
? OrganizationUserType.Owner ? OrganizationUserType.Owner
: _currentContext.Organizations?.FirstOrDefault(o => o.Id == orgId)?.Type; : _currentContext.Organizations?.FirstOrDefault(o => o.Id == orgId)?.Type;
if (orgUserType == null) if (orgUserType == null)
{ {
throw new NotFoundException(); return TypedResults.NotFound();
} }
var result = await _userService.AdminResetPasswordAsync(orgUserType.Value, orgId, id, model.NewMasterPasswordHash, model.Key); var result = await _userService.AdminResetPasswordAsync(orgUserType.Value, orgId, id, model.NewMasterPasswordHash, model.Key);
if (result.Succeeded) if (result.Succeeded)
{ {
return; return TypedResults.Ok();
} }
foreach (var error in result.Errors) foreach (var error in result.Errors)
@@ -488,11 +514,46 @@ public class OrganizationUsersController : Controller
} }
await Task.Delay(2000); await Task.Delay(2000);
throw new BadRequestException(ModelState); return TypedResults.BadRequest(ModelState);
} }
#nullable enable
// TODO: make sure the route and authorize attributes are maintained when the legacy implementation is removed.
private async Task<IResult> PutResetPasswordNew(Guid orgId, Guid id, [FromBody] OrganizationUserResetPasswordRequestModel model)
{
var targetOrganizationUser = await _organizationUserRepository.GetByIdAsync(id);
if (targetOrganizationUser == null || targetOrganizationUser.OrganizationId != orgId)
{
return TypedResults.NotFound();
}
var authorizationResult = await _authorizationService.AuthorizeAsync(User, targetOrganizationUser, new RecoverAccountAuthorizationRequirement());
if (!authorizationResult.Succeeded)
{
// Return an informative error to show in the UI.
// The Authorize attribute already prevents enumeration by users outside the organization, so this can be specific.
var failureReason = authorizationResult.Failure?.FailureReasons.FirstOrDefault()?.Message ?? RecoverAccountAuthorizationHandler.FailureReason;
// This should be a 403 Forbidden, but that causes a logout on our client apps so we're using 400 Bad Request instead
return TypedResults.BadRequest(new ErrorResponseModel(failureReason));
}
var result = await _adminRecoverAccountCommand.RecoverAccountAsync(orgId, targetOrganizationUser, model.NewMasterPasswordHash, model.Key);
if (result.Succeeded)
{
return TypedResults.Ok();
}
foreach (var error in result.Errors)
{
ModelState.AddModelError(string.Empty, error.Description);
}
await Task.Delay(2000);
return TypedResults.BadRequest(ModelState);
}
#nullable disable
[HttpDelete("{id}")] [HttpDelete("{id}")]
[HttpPost("{id}/remove")]
[Authorize<ManageUsersRequirement>] [Authorize<ManageUsersRequirement>]
public async Task Remove(Guid orgId, Guid id) public async Task Remove(Guid orgId, Guid id)
{ {
@@ -500,8 +561,15 @@ public class OrganizationUsersController : Controller
await _removeOrganizationUserCommand.RemoveUserAsync(orgId, id, userId.Value); await _removeOrganizationUserCommand.RemoveUserAsync(orgId, id, userId.Value);
} }
[HttpPost("{id}/remove")]
[Obsolete("This endpoint is deprecated. Use DELETE method instead")]
[Authorize<ManageUsersRequirement>]
public async Task PostRemove(Guid orgId, Guid id)
{
await Remove(orgId, id);
}
[HttpDelete("")] [HttpDelete("")]
[HttpPost("remove")]
[Authorize<ManageUsersRequirement>] [Authorize<ManageUsersRequirement>]
public async Task<ListResponseModel<OrganizationUserBulkResponseModel>> BulkRemove(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) public async Task<ListResponseModel<OrganizationUserBulkResponseModel>> BulkRemove(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model)
{ {
@@ -511,38 +579,70 @@ public class OrganizationUsersController : Controller
new OrganizationUserBulkResponseModel(r.OrganizationUserId, r.ErrorMessage))); new OrganizationUserBulkResponseModel(r.OrganizationUserId, r.ErrorMessage)));
} }
[HttpDelete("{id}/delete-account")] [HttpPost("remove")]
[HttpPost("{id}/delete-account")] [Obsolete("This endpoint is deprecated. Use DELETE method instead")]
[Authorize<ManageUsersRequirement>] [Authorize<ManageUsersRequirement>]
public async Task DeleteAccount(Guid orgId, Guid id) public async Task<ListResponseModel<OrganizationUserBulkResponseModel>> PostBulkRemove(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model)
{ {
var currentUser = await _userService.GetUserByPrincipalAsync(User); return await BulkRemove(orgId, model);
if (currentUser == null)
{
throw new UnauthorizedAccessException();
} }
await _deleteClaimedOrganizationUserAccountCommand.DeleteUserAsync(orgId, id, currentUser.Id); [HttpDelete("{id}/delete-account")]
[Authorize<ManageUsersRequirement>]
public async Task<IResult> DeleteAccount(Guid orgId, Guid id)
{
var currentUserId = _userService.GetProperUserId(User);
if (currentUserId == null)
{
return TypedResults.Unauthorized();
}
var commandResult = await _deleteClaimedOrganizationUserAccountCommand.DeleteUserAsync(orgId, id, currentUserId.Value);
return commandResult.Result.Match<IResult>(
error => error is NotFoundError
? TypedResults.NotFound(new ErrorResponseModel(error.Message))
: TypedResults.BadRequest(new ErrorResponseModel(error.Message)),
TypedResults.Ok
);
}
[HttpPost("{id}/delete-account")]
[Obsolete("This endpoint is deprecated. Use DELETE method instead")]
[Authorize<ManageUsersRequirement>]
public async Task PostDeleteAccount(Guid orgId, Guid id)
{
await DeleteAccount(orgId, id);
} }
[HttpDelete("delete-account")] [HttpDelete("delete-account")]
[HttpPost("delete-account")]
[Authorize<ManageUsersRequirement>] [Authorize<ManageUsersRequirement>]
public async Task<ListResponseModel<OrganizationUserBulkResponseModel>> BulkDeleteAccount(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) public async Task<ListResponseModel<OrganizationUserBulkResponseModel>> BulkDeleteAccount(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model)
{ {
var currentUser = await _userService.GetUserByPrincipalAsync(User); var currentUserId = _userService.GetProperUserId(User);
if (currentUser == null) if (currentUserId == null)
{ {
throw new UnauthorizedAccessException(); throw new UnauthorizedAccessException();
} }
var results = await _deleteClaimedOrganizationUserAccountCommand.DeleteManyUsersAsync(orgId, model.Ids, currentUser.Id); var result = await _deleteClaimedOrganizationUserAccountCommand.DeleteManyUsersAsync(orgId, model.Ids, currentUserId.Value);
return new ListResponseModel<OrganizationUserBulkResponseModel>(results.Select(r => var responses = result.Select(r => r.Result.Match(
new OrganizationUserBulkResponseModel(r.OrganizationUserId, r.ErrorMessage))); error => new OrganizationUserBulkResponseModel(r.Id, error.Message),
_ => new OrganizationUserBulkResponseModel(r.Id, string.Empty)
));
return new ListResponseModel<OrganizationUserBulkResponseModel>(responses);
}
[HttpPost("delete-account")]
[Obsolete("This endpoint is deprecated. Use DELETE method instead")]
[Authorize<ManageUsersRequirement>]
public async Task<ListResponseModel<OrganizationUserBulkResponseModel>> PostBulkDeleteAccount(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model)
{
return await BulkDeleteAccount(orgId, model);
} }
[HttpPatch("{id}/revoke")]
[HttpPut("{id}/revoke")] [HttpPut("{id}/revoke")]
[Authorize<ManageUsersRequirement>] [Authorize<ManageUsersRequirement>]
public async Task RevokeAsync(Guid orgId, Guid id) public async Task RevokeAsync(Guid orgId, Guid id)
@@ -550,7 +650,14 @@ public class OrganizationUsersController : Controller
await RestoreOrRevokeUserAsync(orgId, id, _revokeOrganizationUserCommand.RevokeUserAsync); await RestoreOrRevokeUserAsync(orgId, id, _revokeOrganizationUserCommand.RevokeUserAsync);
} }
[HttpPatch("revoke")] [HttpPatch("{id}/revoke")]
[Obsolete("This endpoint is deprecated. Use PUT method instead")]
[Authorize<ManageUsersRequirement>]
public async Task PatchRevokeAsync(Guid orgId, Guid id)
{
await RevokeAsync(orgId, id);
}
[HttpPut("revoke")] [HttpPut("revoke")]
[Authorize<ManageUsersRequirement>] [Authorize<ManageUsersRequirement>]
public async Task<ListResponseModel<OrganizationUserBulkResponseModel>> BulkRevokeAsync(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) public async Task<ListResponseModel<OrganizationUserBulkResponseModel>> BulkRevokeAsync(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model)
@@ -558,7 +665,14 @@ public class OrganizationUsersController : Controller
return await RestoreOrRevokeUsersAsync(orgId, model, _revokeOrganizationUserCommand.RevokeUsersAsync); return await RestoreOrRevokeUsersAsync(orgId, model, _revokeOrganizationUserCommand.RevokeUsersAsync);
} }
[HttpPatch("{id}/restore")] [HttpPatch("revoke")]
[Obsolete("This endpoint is deprecated. Use PUT method instead")]
[Authorize<ManageUsersRequirement>]
public async Task<ListResponseModel<OrganizationUserBulkResponseModel>> PatchBulkRevokeAsync(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model)
{
return await BulkRevokeAsync(orgId, model);
}
[HttpPut("{id}/restore")] [HttpPut("{id}/restore")]
[Authorize<ManageUsersRequirement>] [Authorize<ManageUsersRequirement>]
public async Task RestoreAsync(Guid orgId, Guid id) public async Task RestoreAsync(Guid orgId, Guid id)
@@ -566,7 +680,14 @@ public class OrganizationUsersController : Controller
await RestoreOrRevokeUserAsync(orgId, id, (orgUser, userId) => _restoreOrganizationUserCommand.RestoreUserAsync(orgUser, userId)); await RestoreOrRevokeUserAsync(orgId, id, (orgUser, userId) => _restoreOrganizationUserCommand.RestoreUserAsync(orgUser, userId));
} }
[HttpPatch("restore")] [HttpPatch("{id}/restore")]
[Obsolete("This endpoint is deprecated. Use PUT method instead")]
[Authorize<ManageUsersRequirement>]
public async Task PatchRestoreAsync(Guid orgId, Guid id)
{
await RestoreAsync(orgId, id);
}
[HttpPut("restore")] [HttpPut("restore")]
[Authorize<ManageUsersRequirement>] [Authorize<ManageUsersRequirement>]
public async Task<ListResponseModel<OrganizationUserBulkResponseModel>> BulkRestoreAsync(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) public async Task<ListResponseModel<OrganizationUserBulkResponseModel>> BulkRestoreAsync(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model)
@@ -574,7 +695,14 @@ public class OrganizationUsersController : Controller
return await RestoreOrRevokeUsersAsync(orgId, model, (orgId, orgUserIds, restoringUserId) => _restoreOrganizationUserCommand.RestoreUsersAsync(orgId, orgUserIds, restoringUserId, _userService)); return await RestoreOrRevokeUsersAsync(orgId, model, (orgId, orgUserIds, restoringUserId) => _restoreOrganizationUserCommand.RestoreUsersAsync(orgId, orgUserIds, restoringUserId, _userService));
} }
[HttpPatch("enable-secrets-manager")] [HttpPatch("restore")]
[Obsolete("This endpoint is deprecated. Use PUT method instead")]
[Authorize<ManageUsersRequirement>]
public async Task<ListResponseModel<OrganizationUserBulkResponseModel>> PatchBulkRestoreAsync(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model)
{
return await BulkRestoreAsync(orgId, model);
}
[HttpPut("enable-secrets-manager")] [HttpPut("enable-secrets-manager")]
[Authorize<ManageUsersRequirement>] [Authorize<ManageUsersRequirement>]
public async Task BulkEnableSecretsManagerAsync(Guid orgId, public async Task BulkEnableSecretsManagerAsync(Guid orgId,
@@ -607,6 +735,40 @@ public class OrganizationUsersController : Controller
await _organizationUserRepository.ReplaceManyAsync(orgUsers); await _organizationUserRepository.ReplaceManyAsync(orgUsers);
} }
[HttpPatch("enable-secrets-manager")]
[Obsolete("This endpoint is deprecated. Use PUT method instead")]
[Authorize<ManageUsersRequirement>]
public async Task PatchBulkEnableSecretsManagerAsync(Guid orgId,
[FromBody] OrganizationUserBulkRequestModel model)
{
await BulkEnableSecretsManagerAsync(orgId, model);
}
[HttpPost("{id}/auto-confirm")]
[Authorize<ManageUsersRequirement>]
[RequireFeature(FeatureFlagKeys.AutomaticConfirmUsers)]
public async Task<IResult> AutomaticallyConfirmOrganizationUserAsync([FromRoute] Guid orgId,
[FromRoute] Guid id,
[FromBody] OrganizationUserConfirmRequestModel model)
{
var userId = _userService.GetProperUserId(User);
if (userId is null || userId.Value == Guid.Empty)
{
return TypedResults.Unauthorized();
}
return Handle(await _automaticallyConfirmOrganizationUserCommand.AutomaticallyConfirmOrganizationUserAsync(
new AutomaticallyConfirmOrganizationUserRequest
{
OrganizationId = orgId,
OrganizationUserId = id,
Key = model.Key,
DefaultUserCollectionName = model.DefaultUserCollectionName,
PerformedBy = new StandardUser(userId.Value, await _currentContext.OrganizationOwner(orgId)),
}));
}
private async Task RestoreOrRevokeUserAsync( private async Task RestoreOrRevokeUserAsync(
Guid orgId, Guid orgId,
Guid id, Guid id,

View File

@@ -225,7 +225,6 @@ public class OrganizationsController : Controller
} }
[HttpPut("{id}")] [HttpPut("{id}")]
[HttpPost("{id}")]
public async Task<OrganizationResponseModel> Put(string id, [FromBody] OrganizationUpdateRequestModel model) public async Task<OrganizationResponseModel> Put(string id, [FromBody] OrganizationUpdateRequestModel model)
{ {
var orgIdGuid = new Guid(id); var orgIdGuid = new Guid(id);
@@ -252,6 +251,13 @@ public class OrganizationsController : Controller
return new OrganizationResponseModel(organization, plan); return new OrganizationResponseModel(organization, plan);
} }
[HttpPost("{id}")]
[Obsolete("This endpoint is deprecated. Use PUT method instead")]
public async Task<OrganizationResponseModel> PostPut(string id, [FromBody] OrganizationUpdateRequestModel model)
{
return await Put(id, model);
}
[HttpPost("{id}/storage")] [HttpPost("{id}/storage")]
[SelfHosted(NotSelfHostedOnly = true)] [SelfHosted(NotSelfHostedOnly = true)]
public async Task<PaymentResponseModel> PostStorage(string id, [FromBody] StorageRequestModel model) public async Task<PaymentResponseModel> PostStorage(string id, [FromBody] StorageRequestModel model)
@@ -291,7 +297,6 @@ public class OrganizationsController : Controller
} }
[HttpDelete("{id}")] [HttpDelete("{id}")]
[HttpPost("{id}/delete")]
public async Task Delete(string id, [FromBody] SecretVerificationRequestModel model) public async Task Delete(string id, [FromBody] SecretVerificationRequestModel model)
{ {
var orgIdGuid = new Guid(id); var orgIdGuid = new Guid(id);
@@ -334,6 +339,13 @@ public class OrganizationsController : Controller
await _organizationDeleteCommand.DeleteAsync(organization); await _organizationDeleteCommand.DeleteAsync(organization);
} }
[HttpPost("{id}/delete")]
[Obsolete("This endpoint is deprecated. Use DELETE method instead")]
public async Task PostDelete(string id, [FromBody] SecretVerificationRequestModel model)
{
await Delete(id, model);
}
[HttpPost("{id}/delete-recover-token")] [HttpPost("{id}/delete-recover-token")]
[AllowAnonymous] [AllowAnonymous]
public async Task PostDeleteRecoverToken(Guid id, [FromBody] OrganizationVerifyDeleteRecoverRequestModel model) public async Task PostDeleteRecoverToken(Guid id, [FromBody] OrganizationVerifyDeleteRecoverRequestModel model)
@@ -554,18 +566,12 @@ public class OrganizationsController : Controller
[HttpPut("{id}/collection-management")] [HttpPut("{id}/collection-management")]
public async Task<OrganizationResponseModel> PutCollectionManagement(Guid id, [FromBody] OrganizationCollectionManagementUpdateRequestModel model) public async Task<OrganizationResponseModel> PutCollectionManagement(Guid id, [FromBody] OrganizationCollectionManagementUpdateRequestModel model)
{ {
var organization = await _organizationRepository.GetByIdAsync(id);
if (organization == null)
{
throw new NotFoundException();
}
if (!await _currentContext.OrganizationOwner(id)) if (!await _currentContext.OrganizationOwner(id))
{ {
throw new NotFoundException(); throw new NotFoundException();
} }
await _organizationService.UpdateAsync(model.ToOrganization(organization, _featureService), eventType: EventType.Organization_CollectionManagement_Updated); var organization = await _organizationService.UpdateCollectionManagementSettingsAsync(id, model.ToSettings());
var plan = await _pricingClient.GetPlan(organization.PlanType); var plan = await _pricingClient.GetPlan(organization.PlanType);
return new OrganizationResponseModel(organization, plan); return new OrganizationResponseModel(organization, plan);
} }

View File

@@ -1,14 +1,18 @@
// FIXME: Update this file to be null safe and then delete the line below // FIXME: Update this file to be null safe and then delete the line below
#nullable disable #nullable disable
using Bit.Api.AdminConsole.Authorization;
using Bit.Api.AdminConsole.Authorization.Requirements;
using Bit.Api.AdminConsole.Models.Request; using Bit.Api.AdminConsole.Models.Request;
using Bit.Api.AdminConsole.Models.Response.Helpers; using Bit.Api.AdminConsole.Models.Response.Helpers;
using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Api.Models.Response; using Bit.Api.Models.Response;
using Bit.Core;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Context; using Bit.Core.Context;
@@ -30,7 +34,6 @@ namespace Bit.Api.AdminConsole.Controllers;
public class PoliciesController : Controller public class PoliciesController : Controller
{ {
private readonly ICurrentContext _currentContext; private readonly ICurrentContext _currentContext;
private readonly IFeatureService _featureService;
private readonly GlobalSettings _globalSettings; private readonly GlobalSettings _globalSettings;
private readonly IOrganizationHasVerifiedDomainsQuery _organizationHasVerifiedDomainsQuery; private readonly IOrganizationHasVerifiedDomainsQuery _organizationHasVerifiedDomainsQuery;
private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationRepository _organizationRepository;
@@ -39,8 +42,9 @@ public class PoliciesController : Controller
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory; private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
private readonly IPolicyRepository _policyRepository; private readonly IPolicyRepository _policyRepository;
private readonly IUserService _userService; private readonly IUserService _userService;
private readonly IFeatureService _featureService;
private readonly ISavePolicyCommand _savePolicyCommand; private readonly ISavePolicyCommand _savePolicyCommand;
private readonly IVNextSavePolicyCommand _vNextSavePolicyCommand;
public PoliciesController(IPolicyRepository policyRepository, public PoliciesController(IPolicyRepository policyRepository,
IOrganizationUserRepository organizationUserRepository, IOrganizationUserRepository organizationUserRepository,
@@ -49,10 +53,11 @@ public class PoliciesController : Controller
GlobalSettings globalSettings, GlobalSettings globalSettings,
IDataProtectionProvider dataProtectionProvider, IDataProtectionProvider dataProtectionProvider,
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory, IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory,
IFeatureService featureService,
IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery, IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery,
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
ISavePolicyCommand savePolicyCommand) IFeatureService featureService,
ISavePolicyCommand savePolicyCommand,
IVNextSavePolicyCommand vNextSavePolicyCommand)
{ {
_policyRepository = policyRepository; _policyRepository = policyRepository;
_organizationUserRepository = organizationUserRepository; _organizationUserRepository = organizationUserRepository;
@@ -63,9 +68,10 @@ public class PoliciesController : Controller
"OrganizationServiceDataProtector"); "OrganizationServiceDataProtector");
_organizationRepository = organizationRepository; _organizationRepository = organizationRepository;
_orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory; _orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory;
_featureService = featureService;
_organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery; _organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery;
_featureService = featureService;
_savePolicyCommand = savePolicyCommand; _savePolicyCommand = savePolicyCommand;
_vNextSavePolicyCommand = vNextSavePolicyCommand;
} }
[HttpGet("{type}")] [HttpGet("{type}")]
@@ -90,7 +96,7 @@ public class PoliciesController : Controller
} }
[HttpGet("")] [HttpGet("")]
public async Task<ListResponseModel<PolicyResponseModel>> Get(string orgId) public async Task<ListResponseModel<PolicyResponseModel>> GetAll(string orgId)
{ {
var orgIdGuid = new Guid(orgId); var orgIdGuid = new Guid(orgId);
if (!await _currentContext.ManagePolicies(orgIdGuid)) if (!await _currentContext.ManagePolicies(orgIdGuid))
@@ -203,13 +209,22 @@ public class PoliciesController : Controller
throw new NotFoundException(); throw new NotFoundException();
} }
if (type != model.Type) var policyUpdate = await model.ToPolicyUpdateAsync(orgId, type, _currentContext);
{
throw new BadRequestException("Mismatched policy type");
}
var policyUpdate = await model.ToPolicyUpdateAsync(orgId, _currentContext);
var policy = await _savePolicyCommand.SaveAsync(policyUpdate); var policy = await _savePolicyCommand.SaveAsync(policyUpdate);
return new PolicyResponseModel(policy); return new PolicyResponseModel(policy);
} }
[HttpPut("{type}/vnext")]
[RequireFeatureAttribute(FeatureFlagKeys.CreateDefaultLocation)]
[Authorize<ManagePoliciesRequirement>]
public async Task<PolicyResponseModel> PutVNext(Guid orgId, PolicyType type, [FromBody] SavePolicyRequest model)
{
var savePolicyRequest = await model.ToSavePolicyModelAsync(orgId, type, _currentContext);
var policy = _featureService.IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor) ?
await _vNextSavePolicyCommand.SaveAsync(savePolicyRequest) :
await _savePolicyCommand.VNextSaveAsync(savePolicyRequest);
return new PolicyResponseModel(policy);
}
} }

View File

@@ -93,7 +93,6 @@ public class ProviderOrganizationsController : Controller
} }
[HttpDelete("{id:guid}")] [HttpDelete("{id:guid}")]
[HttpPost("{id:guid}/delete")]
public async Task Delete(Guid providerId, Guid id) public async Task Delete(Guid providerId, Guid id)
{ {
if (!_currentContext.ManageProviderOrganizations(providerId)) if (!_currentContext.ManageProviderOrganizations(providerId))
@@ -112,4 +111,11 @@ public class ProviderOrganizationsController : Controller
providerOrganization, providerOrganization,
organization); organization);
} }
[HttpPost("{id:guid}/delete")]
[Obsolete("This endpoint is deprecated. Use DELETE method instead")]
public async Task PostDelete(Guid providerId, Guid id)
{
await Delete(providerId, id);
}
} }

View File

@@ -49,7 +49,7 @@ public class ProviderUsersController : Controller
} }
[HttpGet("")] [HttpGet("")]
public async Task<ListResponseModel<ProviderUserUserDetailsResponseModel>> Get(Guid providerId) public async Task<ListResponseModel<ProviderUserUserDetailsResponseModel>> GetAll(Guid providerId)
{ {
if (!_currentContext.ProviderManageUsers(providerId)) if (!_currentContext.ProviderManageUsers(providerId))
{ {
@@ -155,7 +155,6 @@ public class ProviderUsersController : Controller
} }
[HttpPut("{id:guid}")] [HttpPut("{id:guid}")]
[HttpPost("{id:guid}")]
public async Task Put(Guid providerId, Guid id, [FromBody] ProviderUserUpdateRequestModel model) public async Task Put(Guid providerId, Guid id, [FromBody] ProviderUserUpdateRequestModel model)
{ {
if (!_currentContext.ProviderManageUsers(providerId)) if (!_currentContext.ProviderManageUsers(providerId))
@@ -173,8 +172,14 @@ public class ProviderUsersController : Controller
await _providerService.SaveUserAsync(model.ToProviderUser(providerUser), userId.Value); await _providerService.SaveUserAsync(model.ToProviderUser(providerUser), userId.Value);
} }
[HttpPost("{id:guid}")]
[Obsolete("This endpoint is deprecated. Use PUT method instead")]
public async Task PostPut(Guid providerId, Guid id, [FromBody] ProviderUserUpdateRequestModel model)
{
await Put(providerId, id, model);
}
[HttpDelete("{id:guid}")] [HttpDelete("{id:guid}")]
[HttpPost("{id:guid}/delete")]
public async Task Delete(Guid providerId, Guid id) public async Task Delete(Guid providerId, Guid id)
{ {
if (!_currentContext.ProviderManageUsers(providerId)) if (!_currentContext.ProviderManageUsers(providerId))
@@ -186,8 +191,14 @@ public class ProviderUsersController : Controller
await _providerService.DeleteUsersAsync(providerId, new[] { id }, userId.Value); await _providerService.DeleteUsersAsync(providerId, new[] { id }, userId.Value);
} }
[HttpPost("{id:guid}/delete")]
[Obsolete("This endpoint is deprecated. Use DELETE method instead")]
public async Task PostDelete(Guid providerId, Guid id)
{
await Delete(providerId, id);
}
[HttpDelete("")] [HttpDelete("")]
[HttpPost("delete")]
public async Task<ListResponseModel<ProviderUserBulkResponseModel>> BulkDelete(Guid providerId, [FromBody] ProviderUserBulkRequestModel model) public async Task<ListResponseModel<ProviderUserBulkResponseModel>> BulkDelete(Guid providerId, [FromBody] ProviderUserBulkRequestModel model)
{ {
if (!_currentContext.ProviderManageUsers(providerId)) if (!_currentContext.ProviderManageUsers(providerId))
@@ -200,4 +211,11 @@ public class ProviderUsersController : Controller
return new ListResponseModel<ProviderUserBulkResponseModel>(result.Select(r => return new ListResponseModel<ProviderUserBulkResponseModel>(result.Select(r =>
new ProviderUserBulkResponseModel(r.Item1.Id, r.Item2))); new ProviderUserBulkResponseModel(r.Item1.Id, r.Item2)));
} }
[HttpPost("delete")]
[Obsolete("This endpoint is deprecated. Use DELETE method instead")]
public async Task<ListResponseModel<ProviderUserBulkResponseModel>> PostBulkDelete(Guid providerId, [FromBody] ProviderUserBulkRequestModel model)
{
return await BulkDelete(providerId, model);
}
} }

View File

@@ -7,7 +7,6 @@ using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Models.Business;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Settings; using Bit.Core.Settings;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
@@ -53,7 +52,6 @@ public class ProvidersController : Controller
} }
[HttpPut("{id:guid}")] [HttpPut("{id:guid}")]
[HttpPost("{id:guid}")]
public async Task<ProviderResponseModel> Put(Guid id, [FromBody] ProviderUpdateRequestModel model) public async Task<ProviderResponseModel> Put(Guid id, [FromBody] ProviderUpdateRequestModel model)
{ {
if (!_currentContext.ProviderProviderAdmin(id)) if (!_currentContext.ProviderProviderAdmin(id))
@@ -71,6 +69,13 @@ public class ProvidersController : Controller
return new ProviderResponseModel(provider); return new ProviderResponseModel(provider);
} }
[HttpPost("{id:guid}")]
[Obsolete("This endpoint is deprecated. Use PUT method instead")]
public async Task<ProviderResponseModel> PostPut(Guid id, [FromBody] ProviderUpdateRequestModel model)
{
return await Put(id, model);
}
[HttpPost("{id:guid}/setup")] [HttpPost("{id:guid}/setup")]
public async Task<ProviderResponseModel> Setup(Guid id, [FromBody] ProviderSetupRequestModel model) public async Task<ProviderResponseModel> Setup(Guid id, [FromBody] ProviderSetupRequestModel model)
{ {
@@ -87,22 +92,12 @@ public class ProvidersController : Controller
var userId = _userService.GetProperUserId(User).Value; var userId = _userService.GetProperUserId(User).Value;
var taxInfo = new TaxInfo var paymentMethod = model.PaymentMethod.ToDomain();
{ var billingAddress = model.BillingAddress.ToDomain();
BillingAddressCountry = model.TaxInfo.Country,
BillingAddressPostalCode = model.TaxInfo.PostalCode,
TaxIdNumber = model.TaxInfo.TaxId,
BillingAddressLine1 = model.TaxInfo.Line1,
BillingAddressLine2 = model.TaxInfo.Line2,
BillingAddressCity = model.TaxInfo.City,
BillingAddressState = model.TaxInfo.State
};
var tokenizedPaymentSource = model.PaymentSource?.ToDomain();
var response = var response =
await _providerService.CompleteSetupAsync(model.ToProvider(provider), userId, model.Token, model.Key, await _providerService.CompleteSetupAsync(model.ToProvider(provider), userId, model.Token, model.Key,
taxInfo, tokenizedPaymentSource); paymentMethod, billingAddress);
return new ProviderResponseModel(response); return new ProviderResponseModel(response);
} }
@@ -120,7 +115,6 @@ public class ProvidersController : Controller
} }
[HttpDelete("{id}")] [HttpDelete("{id}")]
[HttpPost("{id}/delete")]
public async Task Delete(Guid id) public async Task Delete(Guid id)
{ {
if (!_currentContext.ProviderProviderAdmin(id)) if (!_currentContext.ProviderProviderAdmin(id))
@@ -142,4 +136,11 @@ public class ProvidersController : Controller
await _providerService.DeleteAsync(provider); await _providerService.DeleteAsync(provider);
} }
[HttpPost("{id}/delete")]
[Obsolete("This endpoint is deprecated. Use DELETE method instead")]
public async Task PostDelete(Guid id)
{
await Delete(id);
}
} }

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