1
0
mirror of https://github.com/bitwarden/server.git synced 2025-05-22 20:11:04 -05:00

Merge branch 'main' into test-container-workflow-changes

This commit is contained in:
tangowithfoxtrot 2025-05-21 07:27:59 -07:00
commit 88159d16bb
No known key found for this signature in database
460 changed files with 18278 additions and 5957 deletions

3
.github/CODEOWNERS vendored
View File

@ -90,6 +90,9 @@ src/Admin/Views/Tools @bitwarden/team-billing-dev
.github/workflows/test-database.yml @bitwarden/team-platform-dev .github/workflows/test-database.yml @bitwarden/team-platform-dev
.github/workflows/test.yml @bitwarden/team-platform-dev .github/workflows/test.yml @bitwarden/team-platform-dev
**/*Platform* @bitwarden/team-platform-dev **/*Platform* @bitwarden/team-platform-dev
**/.dockerignore @bitwarden/team-platform-dev
**/Dockerfile @bitwarden/team-platform-dev
**/entrypoint.sh @bitwarden/team-platform-dev
# Multiple owners - DO NOT REMOVE (BRE) # Multiple owners - DO NOT REMOVE (BRE)
**/packages.lock.json **/packages.lock.json

View File

@ -20,7 +20,7 @@
], ],
commitMessagePrefix: "[deps] BRE:", commitMessagePrefix: "[deps] BRE:",
reviewers: ["team:dept-bre"], reviewers: ["team:dept-bre"],
addLabels: ["hold"] addLabels: ["hold"],
}, },
{ {
groupName: "dockerfile minor", groupName: "dockerfile minor",
@ -37,6 +37,16 @@
matchManagers: ["github-actions"], matchManagers: ["github-actions"],
matchUpdateTypes: ["minor"], matchUpdateTypes: ["minor"],
}, },
{
// For any Microsoft.Extensions.* and Microsoft.AspNetCore.* packages, we want to create PRs for patch updates.
// This overrides the default that ignores patch updates for nuget dependencies.
matchPackageNames: [
"/^Microsoft\\.Extensions\\./",
"/^Microsoft\\.AspNetCore\\./",
],
matchUpdateTypes: ["patch"],
dependencyDashboardApproval: false,
},
{ {
matchManagers: ["dockerfile", "docker-compose"], matchManagers: ["dockerfile", "docker-compose"],
commitMessagePrefix: "[deps] BRE:", commitMessagePrefix: "[deps] BRE:",
@ -59,6 +69,7 @@
"DuoUniversal", "DuoUniversal",
"Fido2.AspNet", "Fido2.AspNet",
"Duende.IdentityServer", "Duende.IdentityServer",
"Microsoft.AspNetCore.Authentication.JwtBearer",
"Microsoft.Extensions.Identity.Stores", "Microsoft.Extensions.Identity.Stores",
"Otp.NET", "Otp.NET",
"Sustainsys.Saml2.AspNetCore2", "Sustainsys.Saml2.AspNetCore2",
@ -79,8 +90,6 @@
"CsvHelper", "CsvHelper",
"Kralizek.AutoFixture.Extensions.MockHttp", "Kralizek.AutoFixture.Extensions.MockHttp",
"Microsoft.AspNetCore.Mvc.Testing", "Microsoft.AspNetCore.Mvc.Testing",
"Microsoft.Extensions.Logging",
"Microsoft.Extensions.Logging.Console",
"Newtonsoft.Json", "Newtonsoft.Json",
"NSubstitute", "NSubstitute",
"Sentry.Serilog", "Sentry.Serilog",
@ -100,9 +109,9 @@
reviewers: ["team:team-billing-dev"], reviewers: ["team:team-billing-dev"],
}, },
{ {
matchPackagePatterns: ["^Microsoft.Extensions.Logging"], matchPackageNames: ["/^Microsoft\\.EntityFrameworkCore\\./", "/^dotnet-ef/"],
groupName: "Microsoft.Extensions.Logging", groupName: "EntityFrameworkCore",
description: "Group Microsoft.Extensions.Logging to exclude them from the dotnet monorepo preset", description: "Group EntityFrameworkCore to exclude them from the dotnet monorepo preset",
}, },
{ {
matchPackageNames: [ matchPackageNames: [
@ -117,9 +126,6 @@
"Microsoft.EntityFrameworkCore.Relational", "Microsoft.EntityFrameworkCore.Relational",
"Microsoft.EntityFrameworkCore.Sqlite", "Microsoft.EntityFrameworkCore.Sqlite",
"Microsoft.EntityFrameworkCore.SqlServer", "Microsoft.EntityFrameworkCore.SqlServer",
"Microsoft.Extensions.Caching.Cosmos",
"Microsoft.Extensions.Caching.SqlServer",
"Microsoft.Extensions.Caching.StackExchangeRedis",
"Npgsql.EntityFrameworkCore.PostgreSQL", "Npgsql.EntityFrameworkCore.PostgreSQL",
"Pomelo.EntityFrameworkCore.MySql", "Pomelo.EntityFrameworkCore.MySql",
], ],
@ -142,56 +148,40 @@
"Azure.Messaging.ServiceBus", "Azure.Messaging.ServiceBus",
"Azure.Storage.Blobs", "Azure.Storage.Blobs",
"Azure.Storage.Queues", "Azure.Storage.Queues",
"Microsoft.AspNetCore.Authentication.JwtBearer", "LaunchDarkly.ServerSdk",
"Microsoft.AspNetCore.Http", "Microsoft.AspNetCore.Http",
"Microsoft.AspNetCore.SignalR.Protocols.MessagePack",
"Microsoft.AspNetCore.SignalR.StackExchangeRedis",
"Microsoft.Extensions.Configuration.EnvironmentVariables",
"Microsoft.Extensions.Configuration.UserSecrets",
"Microsoft.Extensions.Configuration",
"Microsoft.Extensions.DependencyInjection.Abstractions",
"Microsoft.Extensions.DependencyInjection",
"Microsoft.Extensions.Logging",
"Microsoft.Extensions.Logging.Console",
"Microsoft.Extensions.Caching.Cosmos",
"Microsoft.Extensions.Caching.SqlServer",
"Microsoft.Extensions.Caching.StackExchangeRedis",
"Quartz", "Quartz",
], ],
description: "Platform owned dependencies", description: "Platform owned dependencies",
commitMessagePrefix: "[deps] Platform:", commitMessagePrefix: "[deps] Platform:",
reviewers: ["team:team-platform-dev"], reviewers: ["team:team-platform-dev"],
}, },
{
matchPackagePatterns: ["EntityFrameworkCore", "^dotnet-ef"],
groupName: "EntityFrameworkCore",
description: "Group EntityFrameworkCore to exclude them from the dotnet monorepo preset",
},
{ {
matchPackageNames: [ matchPackageNames: [
"AutoMapper.Extensions.Microsoft.DependencyInjection", "AutoMapper.Extensions.Microsoft.DependencyInjection",
"AWSSDK.SimpleEmail", "AWSSDK.SimpleEmail",
"AWSSDK.SQS", "AWSSDK.SQS",
"Handlebars.Net", "Handlebars.Net",
"LaunchDarkly.ServerSdk",
"MailKit", "MailKit",
"Microsoft.AspNetCore.SignalR.Protocols.MessagePack",
"Microsoft.AspNetCore.SignalR.StackExchangeRedis",
"Microsoft.Azure.NotificationHubs", "Microsoft.Azure.NotificationHubs",
"Microsoft.Extensions.Configuration.EnvironmentVariables",
"Microsoft.Extensions.Configuration.UserSecrets",
"Microsoft.Extensions.Configuration",
"Microsoft.Extensions.DependencyInjection.Abstractions",
"Microsoft.Extensions.DependencyInjection",
"SendGrid", "SendGrid",
], ],
description: "Tools owned dependencies", description: "Tools owned dependencies",
commitMessagePrefix: "[deps] Tools:", commitMessagePrefix: "[deps] Tools:",
reviewers: ["team:team-tools-dev"], reviewers: ["team:team-tools-dev"],
}, },
{
matchPackagePatterns: ["^Microsoft.AspNetCore.SignalR"],
groupName: "SignalR",
description: "Group SignalR to exclude them from the dotnet monorepo preset",
},
{
matchPackagePatterns: ["^Microsoft.Extensions.Configuration"],
groupName: "Microsoft.Extensions.Configuration",
description: "Group Microsoft.Extensions.Configuration to exclude them from the dotnet monorepo preset",
},
{
matchPackagePatterns: ["^Microsoft.Extensions.DependencyInjection"],
groupName: "Microsoft.Extensions.DependencyInjection",
description: "Group Microsoft.Extensions.DependencyInjection to exclude them from the dotnet monorepo preset",
},
{ {
matchPackageNames: [ matchPackageNames: [
"AngleSharp", "AngleSharp",

View File

@ -14,11 +14,12 @@ on:
env: env:
_AZ_REGISTRY: "bitwardenprod.azurecr.io" _AZ_REGISTRY: "bitwardenprod.azurecr.io"
_GITHUB_PR_REPO_NAME: ${{ github.event.pull_request.head.repo.full_name }}
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
@ -31,12 +32,11 @@ jobs:
- name: Verify format - name: Verify format
run: dotnet format --verify-no-changes run: dotnet format --verify-no-changes
build-container: build-artifacts:
name: Build container images name: Build artifacts
runs-on: ubuntu-24.04 runs-on: ubuntu-22.04
permissions: needs:
id-token: write - lint
security-events: write
outputs: outputs:
has_secrets: ${{ steps.check-secrets.outputs.has_secrets }} has_secrets: ${{ steps.check-secrets.outputs.has_secrets }}
strategy: strategy:
@ -45,10 +45,9 @@ jobs:
include: include:
- project_name: Admin - project_name: Admin
base_path: ./src base_path: ./src
node: true
- project_name: Api - project_name: Api
base_path: ./src base_path: ./src
- project_name: Attachments
base_path: ./util
- project_name: Billing - project_name: Billing
base_path: ./src base_path: ./src
- project_name: Events - project_name: Events
@ -59,20 +58,21 @@ jobs:
base_path: ./src base_path: ./src
- project_name: Identity - project_name: Identity
base_path: ./src base_path: ./src
- project_name: MsSql
base_path: ./util
- project_name: MsSqlMigratorUtility - project_name: MsSqlMigratorUtility
base_path: ./util base_path: ./util
- project_name: Nginx dotnet: true
base_path: ./util
- project_name: Notifications - project_name: Notifications
base_path: ./src base_path: ./src
- project_name: Scim - project_name: Scim
base_path: ./bitwarden_license/src base_path: ./bitwarden_license/src
dotnet: true
- project_name: Server
base_path: ./util
- project_name: Setup - project_name: Setup
base_path: ./util base_path: ./util
- project_name: Sso - project_name: Sso
base_path: ./bitwarden_license/src base_path: ./bitwarden_license/src
node: true
steps: steps:
- name: Check secrets - name: Check secrets
id: check-secrets id: check-secrets
@ -87,6 +87,116 @@ jobs:
with: with:
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}
- name: Set up .NET
uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0
- name: Set up Node
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
with:
cache: "npm"
cache-dependency-path: "**/package-lock.json"
node-version: "16"
- name: Print environment
run: |
whoami
dotnet --info
node --version
npm --version
echo "GitHub ref: $GITHUB_REF"
echo "GitHub event: $GITHUB_EVENT"
- name: Build node
if: ${{ matrix.node }}
working-directory: ${{ matrix.base_path }}/${{ matrix.project_name }}
run: |
npm ci
npm run build
- name: Publish project
working-directory: ${{ matrix.base_path }}/${{ matrix.project_name }}
run: |
echo "Publish"
dotnet publish -c "Release" -o obj/build-output/publish
cd obj/build-output/publish
zip -r ${{ matrix.project_name }}.zip .
mv ${{ matrix.project_name }}.zip ../../../
pwd
ls -atlh ../../../
- name: Upload project artifact
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: ${{ matrix.project_name }}.zip
path: ${{ matrix.base_path }}/${{ matrix.project_name }}/${{ matrix.project_name }}.zip
if-no-files-found: error
build-docker:
name: Build Docker images
runs-on: ubuntu-22.04
permissions:
security-events: write
id-token: write
needs:
- build-artifacts
if: ${{ needs.build-artifacts.outputs.has_secrets == 'true' }}
strategy:
fail-fast: false
matrix:
include:
- project_name: Admin
base_path: ./src
dotnet: true
- project_name: Api
base_path: ./src
dotnet: true
- project_name: Attachments
base_path: ./util
- project_name: Billing
base_path: ./src
dotnet: true
- project_name: Events
base_path: ./src
dotnet: true
- project_name: EventsProcessor
base_path: ./src
dotnet: true
- project_name: Icons
base_path: ./src
dotnet: true
- project_name: Identity
base_path: ./src
dotnet: true
- project_name: MsSql
base_path: ./util
- project_name: MsSqlMigratorUtility
base_path: ./util
dotnet: true
- project_name: Nginx
base_path: ./util
- project_name: Notifications
base_path: ./src
dotnet: true
- project_name: Scim
base_path: ./bitwarden_license/src
dotnet: true
- project_name: Server
base_path: ./util
dotnet: true
- project_name: Setup
base_path: ./util
dotnet: true
- project_name: Sso
base_path: ./bitwarden_license/src
dotnet: true
steps:
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Check branch to publish - name: Check branch to publish
env: env:
PUBLISH_BRANCHES: "main,rc,hotfix-rc" PUBLISH_BRANCHES: "main,rc,hotfix-rc"
@ -100,13 +210,6 @@ jobs:
echo "is_publish_branch=false" >> $GITHUB_ENV echo "is_publish_branch=false" >> $GITHUB_ENV
fi fi
########## Set up Docker ##########
- name: Set up QEMU emulators
uses: docker/setup-qemu-action@68827325e0b33c7199eb31dd4e31fbe9023e06e3 # v3.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0
########## ACRs ########## ########## ACRs ##########
- name: Log in to Azure - production subscription - name: Log in to Azure - production subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
@ -116,22 +219,40 @@ jobs:
- name: Log in to ACR - production subscription - name: Log in to ACR - production subscription
run: az acr login -n bitwardenprod run: az acr login -n bitwardenprod
########## Generate image tag and build container image ########## - name: Log in to Azure - CI subscription
- name: Generate container image tag uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
- name: Retrieve GitHub PAT secrets
id: retrieve-secret-pat
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: "bitwarden-ci"
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
########## Generate image tag and build Docker image ##########
- name: Generate Docker image tag
id: tag id: tag
run: | run: |
if [[ "${GITHUB_EVENT_NAME}" == "pull_request" ]]; then if [[ "${GITHUB_EVENT_NAME}" == "pull_request" || "${GITHUB_EVENT_NAME}" == "pull_request_target" ]]; then
IMAGE_TAG=$(echo "${GITHUB_HEAD_REF}" | sed "s#/#-#g") IMAGE_TAG=$(echo "${GITHUB_HEAD_REF}" | sed "s/[^a-zA-Z0-9]/-/g") # Sanitize branch name to alphanumeric only
else else
IMAGE_TAG=$(echo "${GITHUB_REF:11}" | sed "s#/#-#g") IMAGE_TAG=$(echo "${GITHUB_REF:11}" | sed "s#/#-#g")
fi fi
if [[ "${{ github.event.pull_request.head.repo.fork }}" == "true" ]]; then
SANITIZED_REPO_NAME=$(echo "$_GITHUB_PR_REPO_NAME" | sed "s/[^a-zA-Z0-9]/-/g") # Sanitize repo name to alphanumeric only
IMAGE_TAG=$SANITIZED_REPO_NAME-$IMAGE_TAG # Add repo name to the tag
IMAGE_TAG=${IMAGE_TAG:0:128} # Limit to 128 characters, as that's the max length for Docker image tags
fi
if [[ "$IMAGE_TAG" == "main" ]]; then if [[ "$IMAGE_TAG" == "main" ]]; then
IMAGE_TAG=dev IMAGE_TAG=dev
fi fi
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
echo "### :mega: Container 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
@ -156,26 +277,30 @@ jobs:
fi fi
echo "tags=$TAGS" >> $GITHUB_OUTPUT echo "tags=$TAGS" >> $GITHUB_OUTPUT
- name: Generate image full name - name: Get build artifact
id: cache-name if: ${{ matrix.dotnet }}
env: uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
PROJECT_NAME: ${{ steps.setup.outputs.project_name }} with:
run: echo "name=${_AZ_REGISTRY}/${PROJECT_NAME}:buildcache" >> $GITHUB_OUTPUT name: ${{ matrix.project_name }}.zip
- name: Build Container image - name: Set up build artifact
id: build-container if: ${{ matrix.dotnet }}
run: |
mkdir -p ${{ matrix.base_path}}/${{ matrix.project_name }}/obj/build-output/publish
unzip ${{ matrix.project_name }}.zip \
-d ${{ matrix.base_path }}/${{ matrix.project_name }}/obj/build-output/publish
- name: Build Docker image
id: build-docker
uses: docker/build-push-action@67a2d409c0a876cbe6b11854e3e25193efe4e62d # v6.12.0 uses: docker/build-push-action@67a2d409c0a876cbe6b11854e3e25193efe4e62d # v6.12.0
with: with:
cache-from: type=registry,ref=${{ steps.cache-name.outputs.name }} context: ${{ matrix.base_path }}/${{ matrix.project_name }}
cache-to: type=registry,ref=${{ steps.cache-name.outputs.name}},mode=max
context: .
file: ${{ matrix.base_path }}/${{ matrix.project_name }}/Dockerfile file: ${{ matrix.base_path }}/${{ matrix.project_name }}/Dockerfile
platforms: | platforms: linux/amd64
linux/amd64,
linux/arm/v7,
linux/arm64
push: true push: true
tags: ${{ steps.image-tags.outputs.tags }} tags: ${{ steps.image-tags.outputs.tags }}
secrets: |
"GH_PAT=${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}"
- name: Install Cosign - name: Install Cosign
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
@ -184,7 +309,7 @@ jobs:
- name: Sign image with Cosign - name: Sign image with Cosign
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
env: env:
DIGEST: ${{ steps.build-container.outputs.digest }} DIGEST: ${{ steps.build-docker.outputs.digest }}
TAGS: ${{ steps.image-tags.outputs.tags }} TAGS: ${{ steps.image-tags.outputs.tags }}
run: | run: |
IFS="," read -a tags <<< "${TAGS}" IFS="," read -a tags <<< "${TAGS}"
@ -194,7 +319,7 @@ jobs:
done done
cosign sign --yes ${images} cosign sign --yes ${images}
- name: Scan container image - name: Scan Docker image
id: container-scan id: container-scan
uses: anchore/scan-action@abae793926ec39a78ab18002bc7fc45bbbd94342 # v6.0.0 uses: anchore/scan-action@abae793926ec39a78ab18002bc7fc45bbbd94342 # v6.0.0
with: with:
@ -209,10 +334,10 @@ jobs:
sha: ${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.sha || github.sha }} sha: ${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.sha || github.sha }}
ref: ${{ contains(github.event_name, 'pull_request') && format('refs/pull/{0}/head', github.event.pull_request.number) || github.ref }} ref: ${{ contains(github.event_name, 'pull_request') && format('refs/pull/{0}/head', github.event.pull_request.number) || github.ref }}
build-stub-swagger: upload:
name: Build Docker-Stub/Swagger name: Upload
runs-on: ubuntu-24.04 runs-on: ubuntu-22.04
needs: build-container needs: build-docker
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@ -227,11 +352,8 @@ jobs:
with: with:
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }} creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
- name: Login to PROD ACR - name: Log in to ACR - production subscription
run: az acr login -n ${_AZ_REGISTRY%.azurecr.io} run: az acr login -n $_AZ_REGISTRY --only-show-errors
- name: Restore
run: dotnet tool restore
- name: Make Docker stubs - name: Make Docker stubs
if: | if: |
@ -326,10 +448,8 @@ jobs:
- name: Build Public API Swagger - name: Build Public API Swagger
run: | run: |
cd ./src/Api cd ./src/Api
echo "Restore" echo "Restore tools"
dotnet restore --locked-mode dotnet tool restore
echo "Clean"
dotnet clean -c "Release" -o obj/build-output/publish
echo "Publish" echo "Publish"
dotnet publish -c "Release" -o obj/build-output/publish dotnet publish -c "Release" -o obj/build-output/publish
@ -392,7 +512,9 @@ 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:
- lint
defaults: defaults:
run: run:
shell: bash shell: bash
@ -420,11 +542,6 @@ jobs:
echo "GitHub ref: $GITHUB_REF" echo "GitHub ref: $GITHUB_REF"
echo "GitHub event: $GITHUB_EVENT" echo "GitHub event: $GITHUB_EVENT"
- name: Restore project
run: |
echo "Restore"
dotnet restore --locked-mode
- name: Publish project - name: Publish project
run: | run: |
dotnet publish -c "Release" -o obj/build-output/publish -r ${{ matrix.target }} -p:PublishSingleFile=true \ dotnet publish -c "Release" -o obj/build-output/publish -r ${{ matrix.target }} -p:PublishSingleFile=true \
@ -451,8 +568,9 @@ 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: build-container needs:
- build-docker
steps: steps:
- name: Log in to Azure - CI subscription - name: Log in to Azure - CI subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
@ -484,8 +602,9 @@ jobs:
trigger-k8s-deploy: trigger-k8s-deploy:
name: Trigger k8s deploy name: Trigger k8s deploy
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
runs-on: ubuntu-24.04 runs-on: ubuntu-22.04
needs: build-container needs:
- build-docker
steps: steps:
- name: Log in to Azure - CI subscription - name: Log in to Azure - CI subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
@ -515,49 +634,13 @@ jobs:
} }
}) })
trigger-ee-updates: setup-ephemeral-environment:
name: Trigger Ephemeral Environment updates name: Setup Ephemeral Environment
if: |
needs.build-container.outputs.has_secrets == 'true'
&& github.event_name == 'pull_request'
&& contains(github.event.pull_request.labels.*.name, 'ephemeral-environment')
runs-on: ubuntu-24.04
needs: build-container
steps:
- name: Log in to Azure - CI subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
- name: Retrieve GitHub PAT secrets
id: retrieve-secret-pat
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: "bitwarden-ci"
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
- name: Trigger Ephemeral Environment update
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
script: |
await github.rest.actions.createWorkflowDispatch({
owner: 'bitwarden',
repo: 'devops',
workflow_id: '_update_ephemeral_tags.yml',
ref: 'main',
inputs: {
ephemeral_env_branch: process.env.GITHUB_HEAD_REF
}
})
trigger-ephemeral-environment-sync:
name: Trigger Ephemeral Environment Sync
needs: needs:
- build-container - build-artifacts
- trigger-ee-updates - build-docker
if: | if: |
needs.build-container.outputs.has_secrets == 'true' needs.build-artifacts.outputs.has_secrets == 'true'
&& github.event_name == 'pull_request' && github.event_name == 'pull_request'
&& contains(github.event.pull_request.labels.*.name, 'ephemeral-environment') && contains(github.event.pull_request.labels.*.name, 'ephemeral-environment')
uses: bitwarden/gh-actions/.github/workflows/_ephemeral_environment_manager.yml@main uses: bitwarden/gh-actions/.github/workflows/_ephemeral_environment_manager.yml@main
@ -569,16 +652,15 @@ jobs:
check-failures: check-failures:
name: Check for failures name: Check for failures
if: always() if: always()
runs-on: ubuntu-24.04 runs-on: ubuntu-22.04
needs: needs:
- lint - lint
- build-container - build-artifacts
- build-stub-swagger - build-docker
- upload
- build-mssqlmigratorutility - build-mssqlmigratorutility
- self-host-build - self-host-build
- trigger-k8s-deploy - trigger-k8s-deploy
- trigger-ee-updates
- trigger-ephemeral-environment-sync
steps: steps:
- name: Check if any job failed - name: Check if any job failed
if: | if: |

View File

@ -2,7 +2,9 @@ name: Build on PR Target
on: on:
pull_request_target: pull_request_target:
types: [opened, synchronize] types: [opened, synchronize, reopened]
branches:
- "main"
defaults: defaults:
run: run:

View File

@ -7,8 +7,14 @@ on:
- "main" - "main"
- "rc" - "rc"
- "hotfix-rc" - "hotfix-rc"
pull_request:
types: [opened, synchronize, reopened]
branches-ignore:
- main
pull_request_target: pull_request_target:
types: [opened, synchronize] types: [opened, synchronize, reopened]
branches:
- "main"
jobs: jobs:
check-run: check-run:

View File

@ -3,7 +3,7 @@
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<Version>2025.5.0</Version> <Version>2025.5.1</Version>
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace> <RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>

View File

@ -129,6 +129,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Events.IntegrationTest", "t
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Core.IntegrationTest", "test\Core.IntegrationTest\Core.IntegrationTest.csproj", "{3631BA42-6731-4118-A917-DAA43C5032B9}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Core.IntegrationTest", "test\Core.IntegrationTest\Core.IntegrationTest.csproj", "{3631BA42-6731-4118-A917-DAA43C5032B9}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Seeder", "util\Seeder\Seeder.csproj", "{9A612EBA-1C0E-42B8-982B-62F0EE81000A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DbSeederUtility", "util\DbSeederUtility\DbSeederUtility.csproj", "{17A89266-260A-4A03-81AE-C0468C6EE06E}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@ -325,6 +329,14 @@ Global
{3631BA42-6731-4118-A917-DAA43C5032B9}.Debug|Any CPU.Build.0 = Debug|Any CPU {3631BA42-6731-4118-A917-DAA43C5032B9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3631BA42-6731-4118-A917-DAA43C5032B9}.Release|Any CPU.ActiveCfg = Release|Any CPU {3631BA42-6731-4118-A917-DAA43C5032B9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3631BA42-6731-4118-A917-DAA43C5032B9}.Release|Any CPU.Build.0 = Release|Any CPU {3631BA42-6731-4118-A917-DAA43C5032B9}.Release|Any CPU.Build.0 = Release|Any CPU
{9A612EBA-1C0E-42B8-982B-62F0EE81000A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9A612EBA-1C0E-42B8-982B-62F0EE81000A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9A612EBA-1C0E-42B8-982B-62F0EE81000A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9A612EBA-1C0E-42B8-982B-62F0EE81000A}.Release|Any CPU.Build.0 = Release|Any CPU
{17A89266-260A-4A03-81AE-C0468C6EE06E}.Debug|Any CPU.ActiveCfg = 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.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
@ -377,6 +389,8 @@ Global
{4A725DB3-BE4F-4C23-9087-82D0610D67AF} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F} {4A725DB3-BE4F-4C23-9087-82D0610D67AF} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
{4F4C63A9-AEE2-48C4-AB86-A5BCD665E401} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F} {4F4C63A9-AEE2-48C4-AB86-A5BCD665E401} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
{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}
{17A89266-260A-4A03-81AE-C0468C6EE06E} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E}
EndGlobalSection EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {E01CBF68-2E20-425F-9EDB-E0A6510CA92F} SolutionGuid = {E01CBF68-2E20-425F-9EDB-E0A6510CA92F}

View File

@ -3,9 +3,9 @@ using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Providers.Interfaces;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Entities;
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Repositories; using Bit.Core.Billing.Providers.Entities;
using Bit.Core.Billing.Providers.Repositories;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;

View File

@ -7,13 +7,12 @@ using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Constants; using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Providers.Services;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Services;
using Bit.Core.Billing.Services.Implementations.AutomaticTax;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Microsoft.Extensions.DependencyInjection;
using Stripe; using Stripe;
namespace Bit.Commercial.Core.AdminConsole.Providers; namespace Bit.Commercial.Core.AdminConsole.Providers;
@ -23,7 +22,6 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
private readonly IEventService _eventService; private readonly IEventService _eventService;
private readonly IMailService _mailService; private readonly IMailService _mailService;
private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationRepository _organizationRepository;
private readonly IOrganizationService _organizationService;
private readonly IProviderOrganizationRepository _providerOrganizationRepository; private readonly IProviderOrganizationRepository _providerOrganizationRepository;
private readonly IStripeAdapter _stripeAdapter; private readonly IStripeAdapter _stripeAdapter;
private readonly IFeatureService _featureService; private readonly IFeatureService _featureService;
@ -31,26 +29,22 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
private readonly ISubscriberService _subscriberService; private readonly ISubscriberService _subscriberService;
private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery; private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery;
private readonly IPricingClient _pricingClient; private readonly IPricingClient _pricingClient;
private readonly IAutomaticTaxStrategy _automaticTaxStrategy;
public RemoveOrganizationFromProviderCommand( public RemoveOrganizationFromProviderCommand(
IEventService eventService, IEventService eventService,
IMailService mailService, IMailService mailService,
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
IOrganizationService organizationService,
IProviderOrganizationRepository providerOrganizationRepository, IProviderOrganizationRepository providerOrganizationRepository,
IStripeAdapter stripeAdapter, IStripeAdapter stripeAdapter,
IFeatureService featureService, IFeatureService featureService,
IProviderBillingService providerBillingService, IProviderBillingService providerBillingService,
ISubscriberService subscriberService, ISubscriberService subscriberService,
IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery, IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery,
IPricingClient pricingClient, IPricingClient pricingClient)
[FromKeyedServices(AutomaticTaxFactory.BusinessUse)] IAutomaticTaxStrategy automaticTaxStrategy)
{ {
_eventService = eventService; _eventService = eventService;
_mailService = mailService; _mailService = mailService;
_organizationRepository = organizationRepository; _organizationRepository = organizationRepository;
_organizationService = organizationService;
_providerOrganizationRepository = providerOrganizationRepository; _providerOrganizationRepository = providerOrganizationRepository;
_stripeAdapter = stripeAdapter; _stripeAdapter = stripeAdapter;
_featureService = featureService; _featureService = featureService;
@ -58,7 +52,6 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
_subscriberService = subscriberService; _subscriberService = subscriberService;
_hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery; _hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery;
_pricingClient = pricingClient; _pricingClient = pricingClient;
_automaticTaxStrategy = automaticTaxStrategy;
} }
public async Task RemoveOrganizationFromProvider( public async Task RemoveOrganizationFromProvider(
@ -76,7 +69,7 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
if (!await _hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync( if (!await _hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync(
providerOrganization.OrganizationId, providerOrganization.OrganizationId,
Array.Empty<Guid>(), [],
includeProvider: false)) includeProvider: false))
{ {
throw new BadRequestException("Organization must have at least one confirmed owner."); throw new BadRequestException("Organization must have at least one confirmed owner.");
@ -101,7 +94,7 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
/// <summary> /// <summary>
/// When a client organization is unlinked from a provider, we have to check if they're Stripe-enabled /// When a client organization is unlinked from a provider, we have to check if they're Stripe-enabled
/// and, if they are, we remove their MSP discount and set their Subscription to `send_invoice`. This is because /// and, if they are, we remove their MSP discount and set their Subscription to `send_invoice`. This is because
/// the provider's payment method will be removed from their Stripe customer causing ensuing charges to fail. Lastly, /// the provider's payment method will be removed from their Stripe customer, causing ensuing charges to fail. Lastly,
/// we email the organization owners letting them know they need to add a new payment method. /// we email the organization owners letting them know they need to add a new payment method.
/// </summary> /// </summary>
private async Task ResetOrganizationBillingAsync( private async Task ResetOrganizationBillingAsync(
@ -141,15 +134,18 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
Items = [new SubscriptionItemOptions { Price = plan.PasswordManager.StripeSeatPlanId, Quantity = organization.Seats }] Items = [new SubscriptionItemOptions { Price = plan.PasswordManager.StripeSeatPlanId, Quantity = organization.Seats }]
}; };
if (_featureService.IsEnabled(FeatureFlagKeys.PM19147_AutomaticTaxImprovements)) var setNonUSBusinessUseToReverseCharge = _featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge);
if (setNonUSBusinessUseToReverseCharge)
{ {
_automaticTaxStrategy.SetCreateOptions(subscriptionCreateOptions, customer); subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true };
} }
else else if (customer.HasRecognizedTaxLocation())
{ {
subscriptionCreateOptions.AutomaticTax ??= new SubscriptionAutomaticTaxOptions subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions
{ {
Enabled = true Enabled = customer.Address.Country == "US" ||
customer.TaxIds.Any()
}; };
} }
@ -186,7 +182,7 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
await _mailService.SendProviderUpdatePaymentMethod( await _mailService.SendProviderUpdatePaymentMethod(
organization.Id, organization.Id,
organization.Name, organization.Name,
provider.Name, provider.Name!,
organizationOwnerEmails); organizationOwnerEmails);
} }
} }

View File

@ -5,12 +5,13 @@ using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Models.Business.Provider; using Bit.Core.AdminConsole.Models.Business.Provider;
using Bit.Core.AdminConsole.Models.Business.Tokenables; using Bit.Core.AdminConsole.Models.Business.Tokenables;
using Bit.Core.AdminConsole.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.Models;
using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Providers.Services;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
@ -53,6 +54,7 @@ public class ProviderService : IProviderService
private readonly IApplicationCacheService _applicationCacheService; private readonly IApplicationCacheService _applicationCacheService;
private readonly IProviderBillingService _providerBillingService; private readonly IProviderBillingService _providerBillingService;
private readonly IPricingClient _pricingClient; private readonly IPricingClient _pricingClient;
private readonly IProviderClientOrganizationSignUpCommand _providerClientOrganizationSignUpCommand;
public ProviderService(IProviderRepository providerRepository, IProviderUserRepository providerUserRepository, public ProviderService(IProviderRepository providerRepository, IProviderUserRepository providerUserRepository,
IProviderOrganizationRepository providerOrganizationRepository, IUserRepository userRepository, IProviderOrganizationRepository providerOrganizationRepository, IUserRepository userRepository,
@ -61,7 +63,8 @@ public class ProviderService : IProviderService
IOrganizationRepository organizationRepository, GlobalSettings globalSettings, IOrganizationRepository organizationRepository, GlobalSettings globalSettings,
ICurrentContext currentContext, IStripeAdapter stripeAdapter, IFeatureService featureService, ICurrentContext currentContext, IStripeAdapter stripeAdapter, IFeatureService featureService,
IDataProtectorTokenFactory<ProviderDeleteTokenable> providerDeleteTokenDataFactory, IDataProtectorTokenFactory<ProviderDeleteTokenable> providerDeleteTokenDataFactory,
IApplicationCacheService applicationCacheService, IProviderBillingService providerBillingService, IPricingClient pricingClient) IApplicationCacheService applicationCacheService, IProviderBillingService providerBillingService, IPricingClient pricingClient,
IProviderClientOrganizationSignUpCommand providerClientOrganizationSignUpCommand)
{ {
_providerRepository = providerRepository; _providerRepository = providerRepository;
_providerUserRepository = providerUserRepository; _providerUserRepository = providerUserRepository;
@ -81,6 +84,7 @@ public class ProviderService : IProviderService
_applicationCacheService = applicationCacheService; _applicationCacheService = applicationCacheService;
_providerBillingService = providerBillingService; _providerBillingService = providerBillingService;
_pricingClient = pricingClient; _pricingClient = pricingClient;
_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, TaxInfo taxInfo, TokenizedPaymentSource tokenizedPaymentSource = null)
@ -560,12 +564,12 @@ public class ProviderService : IProviderService
ThrowOnInvalidPlanType(provider.Type, organizationSignup.Plan); ThrowOnInvalidPlanType(provider.Type, organizationSignup.Plan);
var (organization, _, defaultCollection) = await _organizationService.SignupClientAsync(organizationSignup); var signUpResponse = await _providerClientOrganizationSignUpCommand.SignUpClientOrganizationAsync(organizationSignup);
var providerOrganization = new ProviderOrganization var providerOrganization = new ProviderOrganization
{ {
ProviderId = providerId, ProviderId = providerId,
OrganizationId = organization.Id, OrganizationId = signUpResponse.Organization.Id,
Key = organizationSignup.OwnerKey, Key = organizationSignup.OwnerKey,
}; };
@ -574,12 +578,12 @@ public class ProviderService : IProviderService
// Give the owner Can Manage access over the default collection // Give the owner Can Manage access over the default collection
// The orgUser is not available when the org is created so we have to do it here as part of the invite // The orgUser is not available when the org is created so we have to do it here as part of the invite
var defaultOwnerAccess = defaultCollection != null var defaultOwnerAccess = signUpResponse.DefaultCollection != null
? ?
[ [
new CollectionAccessSelection new CollectionAccessSelection
{ {
Id = defaultCollection.Id, Id = signUpResponse.DefaultCollection.Id,
HidePasswords = false, HidePasswords = false,
ReadOnly = false, ReadOnly = false,
Manage = true Manage = true
@ -587,7 +591,7 @@ public class ProviderService : IProviderService
] ]
: Array.Empty<CollectionAccessSelection>(); : Array.Empty<CollectionAccessSelection>();
await _organizationService.InviteUsersAsync(organization.Id, user.Id, systemUser: null, await _organizationService.InviteUsersAsync(signUpResponse.Organization.Id, user.Id, systemUser: null,
new (OrganizationUserInvite, string)[] new (OrganizationUserInvite, string)[]
{ {
( (

View File

@ -1,8 +1,8 @@
using System.Globalization; using System.Globalization;
using Bit.Core.Billing.Entities; using Bit.Core.Billing.Providers.Entities;
using CsvHelper.Configuration.Attributes; using CsvHelper.Configuration.Attributes;
namespace Bit.Commercial.Core.Billing.Models; namespace Bit.Commercial.Core.Billing.Providers.Models;
public class ProviderClientInvoiceReportRow public class ProviderClientInvoiceReportRow
{ {

View File

@ -7,11 +7,12 @@ using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing; using Bit.Core.Billing;
using Bit.Core.Billing.Constants; using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Entities;
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Repositories; using Bit.Core.Billing.Providers.Entities;
using Bit.Core.Billing.Providers.Repositories;
using Bit.Core.Billing.Providers.Services;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Services;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
@ -24,7 +25,7 @@ using Microsoft.Extensions.Logging;
using OneOf; using OneOf;
using Stripe; using Stripe;
namespace Bit.Commercial.Core.Billing; namespace Bit.Commercial.Core.Billing.Providers.Services;
[RequireFeature(FeatureFlagKeys.PM18770_EnableOrganizationBusinessUnitConversion)] [RequireFeature(FeatureFlagKeys.PM18770_EnableOrganizationBusinessUnitConversion)]
public class BusinessUnitConverter( public class BusinessUnitConverter(
@ -67,6 +68,7 @@ public class BusinessUnitConverter(
organization.MaxStorageGb = updatedPlan.PasswordManager.BaseStorageGb; organization.MaxStorageGb = updatedPlan.PasswordManager.BaseStorageGb;
organization.UsePolicies = updatedPlan.HasPolicies; organization.UsePolicies = updatedPlan.HasPolicies;
organization.UseSso = updatedPlan.HasSso; organization.UseSso = updatedPlan.HasSso;
organization.UseOrganizationDomains = updatedPlan.HasOrganizationDomains;
organization.UseGroups = updatedPlan.HasGroups; organization.UseGroups = updatedPlan.HasGroups;
organization.UseEvents = updatedPlan.HasEvents; organization.UseEvents = updatedPlan.HasEvents;
organization.UseDirectory = updatedPlan.HasDirectory; organization.UseDirectory = updatedPlan.HasDirectory;

View File

@ -1,5 +1,5 @@
using System.Globalization; using System.Globalization;
using Bit.Commercial.Core.Billing.Models; using Bit.Commercial.Core.Billing.Providers.Models;
using Bit.Core; using Bit.Core;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Entities.Provider;
@ -8,15 +8,17 @@ using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing; using Bit.Core.Billing;
using Bit.Core.Billing.Caches; using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Constants; using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Entities;
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.Pricing; using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Repositories; using Bit.Core.Billing.Providers.Entities;
using Bit.Core.Billing.Providers.Models;
using Bit.Core.Billing.Providers.Repositories;
using Bit.Core.Billing.Providers.Services;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Services;
using Bit.Core.Billing.Services.Contracts; using Bit.Core.Billing.Tax.Models;
using Bit.Core.Billing.Services.Implementations.AutomaticTax; 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.Models.Business;
@ -25,15 +27,13 @@ using Bit.Core.Services;
using Bit.Core.Settings; using Bit.Core.Settings;
using Braintree; using Braintree;
using CsvHelper; using CsvHelper;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Stripe; using Stripe;
using static Bit.Core.Billing.Utilities; using static Bit.Core.Billing.Utilities;
using Customer = Stripe.Customer; using Customer = Stripe.Customer;
using Subscription = Stripe.Subscription; using Subscription = Stripe.Subscription;
namespace Bit.Commercial.Core.Billing; namespace Bit.Commercial.Core.Billing.Providers.Services;
public class ProviderBillingService( public class ProviderBillingService(
IBraintreeGateway braintreeGateway, IBraintreeGateway braintreeGateway,
@ -50,8 +50,7 @@ public class ProviderBillingService(
ISetupIntentCache setupIntentCache, ISetupIntentCache setupIntentCache,
IStripeAdapter stripeAdapter, IStripeAdapter stripeAdapter,
ISubscriberService subscriberService, ISubscriberService subscriberService,
ITaxService taxService, ITaxService taxService)
[FromKeyedServices(AutomaticTaxFactory.BusinessUse)] IAutomaticTaxStrategy automaticTaxStrategy)
: IProviderBillingService : IProviderBillingService
{ {
public async Task AddExistingOrganization( public async Task AddExistingOrganization(
@ -97,6 +96,7 @@ public class ProviderBillingService(
organization.MaxStorageGb = plan.PasswordManager.BaseStorageGb; organization.MaxStorageGb = plan.PasswordManager.BaseStorageGb;
organization.UsePolicies = plan.HasPolicies; organization.UsePolicies = plan.HasPolicies;
organization.UseSso = plan.HasSso; organization.UseSso = plan.HasSso;
organization.UseOrganizationDomains = plan.HasOrganizationDomains;
organization.UseGroups = plan.HasGroups; organization.UseGroups = plan.HasGroups;
organization.UseEvents = plan.HasEvents; organization.UseEvents = plan.HasEvents;
organization.UseDirectory = plan.HasDirectory; organization.UseDirectory = plan.HasDirectory;
@ -125,7 +125,7 @@ public class ProviderBillingService(
/* /*
* We have to scale the provider's seats before the ProviderOrganization * We have to scale the provider's seats before the ProviderOrganization
* row is inserted so the added organization's seats don't get double counted. * row is inserted so the added organization's seats don't get double-counted.
*/ */
await ScaleSeats(provider, organization.PlanType, organization.Seats!.Value); await ScaleSeats(provider, organization.PlanType, organization.Seats!.Value);
@ -233,7 +233,7 @@ public class ProviderBillingService(
var providerCustomer = await subscriberService.GetCustomerOrThrow(provider, new CustomerGetOptions var providerCustomer = await subscriberService.GetCustomerOrThrow(provider, new CustomerGetOptions
{ {
Expand = ["tax_ids"] Expand = ["tax", "tax_ids"]
}); });
var providerTaxId = providerCustomer.TaxIds.FirstOrDefault(); var providerTaxId = providerCustomer.TaxIds.FirstOrDefault();
@ -281,6 +281,13 @@ public class ProviderBillingService(
] ]
}; };
var setNonUSBusinessUseToReverseCharge = featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge);
if (setNonUSBusinessUseToReverseCharge && providerCustomer.Address is not { Country: "US" })
{
customerCreateOptions.TaxExempt = StripeConstants.TaxExempt.Reverse;
}
var customer = await stripeAdapter.CustomerCreateAsync(customerCreateOptions); var customer = await stripeAdapter.CustomerCreateAsync(customerCreateOptions);
organization.GatewayCustomerId = customer.Id; organization.GatewayCustomerId = customer.Id;
@ -517,6 +524,13 @@ public class ProviderBillingService(
} }
}; };
var setNonUSBusinessUseToReverseCharge = featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge);
if (setNonUSBusinessUseToReverseCharge && taxInfo.BillingAddressCountry != "US")
{
options.TaxExempt = StripeConstants.TaxExempt.Reverse;
}
if (!string.IsNullOrEmpty(taxInfo.TaxIdNumber)) if (!string.IsNullOrEmpty(taxInfo.TaxIdNumber))
{ {
var taxIdType = taxService.GetStripeTaxCode( var taxIdType = taxService.GetStripeTaxCode(
@ -528,6 +542,7 @@ public class ProviderBillingService(
logger.LogWarning("Could not infer tax ID type in country '{Country}' with tax ID '{TaxID}'.", logger.LogWarning("Could not infer tax ID type in country '{Country}' with tax ID '{TaxID}'.",
taxInfo.BillingAddressCountry, taxInfo.BillingAddressCountry,
taxInfo.TaxIdNumber); taxInfo.TaxIdNumber);
throw new BadRequestException("billingTaxIdTypeInferenceError"); throw new BadRequestException("billingTaxIdTypeInferenceError");
} }
@ -692,6 +707,13 @@ public class ProviderBillingService(
customer.Metadata.ContainsKey(BraintreeCustomerIdKey) || customer.Metadata.ContainsKey(BraintreeCustomerIdKey) ||
setupIntent.IsUnverifiedBankAccount()); setupIntent.IsUnverifiedBankAccount());
int? trialPeriodDays = provider.Type switch
{
ProviderType.Msp when usePaymentMethod => 14,
ProviderType.BusinessUnit when usePaymentMethod => 4,
_ => null
};
var subscriptionCreateOptions = new SubscriptionCreateOptions var subscriptionCreateOptions = new SubscriptionCreateOptions
{ {
CollectionMethod = usePaymentMethod ? CollectionMethod = usePaymentMethod ?
@ -705,17 +727,24 @@ public class ProviderBillingService(
}, },
OffSession = true, OffSession = true,
ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations, ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations,
TrialPeriodDays = usePaymentMethod ? 14 : null TrialPeriodDays = trialPeriodDays
}; };
if (featureService.IsEnabled(FeatureFlagKeys.PM19147_AutomaticTaxImprovements)) var setNonUSBusinessUseToReverseCharge =
{ featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge);
automaticTaxStrategy.SetCreateOptions(subscriptionCreateOptions, customer);
} if (setNonUSBusinessUseToReverseCharge)
else
{ {
subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }; subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true };
} }
else if (customer.HasRecognizedTaxLocation())
{
subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions
{
Enabled = customer.Address.Country == "US" ||
customer.TaxIds.Any()
};
}
try try
{ {

View File

@ -6,7 +6,7 @@ using Bit.Core.Billing;
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
using Stripe; using Stripe;
namespace Bit.Commercial.Core.Billing; namespace Bit.Commercial.Core.Billing.Providers.Services;
public static class ProviderPriceAdapter public static class ProviderPriceAdapter
{ {

View File

@ -1,13 +1,9 @@
using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Licenses;
using Bit.Core.Billing.Licenses.Extensions;
using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Pricing;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.SecretsManager.Queries.Projects.Interfaces; using Bit.Core.SecretsManager.Queries.Projects.Interfaces;
using Bit.Core.SecretsManager.Repositories; using Bit.Core.SecretsManager.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings; using Bit.Core.Settings;
namespace Bit.Commercial.Core.SecretsManager.Queries.Projects; namespace Bit.Commercial.Core.SecretsManager.Queries.Projects;
@ -17,72 +13,42 @@ public class MaxProjectsQuery : IMaxProjectsQuery
private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationRepository _organizationRepository;
private readonly IProjectRepository _projectRepository; private readonly IProjectRepository _projectRepository;
private readonly IGlobalSettings _globalSettings; private readonly IGlobalSettings _globalSettings;
private readonly ILicensingService _licensingService;
private readonly IPricingClient _pricingClient; private readonly IPricingClient _pricingClient;
public MaxProjectsQuery( public MaxProjectsQuery(
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
IProjectRepository projectRepository, IProjectRepository projectRepository,
IGlobalSettings globalSettings, IGlobalSettings globalSettings,
ILicensingService licensingService,
IPricingClient pricingClient) IPricingClient pricingClient)
{ {
_organizationRepository = organizationRepository; _organizationRepository = organizationRepository;
_projectRepository = projectRepository; _projectRepository = projectRepository;
_globalSettings = globalSettings; _globalSettings = globalSettings;
_licensingService = licensingService;
_pricingClient = pricingClient; _pricingClient = pricingClient;
} }
public async Task<(short? max, bool? overMax)> GetByOrgIdAsync(Guid organizationId, int projectsToAdd) public async Task<(short? max, bool? overMax)> GetByOrgIdAsync(Guid organizationId, int projectsToAdd)
{ {
// "MaxProjects" only applies to free 2-person organizations, which can't be self-hosted.
if (_globalSettings.SelfHosted)
{
return (null, null);
}
var org = await _organizationRepository.GetByIdAsync(organizationId); var org = await _organizationRepository.GetByIdAsync(organizationId);
if (org == null) if (org == null)
{ {
throw new NotFoundException(); throw new NotFoundException();
} }
var (planType, maxProjects) = await GetPlanTypeAndMaxProjectsAsync(org); var plan = await _pricingClient.GetPlan(org.PlanType);
if (planType != PlanType.Free) if (plan is not { SecretsManager: not null, Type: PlanType.Free })
{ {
return (null, null); return (null, null);
} }
var projects = await _projectRepository.GetProjectCountByOrganizationIdAsync(organizationId); var projects = await _projectRepository.GetProjectCountByOrganizationIdAsync(organizationId);
return ((short? max, bool? overMax))(projects + projectsToAdd > maxProjects ? (maxProjects, true) : (maxProjects, false)); return ((short? max, bool? overMax))(projects + projectsToAdd > plan.SecretsManager.MaxProjects ? (plan.SecretsManager.MaxProjects, true) : (plan.SecretsManager.MaxProjects, false));
}
private async Task<(PlanType planType, int maxProjects)> GetPlanTypeAndMaxProjectsAsync(Organization organization)
{
if (_globalSettings.SelfHosted)
{
var license = await _licensingService.ReadOrganizationLicenseAsync(organization);
if (license == null)
{
throw new BadRequestException("License not found.");
}
var claimsPrincipal = _licensingService.GetClaimsPrincipalFromLicense(license);
var maxProjects = claimsPrincipal.GetValue<int?>(OrganizationLicenseConstants.SmMaxProjects);
if (!maxProjects.HasValue)
{
throw new BadRequestException("License does not contain a value for max Secrets Manager projects");
}
var planType = claimsPrincipal.GetValue<PlanType>(OrganizationLicenseConstants.PlanType);
return (planType, maxProjects.Value);
}
var plan = await _pricingClient.GetPlan(organization.PlanType);
if (plan is { SupportsSecretsManager: true })
{
return (plan.Type, plan.SecretsManager.MaxProjects);
}
throw new BadRequestException("Existing plan not found.");
} }
} }

View File

@ -1,9 +1,9 @@
using Bit.Commercial.Core.AdminConsole.Providers; using Bit.Commercial.Core.AdminConsole.Providers;
using Bit.Commercial.Core.AdminConsole.Services; using Bit.Commercial.Core.AdminConsole.Services;
using Bit.Commercial.Core.Billing; using Bit.Commercial.Core.Billing.Providers.Services;
using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Providers.Interfaces;
using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Providers.Services;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
namespace Bit.Commercial.Core.Utilities; namespace Bit.Commercial.Core.Utilities;

View File

@ -6,10 +6,10 @@ using Bit.Core.AdminConsole.Models.Business;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
using Bit.Core.AdminConsole.Utilities.Commands;
using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Pricing;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Models.Commands;
using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
@ -76,9 +76,8 @@ public class PostUserCommand(
var invitedOrganizationUserId = result switch var invitedOrganizationUserId = result switch
{ {
Success<ScimInviteOrganizationUsersResponse> success => success.Value.InvitedUser.Id, Success<ScimInviteOrganizationUsersResponse> success => success.Value.InvitedUser.Id,
Failure<ScimInviteOrganizationUsersResponse> failure when failure.Errors Failure<ScimInviteOrganizationUsersResponse> { Error.Message: NoUsersToInviteError.Code } => (Guid?)null,
.Any(x => x.Message == NoUsersToInviteError.Code) => (Guid?)null, Failure<ScimInviteOrganizationUsersResponse> failure => throw MapToBitException(failure.Error),
Failure<ScimInviteOrganizationUsersResponse> failure when failure.Errors.Length != 0 => throw MapToBitException(failure.Errors),
_ => throw new InvalidOperationException() _ => throw new InvalidOperationException()
}; };

View File

@ -15,7 +15,7 @@
}, },
"devDependencies": { "devDependencies": {
"css-loader": "7.1.2", "css-loader": "7.1.2",
"expose-loader": "5.0.0", "expose-loader": "5.0.1",
"mini-css-extract-plugin": "2.9.2", "mini-css-extract-plugin": "2.9.2",
"sass": "1.85.0", "sass": "1.85.0",
"sass-loader": "16.0.4", "sass-loader": "16.0.4",
@ -1083,9 +1083,9 @@
} }
}, },
"node_modules/expose-loader": { "node_modules/expose-loader": {
"version": "5.0.0", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/expose-loader/-/expose-loader-5.0.0.tgz", "resolved": "https://registry.npmjs.org/expose-loader/-/expose-loader-5.0.1.tgz",
"integrity": "sha512-BtUqYRmvx1bEY5HN6eK2I9URUZgNmN0x5UANuocaNjXSgfoDlkXt+wyEMe7i5DzDNh2BKJHPc5F4rBwEdSQX6w==", "integrity": "sha512-5YPZuszN/eWND/B+xuq5nIpb/l5TV1HYmdO6SubYtHv+HenVw9/6bn33Mm5reY8DNid7AVtbARvyUD34edfCtg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {

View File

@ -14,7 +14,7 @@
}, },
"devDependencies": { "devDependencies": {
"css-loader": "7.1.2", "css-loader": "7.1.2",
"expose-loader": "5.0.0", "expose-loader": "5.0.1",
"mini-css-extract-plugin": "2.9.2", "mini-css-extract-plugin": "2.9.2",
"sass": "1.85.0", "sass": "1.85.0",
"sass-loader": "16.0.4", "sass-loader": "16.0.4",

View File

@ -1,4 +1,5 @@
using Bit.Commercial.Core.AdminConsole.Providers; using Bit.Commercial.Core.AdminConsole.Providers;
using Bit.Core;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Enums.Provider;
@ -7,6 +8,7 @@ using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Constants; using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Providers.Services;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Services;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
@ -223,31 +225,115 @@ public class RemoveOrganizationFromProviderCommandTests
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>(); var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, Arg.Is<CustomerUpdateOptions>(options =>
options.Description == string.Empty &&
options.Email == organization.BillingEmail &&
options.Expand[0] == "tax" &&
options.Expand[1] == "tax_ids")).Returns(new Customer
{
Id = "customer_id",
Address = new Address
{
Country = "US"
}
});
stripeAdapter.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(new Subscription stripeAdapter.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(new Subscription
{ {
Id = "subscription_id" Id = "subscription_id"
}); });
sutProvider.GetDependency<IAutomaticTaxStrategy>() await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization);
.When(x => x.SetCreateOptions(
Arg.Is<SubscriptionCreateOptions>(options => await stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(options =>
options.Customer == organization.GatewayCustomerId && options.Customer == organization.GatewayCustomerId &&
options.CollectionMethod == StripeConstants.CollectionMethod.SendInvoice && options.CollectionMethod == StripeConstants.CollectionMethod.SendInvoice &&
options.DaysUntilDue == 30 && options.DaysUntilDue == 30 &&
options.Metadata["organizationId"] == organization.Id.ToString() && options.AutomaticTax.Enabled == true &&
options.OffSession == true && options.Metadata["organizationId"] == organization.Id.ToString() &&
options.ProrationBehavior == StripeConstants.ProrationBehavior.CreateProrations && options.OffSession == true &&
options.Items.First().Price == teamsMonthlyPlan.PasswordManager.StripeSeatPlanId && options.ProrationBehavior == StripeConstants.ProrationBehavior.CreateProrations &&
options.Items.First().Quantity == organization.Seats) options.Items.First().Price == teamsMonthlyPlan.PasswordManager.StripeSeatPlanId &&
, Arg.Any<Customer>())) options.Items.First().Quantity == organization.Seats));
.Do(x =>
await sutProvider.GetDependency<IProviderBillingService>().Received(1)
.ScaleSeats(provider, organization.PlanType, -organization.Seats ?? 0);
await organizationRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(
org =>
org.BillingEmail == "a@example.com" &&
org.GatewaySubscriptionId == "subscription_id" &&
org.Status == OrganizationStatusType.Created));
await sutProvider.GetDependency<IProviderOrganizationRepository>().Received(1)
.DeleteAsync(providerOrganization);
await sutProvider.GetDependency<IEventService>().Received(1)
.LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_Removed);
await sutProvider.GetDependency<IMailService>().Received(1)
.SendProviderUpdatePaymentMethod(
organization.Id,
organization.Name,
provider.Name,
Arg.Is<IEnumerable<string>>(emails => emails.FirstOrDefault() == "a@example.com"));
}
[Theory, BitAutoData]
public async Task RemoveOrganizationFromProvider_OrganizationStripeEnabled_ConsolidatedBilling_ReverseCharge_MakesCorrectInvocations(
Provider provider,
ProviderOrganization providerOrganization,
Organization organization,
SutProvider<RemoveOrganizationFromProviderCommand> sutProvider)
{
provider.Status = ProviderStatusType.Billable;
providerOrganization.ProviderId = provider.Id;
organization.Status = OrganizationStatusType.Managed;
organization.PlanType = PlanType.TeamsMonthly;
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(PlanType.TeamsMonthly).Returns(teamsMonthlyPlan);
sutProvider.GetDependency<IHasConfirmedOwnersExceptQuery>().HasConfirmedOwnersExceptAsync(
providerOrganization.OrganizationId,
[],
includeProvider: false)
.Returns(true);
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
organizationRepository.GetOwnerEmailAddressesById(organization.Id).Returns([
"a@example.com",
"b@example.com"
]);
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, Arg.Is<CustomerUpdateOptions>(options =>
options.Description == string.Empty &&
options.Email == organization.BillingEmail &&
options.Expand[0] == "tax" &&
options.Expand[1] == "tax_ids")).Returns(new Customer
{ {
x.Arg<SubscriptionCreateOptions>().AutomaticTax = new SubscriptionAutomaticTaxOptions Id = "customer_id",
Address = new Address
{ {
Enabled = true Country = "US"
}; }
}); });
stripeAdapter.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(new Subscription
{
Id = "subscription_id"
});
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge).Returns(true);
await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization); await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization);
await stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(options => await stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(options =>

View File

@ -6,11 +6,12 @@ using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Models.Business.Provider; using Bit.Core.AdminConsole.Models.Business.Provider;
using Bit.Core.AdminConsole.Models.Business.Tokenables; using Bit.Core.AdminConsole.Models.Business.Tokenables;
using Bit.Core.AdminConsole.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.Models;
using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Providers.Services;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
@ -717,8 +718,8 @@ public class ProviderServiceTests
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider); sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>(); var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
sutProvider.GetDependency<IOrganizationService>().SignupClientAsync(organizationSignup) sutProvider.GetDependency<IProviderClientOrganizationSignUpCommand>().SignUpClientOrganizationAsync(organizationSignup)
.Returns((organization, null as OrganizationUser, new Collection())); .Returns(new ProviderClientOrganizationSignUpResponse(organization, new Collection()));
var providerOrganization = var providerOrganization =
await sutProvider.Sut.CreateOrganizationAsync(provider.Id, organizationSignup, clientOwnerEmail, user); await sutProvider.Sut.CreateOrganizationAsync(provider.Id, organizationSignup, clientOwnerEmail, user);
@ -755,8 +756,8 @@ public class ProviderServiceTests
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>(); var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
sutProvider.GetDependency<IOrganizationService>().SignupClientAsync(organizationSignup) sutProvider.GetDependency<IProviderClientOrganizationSignUpCommand>().SignUpClientOrganizationAsync(organizationSignup)
.Returns((organization, null as OrganizationUser, new Collection())); .Returns(new ProviderClientOrganizationSignUpResponse(organization, new Collection()));
await Assert.ThrowsAsync<BadRequestException>(() => await Assert.ThrowsAsync<BadRequestException>(() =>
sutProvider.Sut.CreateOrganizationAsync(provider.Id, organizationSignup, clientOwnerEmail, user)); sutProvider.Sut.CreateOrganizationAsync(provider.Id, organizationSignup, clientOwnerEmail, user));
@ -782,8 +783,8 @@ public class ProviderServiceTests
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>(); var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
sutProvider.GetDependency<IOrganizationService>().SignupClientAsync(organizationSignup) sutProvider.GetDependency<IProviderClientOrganizationSignUpCommand>().SignUpClientOrganizationAsync(organizationSignup)
.Returns((organization, null as OrganizationUser, new Collection())); .Returns(new ProviderClientOrganizationSignUpResponse(organization, new Collection()));
var providerOrganization = await sutProvider.Sut.CreateOrganizationAsync(provider.Id, organizationSignup, clientOwnerEmail, user); var providerOrganization = await sutProvider.Sut.CreateOrganizationAsync(provider.Id, organizationSignup, clientOwnerEmail, user);
@ -821,8 +822,8 @@ public class ProviderServiceTests
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider); sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>(); var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
sutProvider.GetDependency<IOrganizationService>().SignupClientAsync(organizationSignup) sutProvider.GetDependency<IProviderClientOrganizationSignUpCommand>().SignUpClientOrganizationAsync(organizationSignup)
.Returns((organization, null as OrganizationUser, defaultCollection)); .Returns(new ProviderClientOrganizationSignUpResponse(organization, defaultCollection));
var providerOrganization = var providerOrganization =
await sutProvider.Sut.CreateOrganizationAsync(provider.Id, organizationSignup, clientOwnerEmail, user); await sutProvider.Sut.CreateOrganizationAsync(provider.Id, organizationSignup, clientOwnerEmail, user);

View File

@ -1,16 +1,16 @@
#nullable enable #nullable enable
using System.Text; using System.Text;
using Bit.Commercial.Core.Billing; using Bit.Commercial.Core.Billing.Providers.Services;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing; using Bit.Core.Billing;
using Bit.Core.Billing.Constants; using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Entities;
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Repositories; using Bit.Core.Billing.Providers.Entities;
using Bit.Core.Billing.Providers.Repositories;
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;
@ -25,7 +25,7 @@ using NSubstitute;
using Stripe; using Stripe;
using Xunit; using Xunit;
namespace Bit.Commercial.Core.Test.Billing; namespace Bit.Commercial.Core.Test.Billing.Providers;
public class BusinessUnitConverterTests public class BusinessUnitConverterTests
{ {

View File

@ -1,7 +1,7 @@
using System.Globalization; using System.Globalization;
using System.Net; using System.Net;
using Bit.Commercial.Core.Billing; using Bit.Commercial.Core.Billing.Providers.Models;
using Bit.Commercial.Core.Billing.Models; using Bit.Commercial.Core.Billing.Providers.Services;
using Bit.Core; using Bit.Core;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Entities.Provider;
@ -10,13 +10,14 @@ using Bit.Core.AdminConsole.Models.Data.Provider;
using Bit.Core.AdminConsole.Repositories; 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.Entities;
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Models; using Bit.Core.Billing.Models;
using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Repositories; using Bit.Core.Billing.Providers.Entities;
using Bit.Core.Billing.Providers.Models;
using Bit.Core.Billing.Providers.Repositories;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Services;
using Bit.Core.Billing.Services.Contracts; 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;
@ -39,7 +40,7 @@ using Customer = Stripe.Customer;
using PaymentMethod = Stripe.PaymentMethod; using PaymentMethod = Stripe.PaymentMethod;
using Subscription = Stripe.Subscription; using Subscription = Stripe.Subscription;
namespace Bit.Commercial.Core.Test.Billing; namespace Bit.Commercial.Core.Test.Billing.Providers;
[SutProviderCustomize] [SutProviderCustomize]
public class ProviderBillingServiceTests public class ProviderBillingServiceTests
@ -261,7 +262,7 @@ public class ProviderBillingServiceTests
}; };
sutProvider.GetDependency<ISubscriberService>().GetCustomerOrThrow(provider, Arg.Is<CustomerGetOptions>( sutProvider.GetDependency<ISubscriberService>().GetCustomerOrThrow(provider, Arg.Is<CustomerGetOptions>(
options => options.Expand.FirstOrDefault() == "tax_ids")) options => options.Expand.Contains("tax") && options.Expand.Contains("tax_ids")))
.Returns(providerCustomer); .Returns(providerCustomer);
sutProvider.GetDependency<IGlobalSettings>().BaseServiceUri sutProvider.GetDependency<IGlobalSettings>().BaseServiceUri
@ -311,6 +312,91 @@ public class ProviderBillingServiceTests
org => org.GatewayCustomerId == "customer_id")); org => org.GatewayCustomerId == "customer_id"));
} }
[Theory, BitAutoData]
public async Task CreateCustomer_ForClientOrg_ReverseCharge_Succeeds(
Provider provider,
Organization organization,
SutProvider<ProviderBillingService> sutProvider)
{
organization.GatewayCustomerId = null;
organization.Name = "Name";
organization.BusinessName = "BusinessName";
var providerCustomer = new Customer
{
Address = new Address
{
Country = "CA",
PostalCode = "12345",
Line1 = "123 Main St.",
Line2 = "Unit 4",
City = "Fake Town",
State = "Fake State"
},
TaxIds = new StripeList<TaxId>
{
Data =
[
new TaxId { Type = "TYPE", Value = "VALUE" }
]
}
};
sutProvider.GetDependency<ISubscriberService>().GetCustomerOrThrow(provider, Arg.Is<CustomerGetOptions>(
options => options.Expand.Contains("tax") && options.Expand.Contains("tax_ids")))
.Returns(providerCustomer);
sutProvider.GetDependency<IGlobalSettings>().BaseServiceUri
.Returns(new Bit.Core.Settings.GlobalSettings.BaseServiceUriSettings(new Bit.Core.Settings.GlobalSettings())
{
CloudRegion = "US"
});
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge).Returns(true);
sutProvider.GetDependency<IStripeAdapter>().CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(
options =>
options.Address.Country == providerCustomer.Address.Country &&
options.Address.PostalCode == providerCustomer.Address.PostalCode &&
options.Address.Line1 == providerCustomer.Address.Line1 &&
options.Address.Line2 == providerCustomer.Address.Line2 &&
options.Address.City == providerCustomer.Address.City &&
options.Address.State == providerCustomer.Address.State &&
options.Name == organization.DisplayName() &&
options.Description == $"{provider.Name} Client Organization" &&
options.Email == provider.BillingEmail &&
options.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Organization" &&
options.InvoiceSettings.CustomFields.FirstOrDefault().Value == "Name" &&
options.Metadata["region"] == "US" &&
options.TaxIdData.FirstOrDefault().Type == providerCustomer.TaxIds.FirstOrDefault().Type &&
options.TaxIdData.FirstOrDefault().Value == providerCustomer.TaxIds.FirstOrDefault().Value &&
options.TaxExempt == StripeConstants.TaxExempt.Reverse))
.Returns(new Customer { Id = "customer_id" });
await sutProvider.Sut.CreateCustomerForClientOrganization(provider, organization);
await sutProvider.GetDependency<IStripeAdapter>().Received(1).CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(
options =>
options.Address.Country == providerCustomer.Address.Country &&
options.Address.PostalCode == providerCustomer.Address.PostalCode &&
options.Address.Line1 == providerCustomer.Address.Line1 &&
options.Address.Line2 == providerCustomer.Address.Line2 &&
options.Address.City == providerCustomer.Address.City &&
options.Address.State == providerCustomer.Address.State &&
options.Name == organization.DisplayName() &&
options.Description == $"{provider.Name} Client Organization" &&
options.Email == provider.BillingEmail &&
options.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Organization" &&
options.InvoiceSettings.CustomFields.FirstOrDefault().Value == "Name" &&
options.Metadata["region"] == "US" &&
options.TaxIdData.FirstOrDefault().Type == providerCustomer.TaxIds.FirstOrDefault().Type &&
options.TaxIdData.FirstOrDefault().Value == providerCustomer.TaxIds.FirstOrDefault().Value));
await sutProvider.GetDependency<IOrganizationRepository>().Received(1).ReplaceAsync(Arg.Is<Organization>(
org => org.GatewayCustomerId == "customer_id"));
}
#endregion #endregion
#region GenerateClientInvoiceReport #region GenerateClientInvoiceReport
@ -1181,6 +1267,62 @@ public class ProviderBillingServiceTests
Assert.Equivalent(expected, actual); Assert.Equivalent(expected, actual);
} }
[Theory, BitAutoData]
public async Task SetupCustomer_WithCard_ReverseCharge_Success(
SutProvider<ProviderBillingService> sutProvider,
Provider provider,
TaxInfo taxInfo)
{
provider.Name = "MSP";
sutProvider.GetDependency<ITaxService>()
.GetStripeTaxCode(Arg.Is<string>(
p => p == taxInfo.BillingAddressCountry),
Arg.Is<string>(p => p == taxInfo.TaxIdNumber))
.Returns(taxInfo.TaxIdType);
taxInfo.BillingAddressCountry = "AD";
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
var expected = new Customer
{
Id = "customer_id",
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
};
var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.Card, "token");
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge).Returns(true);
stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o =>
o.Address.Country == taxInfo.BillingAddressCountry &&
o.Address.PostalCode == taxInfo.BillingAddressPostalCode &&
o.Address.Line1 == taxInfo.BillingAddressLine1 &&
o.Address.Line2 == taxInfo.BillingAddressLine2 &&
o.Address.City == taxInfo.BillingAddressCity &&
o.Address.State == taxInfo.BillingAddressState &&
o.Description == WebUtility.HtmlDecode(provider.BusinessName) &&
o.Email == provider.BillingEmail &&
o.PaymentMethod == tokenizedPaymentSource.Token &&
o.InvoiceSettings.DefaultPaymentMethod == tokenizedPaymentSource.Token &&
o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" &&
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" &&
o.Metadata["region"] == "" &&
o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType &&
o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber &&
o.TaxExempt == StripeConstants.TaxExempt.Reverse))
.Returns(expected);
var actual = await sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource);
Assert.Equivalent(expected, actual);
}
[Theory, BitAutoData] [Theory, BitAutoData]
public async Task SetupCustomer_Throws_BadRequestException_WhenTaxIdIsInvalid( public async Task SetupCustomer_Throws_BadRequestException_WhenTaxIdIsInvalid(
SutProvider<ProviderBillingService> sutProvider, SutProvider<ProviderBillingService> sutProvider,
@ -1306,7 +1448,7 @@ public class ProviderBillingServiceTests
.Returns(new Customer .Returns(new Customer
{ {
Id = "customer_id", Id = "customer_id",
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported } Address = new Address { Country = "US" }
}); });
var providerPlans = new List<ProviderPlan> var providerPlans = new List<ProviderPlan>
@ -1358,7 +1500,7 @@ public class ProviderBillingServiceTests
var customer = new Customer var customer = new Customer
{ {
Id = "customer_id", Id = "customer_id",
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported } Address = new Address { Country = "US" }
}; };
sutProvider.GetDependency<ISubscriberService>() sutProvider.GetDependency<ISubscriberService>()
.GetCustomerOrThrow( .GetCustomerOrThrow(
@ -1398,19 +1540,6 @@ public class ProviderBillingServiceTests
var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active }; var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active };
sutProvider.GetDependency<IAutomaticTaxStrategy>()
.When(x => x.SetCreateOptions(
Arg.Is<SubscriptionCreateOptions>(options =>
options.Customer == "customer_id")
, Arg.Is<Customer>(p => p == customer)))
.Do(x =>
{
x.Arg<SubscriptionCreateOptions>().AutomaticTax = new SubscriptionAutomaticTaxOptions
{
Enabled = true
};
});
sutProvider.GetDependency<IStripeAdapter>().SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>( sutProvider.GetDependency<IStripeAdapter>().SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(
sub => sub =>
sub.AutomaticTax.Enabled == true && sub.AutomaticTax.Enabled == true &&
@ -1442,11 +1571,11 @@ public class ProviderBillingServiceTests
var customer = new Customer var customer = new Customer
{ {
Id = "customer_id", Id = "customer_id",
Address = new Address { Country = "US" },
InvoiceSettings = new CustomerInvoiceSettings InvoiceSettings = new CustomerInvoiceSettings
{ {
DefaultPaymentMethodId = "pm_123" DefaultPaymentMethodId = "pm_123"
}, }
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
}; };
sutProvider.GetDependency<ISubscriberService>() sutProvider.GetDependency<ISubscriberService>()
@ -1487,19 +1616,6 @@ public class ProviderBillingServiceTests
var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active }; var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active };
sutProvider.GetDependency<IAutomaticTaxStrategy>()
.When(x => x.SetCreateOptions(
Arg.Is<SubscriptionCreateOptions>(options =>
options.Customer == "customer_id")
, Arg.Is<Customer>(p => p == customer)))
.Do(x =>
{
x.Arg<SubscriptionCreateOptions>().AutomaticTax = new SubscriptionAutomaticTaxOptions
{
Enabled = true
};
});
sutProvider.GetDependency<IFeatureService>() sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true); .IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
@ -1535,9 +1651,9 @@ public class ProviderBillingServiceTests
var customer = new Customer var customer = new Customer
{ {
Id = "customer_id", Id = "customer_id",
Address = new Address { Country = "US" },
InvoiceSettings = new CustomerInvoiceSettings(), InvoiceSettings = new CustomerInvoiceSettings(),
Metadata = new Dictionary<string, string>(), Metadata = new Dictionary<string, string>()
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
}; };
sutProvider.GetDependency<ISubscriberService>() sutProvider.GetDependency<ISubscriberService>()
@ -1578,19 +1694,6 @@ public class ProviderBillingServiceTests
var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active }; var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active };
sutProvider.GetDependency<IAutomaticTaxStrategy>()
.When(x => x.SetCreateOptions(
Arg.Is<SubscriptionCreateOptions>(options =>
options.Customer == "customer_id")
, Arg.Is<Customer>(p => p == customer)))
.Do(x =>
{
x.Arg<SubscriptionCreateOptions>().AutomaticTax = new SubscriptionAutomaticTaxOptions
{
Enabled = true
};
});
sutProvider.GetDependency<IFeatureService>() sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true); .IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
@ -1645,12 +1748,15 @@ public class ProviderBillingServiceTests
var customer = new Customer var customer = new Customer
{ {
Id = "customer_id", Id = "customer_id",
Address = new Address
{
Country = "US"
},
InvoiceSettings = new CustomerInvoiceSettings(), InvoiceSettings = new CustomerInvoiceSettings(),
Metadata = new Dictionary<string, string> Metadata = new Dictionary<string, string>
{ {
["btCustomerId"] = "braintree_customer_id" ["btCustomerId"] = "braintree_customer_id"
}, }
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
}; };
sutProvider.GetDependency<ISubscriberService>() sutProvider.GetDependency<ISubscriberService>()
@ -1691,22 +1797,92 @@ public class ProviderBillingServiceTests
var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active }; var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active };
sutProvider.GetDependency<IAutomaticTaxStrategy>() sutProvider.GetDependency<IFeatureService>()
.When(x => x.SetCreateOptions( .IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
Arg.Is<SubscriptionCreateOptions>(options =>
options.Customer == "customer_id") sutProvider.GetDependency<IStripeAdapter>().SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(
, Arg.Is<Customer>(p => p == customer))) sub =>
.Do(x => sub.AutomaticTax.Enabled == true &&
sub.CollectionMethod == StripeConstants.CollectionMethod.ChargeAutomatically &&
sub.Customer == "customer_id" &&
sub.DaysUntilDue == null &&
sub.Items.Count == 2 &&
sub.Items.ElementAt(0).Price == ProviderPriceAdapter.MSP.Active.Teams &&
sub.Items.ElementAt(0).Quantity == 100 &&
sub.Items.ElementAt(1).Price == ProviderPriceAdapter.MSP.Active.Enterprise &&
sub.Items.ElementAt(1).Quantity == 100 &&
sub.Metadata["providerId"] == provider.Id.ToString() &&
sub.OffSession == true &&
sub.ProrationBehavior == StripeConstants.ProrationBehavior.CreateProrations &&
sub.TrialPeriodDays == 14)).Returns(expected);
var actual = await sutProvider.Sut.SetupSubscription(provider);
Assert.Equivalent(expected, actual);
}
[Theory, BitAutoData]
public async Task SetupSubscription_ReverseCharge_Succeeds(
SutProvider<ProviderBillingService> sutProvider,
Provider provider)
{
provider.Type = ProviderType.Msp;
provider.GatewaySubscriptionId = null;
var customer = new Customer
{
Id = "customer_id",
Address = new Address { Country = "CA" },
InvoiceSettings = new CustomerInvoiceSettings
{ {
x.Arg<SubscriptionCreateOptions>().AutomaticTax = new SubscriptionAutomaticTaxOptions DefaultPaymentMethodId = "pm_123"
{ }
Enabled = true };
};
}); sutProvider.GetDependency<ISubscriberService>()
.GetCustomerOrThrow(
provider,
Arg.Is<CustomerGetOptions>(p => p.Expand.Contains("tax") || p.Expand.Contains("tax_ids"))).Returns(customer);
var providerPlans = new List<ProviderPlan>
{
new()
{
Id = Guid.NewGuid(),
ProviderId = provider.Id,
PlanType = PlanType.TeamsMonthly,
SeatMinimum = 100,
PurchasedSeats = 0,
AllocatedSeats = 0
},
new()
{
Id = Guid.NewGuid(),
ProviderId = provider.Id,
PlanType = PlanType.EnterpriseMonthly,
SeatMinimum = 100,
PurchasedSeats = 0,
AllocatedSeats = 0
}
};
foreach (var plan in providerPlans)
{
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(StaticStore.GetPlan(plan.PlanType));
}
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
.Returns(providerPlans);
var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active };
sutProvider.GetDependency<IFeatureService>() sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true); .IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge).Returns(true);
sutProvider.GetDependency<IStripeAdapter>().SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>( sutProvider.GetDependency<IStripeAdapter>().SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(
sub => sub =>
sub.AutomaticTax.Enabled == true && sub.AutomaticTax.Enabled == true &&

View File

@ -1,11 +1,11 @@
using Bit.Commercial.Core.Billing; 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 Stripe; using Stripe;
using Xunit; using Xunit;
namespace Bit.Commercial.Core.Test.Billing; namespace Bit.Commercial.Core.Test.Billing.Providers;
public class ProviderPriceAdapterTests public class ProviderPriceAdapterTests
{ {

View File

@ -1,9 +1,9 @@
using Bit.Core.Billing.Services; using Bit.Core.Billing.Tax.Services.Implementations;
using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes; using Bit.Test.Common.AutoFixture.Attributes;
using Xunit; using Xunit;
namespace Bit.Commercial.Core.Test.Billing; namespace Bit.Commercial.Core.Test.Billing.Tax;
[SutProviderCustomize] [SutProviderCustomize]
public class TaxServiceTests public class TaxServiceTests

View File

@ -1,14 +1,10 @@
using System.Security.Claims; using Bit.Commercial.Core.SecretsManager.Queries.Projects;
using Bit.Commercial.Core.SecretsManager.Queries.Projects;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Licenses;
using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Pricing;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Models.Business;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.SecretsManager.Repositories; using Bit.Core.SecretsManager.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings; using Bit.Core.Settings;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture;
@ -22,11 +18,26 @@ namespace Bit.Commercial.Core.Test.SecretsManager.Queries.Projects;
[SutProviderCustomize] [SutProviderCustomize]
public class MaxProjectsQueryTests public class MaxProjectsQueryTests
{ {
[Theory]
[BitAutoData]
public async Task GetByOrgIdAsync_SelfHosted_ReturnsNulls(SutProvider<MaxProjectsQuery> sutProvider,
Guid organizationId)
{
sutProvider.GetDependency<IGlobalSettings>().SelfHosted.Returns(true);
var (max, overMax) = await sutProvider.Sut.GetByOrgIdAsync(organizationId, 1);
Assert.Null(max);
Assert.Null(overMax);
}
[Theory] [Theory]
[BitAutoData] [BitAutoData]
public async Task GetByOrgIdAsync_OrganizationIsNull_ThrowsNotFound(SutProvider<MaxProjectsQuery> sutProvider, public async Task GetByOrgIdAsync_OrganizationIsNull_ThrowsNotFound(SutProvider<MaxProjectsQuery> sutProvider,
Guid organizationId) Guid organizationId)
{ {
sutProvider.GetDependency<IGlobalSettings>().SelfHosted.Returns(false);
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(default).ReturnsNull(); sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(default).ReturnsNull();
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.GetByOrgIdAsync(organizationId, 1)); await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.GetByOrgIdAsync(organizationId, 1));
@ -35,54 +46,6 @@ public class MaxProjectsQueryTests
.GetProjectCountByOrganizationIdAsync(organizationId); .GetProjectCountByOrganizationIdAsync(organizationId);
} }
[Theory]
[BitAutoData(PlanType.FamiliesAnnually2019)]
[BitAutoData(PlanType.Custom)]
[BitAutoData(PlanType.FamiliesAnnually)]
public async Task GetByOrgIdAsync_Cloud_SmPlanIsNull_ThrowsBadRequest(PlanType planType,
SutProvider<MaxProjectsQuery> sutProvider, Organization organization)
{
organization.PlanType = planType;
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(organization.Id)
.Returns(organization);
sutProvider.GetDependency<IGlobalSettings>().SelfHosted.Returns(false);
var plan = StaticStore.GetPlan(planType);
sutProvider.GetDependency<IPricingClient>().GetPlan(organization.PlanType).Returns(plan);
await Assert.ThrowsAsync<BadRequestException>(
async () => await sutProvider.Sut.GetByOrgIdAsync(organization.Id, 1));
await sutProvider.GetDependency<IProjectRepository>()
.DidNotReceiveWithAnyArgs()
.GetProjectCountByOrganizationIdAsync(organization.Id);
}
[Theory]
[BitAutoData]
public async Task GetByOrgIdAsync_SelfHosted_NoMaxProjectsClaim_ThrowsBadRequest(
SutProvider<MaxProjectsQuery> sutProvider, Organization organization)
{
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(organization.Id)
.Returns(organization);
sutProvider.GetDependency<IGlobalSettings>().SelfHosted.Returns(true);
var license = new OrganizationLicense();
var claimsPrincipal = new ClaimsPrincipal();
sutProvider.GetDependency<ILicensingService>().ReadOrganizationLicenseAsync(organization).Returns(license);
sutProvider.GetDependency<ILicensingService>().GetClaimsPrincipalFromLicense(license).Returns(claimsPrincipal);
await Assert.ThrowsAsync<BadRequestException>(
async () => await sutProvider.Sut.GetByOrgIdAsync(organization.Id, 1));
await sutProvider.GetDependency<IProjectRepository>()
.DidNotReceiveWithAnyArgs()
.GetProjectCountByOrganizationIdAsync(organization.Id);
}
[Theory] [Theory]
[BitAutoData(PlanType.TeamsMonthly2019)] [BitAutoData(PlanType.TeamsMonthly2019)]
[BitAutoData(PlanType.TeamsMonthly2020)] [BitAutoData(PlanType.TeamsMonthly2020)]
@ -97,57 +60,16 @@ public class MaxProjectsQueryTests
[BitAutoData(PlanType.EnterpriseAnnually2019)] [BitAutoData(PlanType.EnterpriseAnnually2019)]
[BitAutoData(PlanType.EnterpriseAnnually2020)] [BitAutoData(PlanType.EnterpriseAnnually2020)]
[BitAutoData(PlanType.EnterpriseAnnually)] [BitAutoData(PlanType.EnterpriseAnnually)]
public async Task GetByOrgIdAsync_Cloud_SmNoneFreePlans_ReturnsNull(PlanType planType, public async Task GetByOrgIdAsync_SmNoneFreePlans_ReturnsNull(PlanType planType,
SutProvider<MaxProjectsQuery> sutProvider, Organization organization) SutProvider<MaxProjectsQuery> sutProvider, Organization organization)
{ {
organization.PlanType = planType;
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
sutProvider.GetDependency<IGlobalSettings>().SelfHosted.Returns(false); sutProvider.GetDependency<IGlobalSettings>().SelfHosted.Returns(false);
var plan = StaticStore.GetPlan(planType);
sutProvider.GetDependency<IPricingClient>().GetPlan(organization.PlanType).Returns(plan);
var (limit, overLimit) = await sutProvider.Sut.GetByOrgIdAsync(organization.Id, 1);
Assert.Null(limit);
Assert.Null(overLimit);
await sutProvider.GetDependency<IProjectRepository>().DidNotReceiveWithAnyArgs()
.GetProjectCountByOrganizationIdAsync(organization.Id);
}
[Theory]
[BitAutoData(PlanType.TeamsMonthly2019)]
[BitAutoData(PlanType.TeamsMonthly2020)]
[BitAutoData(PlanType.TeamsMonthly)]
[BitAutoData(PlanType.TeamsAnnually2019)]
[BitAutoData(PlanType.TeamsAnnually2020)]
[BitAutoData(PlanType.TeamsAnnually)]
[BitAutoData(PlanType.TeamsStarter)]
[BitAutoData(PlanType.EnterpriseMonthly2019)]
[BitAutoData(PlanType.EnterpriseMonthly2020)]
[BitAutoData(PlanType.EnterpriseMonthly)]
[BitAutoData(PlanType.EnterpriseAnnually2019)]
[BitAutoData(PlanType.EnterpriseAnnually2020)]
[BitAutoData(PlanType.EnterpriseAnnually)]
public async Task GetByOrgIdAsync_SelfHosted_SmNoneFreePlans_ReturnsNull(PlanType planType,
SutProvider<MaxProjectsQuery> sutProvider, Organization organization)
{
organization.PlanType = planType; organization.PlanType = planType;
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization); sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
sutProvider.GetDependency<IGlobalSettings>().SelfHosted.Returns(true);
var license = new OrganizationLicense(); sutProvider.GetDependency<IPricingClient>().GetPlan(organization.PlanType)
var plan = StaticStore.GetPlan(planType); .Returns(StaticStore.GetPlan(organization.PlanType));
var claims = new List<Claim>
{
new (nameof(OrganizationLicenseConstants.PlanType), organization.PlanType.ToString()),
new (nameof(OrganizationLicenseConstants.SmMaxProjects), plan.SecretsManager.MaxProjects.ToString())
};
var identity = new ClaimsIdentity(claims, "TestAuthenticationType");
var claimsPrincipal = new ClaimsPrincipal(identity);
sutProvider.GetDependency<ILicensingService>().ReadOrganizationLicenseAsync(organization).Returns(license);
sutProvider.GetDependency<ILicensingService>().GetClaimsPrincipalFromLicense(license).Returns(claimsPrincipal);
var (limit, overLimit) = await sutProvider.Sut.GetByOrgIdAsync(organization.Id, 1); var (limit, overLimit) = await sutProvider.Sut.GetByOrgIdAsync(organization.Id, 1);
@ -183,7 +105,7 @@ public class MaxProjectsQueryTests
[BitAutoData(PlanType.Free, 3, 4, true)] [BitAutoData(PlanType.Free, 3, 4, true)]
[BitAutoData(PlanType.Free, 4, 4, true)] [BitAutoData(PlanType.Free, 4, 4, true)]
[BitAutoData(PlanType.Free, 40, 4, true)] [BitAutoData(PlanType.Free, 40, 4, true)]
public async Task GetByOrgIdAsync_Cloud_SmFreePlan__Success(PlanType planType, int projects, int projectsToAdd, bool expectedOverMax, public async Task GetByOrgIdAsync_SmFreePlan__Success(PlanType planType, int projects, int projectsToAdd, bool expectedOverMax,
SutProvider<MaxProjectsQuery> sutProvider, Organization organization) SutProvider<MaxProjectsQuery> sutProvider, Organization organization)
{ {
organization.PlanType = planType; organization.PlanType = planType;
@ -191,66 +113,8 @@ public class MaxProjectsQueryTests
sutProvider.GetDependency<IProjectRepository>().GetProjectCountByOrganizationIdAsync(organization.Id) sutProvider.GetDependency<IProjectRepository>().GetProjectCountByOrganizationIdAsync(organization.Id)
.Returns(projects); .Returns(projects);
sutProvider.GetDependency<IGlobalSettings>().SelfHosted.Returns(false); sutProvider.GetDependency<IPricingClient>().GetPlan(organization.PlanType)
var plan = StaticStore.GetPlan(planType); .Returns(StaticStore.GetPlan(organization.PlanType));
sutProvider.GetDependency<IPricingClient>().GetPlan(organization.PlanType).Returns(plan);
var (max, overMax) = await sutProvider.Sut.GetByOrgIdAsync(organization.Id, projectsToAdd);
Assert.NotNull(max);
Assert.NotNull(overMax);
Assert.Equal(3, max.Value);
Assert.Equal(expectedOverMax, overMax);
await sutProvider.GetDependency<IProjectRepository>().Received(1)
.GetProjectCountByOrganizationIdAsync(organization.Id);
}
[Theory]
[BitAutoData(PlanType.Free, 0, 1, false)]
[BitAutoData(PlanType.Free, 1, 1, false)]
[BitAutoData(PlanType.Free, 2, 1, false)]
[BitAutoData(PlanType.Free, 3, 1, true)]
[BitAutoData(PlanType.Free, 4, 1, true)]
[BitAutoData(PlanType.Free, 40, 1, true)]
[BitAutoData(PlanType.Free, 0, 2, false)]
[BitAutoData(PlanType.Free, 1, 2, false)]
[BitAutoData(PlanType.Free, 2, 2, true)]
[BitAutoData(PlanType.Free, 3, 2, true)]
[BitAutoData(PlanType.Free, 4, 2, true)]
[BitAutoData(PlanType.Free, 40, 2, true)]
[BitAutoData(PlanType.Free, 0, 3, false)]
[BitAutoData(PlanType.Free, 1, 3, true)]
[BitAutoData(PlanType.Free, 2, 3, true)]
[BitAutoData(PlanType.Free, 3, 3, true)]
[BitAutoData(PlanType.Free, 4, 3, true)]
[BitAutoData(PlanType.Free, 40, 3, true)]
[BitAutoData(PlanType.Free, 0, 4, true)]
[BitAutoData(PlanType.Free, 1, 4, true)]
[BitAutoData(PlanType.Free, 2, 4, true)]
[BitAutoData(PlanType.Free, 3, 4, true)]
[BitAutoData(PlanType.Free, 4, 4, true)]
[BitAutoData(PlanType.Free, 40, 4, true)]
public async Task GetByOrgIdAsync_SelfHosted_SmFreePlan__Success(PlanType planType, int projects, int projectsToAdd, bool expectedOverMax,
SutProvider<MaxProjectsQuery> sutProvider, Organization organization)
{
organization.PlanType = planType;
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
sutProvider.GetDependency<IProjectRepository>().GetProjectCountByOrganizationIdAsync(organization.Id)
.Returns(projects);
sutProvider.GetDependency<IGlobalSettings>().SelfHosted.Returns(true);
var license = new OrganizationLicense();
var plan = StaticStore.GetPlan(planType);
var claims = new List<Claim>
{
new (nameof(OrganizationLicenseConstants.PlanType), organization.PlanType.ToString()),
new (nameof(OrganizationLicenseConstants.SmMaxProjects), plan.SecretsManager.MaxProjects.ToString())
};
var identity = new ClaimsIdentity(claims, "TestAuthenticationType");
var claimsPrincipal = new ClaimsPrincipal(identity);
sutProvider.GetDependency<ILicensingService>().ReadOrganizationLicenseAsync(organization).Returns(license);
sutProvider.GetDependency<ILicensingService>().GetClaimsPrincipalFromLicense(license).Returns(claimsPrincipal);
var (max, overMax) = await sutProvider.Sut.GetByOrgIdAsync(organization.Id, projectsToAdd); var (max, overMax) = await sutProvider.Sut.GetByOrgIdAsync(organization.Id, projectsToAdd);

View File

@ -11,6 +11,7 @@ MAILCATCHER_PORT=1080
# Alternative databases # Alternative databases
POSTGRES_PASSWORD=SET_A_PASSWORD_HERE_123 POSTGRES_PASSWORD=SET_A_PASSWORD_HERE_123
MYSQL_ROOT_PASSWORD=SET_A_PASSWORD_HERE_123 MYSQL_ROOT_PASSWORD=SET_A_PASSWORD_HERE_123
MARIADB_ROOT_PASSWORD=SET_A_PASSWORD_HERE_123
# IdP configuration # IdP configuration
# Complete using the values from the Manage SSO page in the web vault # Complete using the values from the Manage SSO page in the web vault

View File

@ -70,6 +70,20 @@ services:
profiles: profiles:
- mysql - mysql
mariadb:
image: mariadb:10
ports:
- 4306:3306
environment:
MARIADB_USER: maria
MARIADB_PASSWORD: ${MARIADB_ROOT_PASSWORD}
MARIADB_DATABASE: vault_dev
MARIADB_RANDOM_ROOT_PASSWORD: "true"
volumes:
- mariadb_dev_data:/var/lib/mysql
profiles:
- mariadb
idp: idp:
image: kenchan0130/simplesamlphp:1.19.8 image: kenchan0130/simplesamlphp:1.19.8
container_name: idp container_name: idp

View File

@ -5,6 +5,7 @@ param(
[switch]$all, [switch]$all,
[switch]$postgres, [switch]$postgres,
[switch]$mysql, [switch]$mysql,
[switch]$mariadb,
[switch]$mssql, [switch]$mssql,
[switch]$sqlite, [switch]$sqlite,
[switch]$selfhost, [switch]$selfhost,
@ -15,11 +16,15 @@ param(
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
$currentDir = Get-Location $currentDir = Get-Location
if (!$all -and !$postgres -and !$mysql -and !$sqlite) { function Get-IsEFDatabase {
return $postgres -or $mysql -or $mariadb -or $sqlite;
}
if (!$all -and !$(Get-IsEFDatabase)) {
$mssql = $true; $mssql = $true;
} }
if ($all -or $postgres -or $mysql -or $sqlite) { if ($all -or $(Get-IsEFDatabase)) {
dotnet ef *> $null dotnet ef *> $null
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {
Write-Host "Entity Framework Core tools were not found in the dotnet global tools. Attempting to install" Write-Host "Entity Framework Core tools were not found in the dotnet global tools. Attempting to install"
@ -60,9 +65,12 @@ if ($all -or $mssql) {
} }
Foreach ($item in @( Foreach ($item in @(
@($mysql, "MySQL", "MySqlMigrations", "mySql", 2),
@($postgres, "PostgreSQL", "PostgresMigrations", "postgreSql", 0), @($postgres, "PostgreSQL", "PostgresMigrations", "postgreSql", 0),
@($sqlite, "SQLite", "SqliteMigrations", "sqlite", 1) @($sqlite, "SQLite", "SqliteMigrations", "sqlite", 1),
@($mysql, "MySQL", "MySqlMigrations", "mySql", 2),
# MariaDB shares the MySQL connection string in the server config so they are mutually exclusive in that context.
# However they can still be run independently for integration tests.
@($mariadb, "MariaDB", "MySqlMigrations", "mySql", 3)
)) { )) {
if (!$item[0] -and !$all) { if (!$item[0] -and !$all) {
continue continue

View File

@ -4,6 +4,7 @@
"rollForward": "latestFeature" "rollForward": "latestFeature"
}, },
"msbuild-sdks": { "msbuild-sdks": {
"Microsoft.Build.Traversal": "4.1.0" "Microsoft.Build.Traversal": "4.1.0",
"Microsoft.Build.Sql": "0.1.9-preview"
} }
} }

View File

@ -40,8 +40,6 @@ export function authenticate(
payload["deviceName"] = "chrome"; payload["deviceName"] = "chrome";
payload["username"] = username; payload["username"] = username;
payload["password"] = password; payload["password"] = password;
params.headers["Auth-Email"] = encoding.b64encode(username);
} else { } else {
payload["scope"] = "api.organization"; payload["scope"] = "api.organization";
payload["grant_type"] = "client_credentials"; payload["grant_type"] = "client_credentials";

View File

@ -11,7 +11,7 @@ using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Providers.Services;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Models.OrganizationConnectionConfigs; using Bit.Core.Models.OrganizationConnectionConfigs;
@ -462,6 +462,7 @@ public class OrganizationsController : Controller
organization.UsersGetPremium = model.UsersGetPremium; organization.UsersGetPremium = model.UsersGetPremium;
organization.UseSecretsManager = model.UseSecretsManager; organization.UseSecretsManager = model.UseSecretsManager;
organization.UseRiskInsights = model.UseRiskInsights; organization.UseRiskInsights = model.UseRiskInsights;
organization.UseOrganizationDomains = model.UseOrganizationDomains;
organization.UseAdminSponsoredFamilies = model.UseAdminSponsoredFamilies; organization.UseAdminSponsoredFamilies = model.UseAdminSponsoredFamilies;
//secrets //secrets

View File

@ -10,13 +10,13 @@ using Bit.Core.AdminConsole.Providers.Interfaces;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Constants; using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Entities;
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Repositories; using Bit.Core.Billing.Providers.Entities;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Providers.Models;
using Bit.Core.Billing.Services.Contracts; using Bit.Core.Billing.Providers.Repositories;
using Bit.Core.Billing.Providers.Services;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;

View File

@ -102,7 +102,7 @@ public class OrganizationEditModel : OrganizationViewModel
MaxAutoscaleSmSeats = org.MaxAutoscaleSmSeats; MaxAutoscaleSmSeats = org.MaxAutoscaleSmSeats;
SmServiceAccounts = org.SmServiceAccounts; SmServiceAccounts = org.SmServiceAccounts;
MaxAutoscaleSmServiceAccounts = org.MaxAutoscaleSmServiceAccounts; MaxAutoscaleSmServiceAccounts = org.MaxAutoscaleSmServiceAccounts;
UseOrganizationDomains = org.UseOrganizationDomains;
_plans = plans; _plans = plans;
} }
@ -186,6 +186,8 @@ public class OrganizationEditModel : OrganizationViewModel
public int? SmServiceAccounts { get; set; } public int? SmServiceAccounts { get; set; }
[Display(Name = "Max Autoscale Machine Accounts")] [Display(Name = "Max Autoscale Machine Accounts")]
public int? MaxAutoscaleSmServiceAccounts { get; set; } public int? MaxAutoscaleSmServiceAccounts { get; set; }
[Display(Name = "Use Organization Domains")]
public bool UseOrganizationDomains { get; set; }
/** /**
* Creates a Plan[] object for use in Javascript * Creates a Plan[] object for use in Javascript
@ -215,6 +217,7 @@ public class OrganizationEditModel : OrganizationViewModel
Has2fa = p.Has2fa, Has2fa = p.Has2fa,
HasApi = p.HasApi, HasApi = p.HasApi,
HasSso = p.HasSso, HasSso = p.HasSso,
HasOrganizationDomains = p.HasOrganizationDomains,
HasKeyConnector = p.HasKeyConnector, HasKeyConnector = p.HasKeyConnector,
HasScim = p.HasScim, HasScim = p.HasScim,
HasResetPassword = p.HasResetPassword, HasResetPassword = p.HasResetPassword,
@ -315,6 +318,7 @@ public class OrganizationEditModel : OrganizationViewModel
existingOrganization.MaxAutoscaleSmSeats = MaxAutoscaleSmSeats; existingOrganization.MaxAutoscaleSmSeats = MaxAutoscaleSmSeats;
existingOrganization.SmServiceAccounts = SmServiceAccounts; existingOrganization.SmServiceAccounts = SmServiceAccounts;
existingOrganization.MaxAutoscaleSmServiceAccounts = MaxAutoscaleSmServiceAccounts; existingOrganization.MaxAutoscaleSmServiceAccounts = MaxAutoscaleSmServiceAccounts;
existingOrganization.UseOrganizationDomains = UseOrganizationDomains;
return existingOrganization; return existingOrganization;
} }
} }

View File

@ -2,8 +2,8 @@
using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Models.Data.Provider; using Bit.Core.AdminConsole.Models.Data.Provider;
using Bit.Core.Billing.Entities;
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Providers.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.SharedWeb.Utilities; using Bit.SharedWeb.Utilities;

View File

@ -2,8 +2,8 @@
using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Models.Data.Provider; using Bit.Core.AdminConsole.Models.Data.Provider;
using Bit.Core.Billing.Entities;
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Providers.Entities;
namespace Bit.Admin.AdminConsole.Models; namespace Bit.Admin.AdminConsole.Models;

View File

@ -124,6 +124,10 @@
<input type="checkbox" class="form-check-input" asp-for="UseSso" disabled='@(canEditPlan ? null : "disabled")'> <input type="checkbox" class="form-check-input" asp-for="UseSso" disabled='@(canEditPlan ? null : "disabled")'>
<label class="form-check-label" asp-for="UseSso"></label> <label class="form-check-label" asp-for="UseSso"></label>
</div> </div>
<div class="form-check">
<input type="checkbox" class="form-check-input" asp-for="UseOrganizationDomains" disabled='@(canEditPlan ? null : "disabled")'>
<label class="form-check-label" asp-for="UseOrganizationDomains"></label>
</div>
<div class="form-check"> <div class="form-check">
<input type="checkbox" class="form-check-input" asp-for="UseKeyConnector" disabled='@(canEditPlan ? null : "disabled")'> <input type="checkbox" class="form-check-input" asp-for="UseKeyConnector" disabled='@(canEditPlan ? null : "disabled")'>
<label class="form-check-label" asp-for="UseKeyConnector"></label> <label class="form-check-label" asp-for="UseKeyConnector"></label>

View File

@ -69,6 +69,7 @@
document.getElementById('@(nameof(Model.UseGroups))').checked = plan.hasGroups; document.getElementById('@(nameof(Model.UseGroups))').checked = plan.hasGroups;
document.getElementById('@(nameof(Model.UsePolicies))').checked = plan.hasPolicies; document.getElementById('@(nameof(Model.UsePolicies))').checked = plan.hasPolicies;
document.getElementById('@(nameof(Model.UseSso))').checked = plan.hasSso; document.getElementById('@(nameof(Model.UseSso))').checked = plan.hasSso;
document.getElementById('@(nameof(Model.UseOrganizationDomains))').checked = plan.hasOrganizationDomains;
document.getElementById('@(nameof(Model.UseScim))').checked = plan.hasScim; document.getElementById('@(nameof(Model.UseScim))').checked = plan.hasScim;
document.getElementById('@(nameof(Model.UseDirectory))').checked = plan.hasDirectory; document.getElementById('@(nameof(Model.UseDirectory))').checked = plan.hasDirectory;
document.getElementById('@(nameof(Model.UseEvents))').checked = plan.hasEvents; document.getElementById('@(nameof(Model.UseEvents))').checked = plan.hasEvents;

View File

@ -7,7 +7,7 @@ using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Providers.Services;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Utilities; using Bit.Core.Utilities;

View File

@ -1,8 +1,8 @@
using Bit.Admin.Billing.Models; using Bit.Admin.Billing.Models;
using Bit.Admin.Enums; using Bit.Admin.Enums;
using Bit.Admin.Utilities; using Bit.Admin.Utilities;
using Bit.Core.Billing.Migration.Models; using Bit.Core.Billing.Providers.Migration.Models;
using Bit.Core.Billing.Migration.Services; using Bit.Core.Billing.Providers.Migration.Services;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;

View File

@ -1,4 +1,4 @@
using Bit.Core.Billing.Entities; using Bit.Core.Billing.Providers.Entities;
namespace Bit.Admin.Billing.Models; namespace Bit.Admin.Billing.Models;

View File

@ -1,5 +1,5 @@
@using System.Text.Json @using System.Text.Json
@model Bit.Core.Billing.Migration.Models.ProviderMigrationResult @model Bit.Core.Billing.Providers.Migration.Models.ProviderMigrationResult
@{ @{
ViewData["Title"] = "Results"; ViewData["Title"] = "Results";
} }

View File

@ -1,4 +1,4 @@
@model Bit.Core.Billing.Migration.Models.ProviderMigrationResult[] @model Bit.Core.Billing.Providers.Migration.Models.ProviderMigrationResult[]
@{ @{
ViewData["Title"] = "Results"; ViewData["Title"] = "Results";
} }

View File

@ -4,7 +4,6 @@ using Bit.Admin.Enums;
using Bit.Admin.Models; using Bit.Admin.Models;
using Bit.Admin.Services; using Bit.Admin.Services;
using Bit.Admin.Utilities; using Bit.Admin.Utilities;
using Bit.Core;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
@ -89,7 +88,7 @@ public class UsersController : Controller
var ciphers = await _cipherRepository.GetManyByUserIdAsync(id); var ciphers = await _cipherRepository.GetManyByUserIdAsync(id);
var isTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user); var isTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user);
var verifiedDomain = await AccountDeprovisioningEnabled(user.Id); var verifiedDomain = await _userService.IsClaimedByAnyOrganizationAsync(user.Id);
return View(UserViewModel.MapViewModel(user, isTwoFactorEnabled, ciphers, verifiedDomain)); return View(UserViewModel.MapViewModel(user, isTwoFactorEnabled, ciphers, verifiedDomain));
} }
@ -106,7 +105,7 @@ public class UsersController : Controller
var billingInfo = await _paymentService.GetBillingAsync(user); var billingInfo = await _paymentService.GetBillingAsync(user);
var billingHistoryInfo = await _paymentService.GetBillingHistoryAsync(user); var billingHistoryInfo = await _paymentService.GetBillingHistoryAsync(user);
var isTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user); var isTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user);
var verifiedDomain = await AccountDeprovisioningEnabled(user.Id); var verifiedDomain = await _userService.IsClaimedByAnyOrganizationAsync(user.Id);
var deviceVerificationRequired = await _userService.ActiveNewDeviceVerificationException(user.Id); var deviceVerificationRequired = await _userService.ActiveNewDeviceVerificationException(user.Id);
return View(new UserEditModel(user, isTwoFactorEnabled, ciphers, billingInfo, billingHistoryInfo, _globalSettings, verifiedDomain, deviceVerificationRequired)); return View(new UserEditModel(user, isTwoFactorEnabled, ciphers, billingInfo, billingHistoryInfo, _globalSettings, verifiedDomain, deviceVerificationRequired));
@ -167,7 +166,6 @@ public class UsersController : Controller
[HttpPost] [HttpPost]
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
[RequirePermission(Permission.User_NewDeviceException_Edit)] [RequirePermission(Permission.User_NewDeviceException_Edit)]
[RequireFeature(FeatureFlagKeys.NewDeviceVerification)]
public async Task<IActionResult> ToggleNewDeviceVerification(Guid id) public async Task<IActionResult> ToggleNewDeviceVerification(Guid id)
{ {
var user = await _userRepository.GetByIdAsync(id); var user = await _userRepository.GetByIdAsync(id);
@ -179,12 +177,4 @@ public class UsersController : Controller
await _userService.ToggleNewDeviceVerificationException(user.Id); await _userService.ToggleNewDeviceVerificationException(user.Id);
return RedirectToAction("Edit", new { id }); return RedirectToAction("Edit", new { id });
} }
// TODO: Feature flag to be removed in PM-14207
private async Task<bool?> AccountDeprovisioningEnabled(Guid userId)
{
return _featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
? await _userService.IsClaimedByAnyOrganizationAsync(userId)
: null;
}
} }

View File

@ -17,7 +17,7 @@ public class ChargeBraintreeModel : IValidatableObject
{ {
if (Id != null) if (Id != null)
{ {
if (Id.Length != 36 || (Id[0] != 'o' && Id[0] != 'u') || if (Id.Length != 36 || (Id[0] != 'o' && Id[0] != 'u' && Id[0] != 'p') ||
!Guid.TryParse(Id.Substring(1, 32), out var guid)) !Guid.TryParse(Id.Substring(1, 32), out var guid))
{ {
yield return new ValidationResult("Customer Id is not a valid format."); yield return new ValidationResult("Customer Id is not a valid format.");

View File

@ -10,7 +10,7 @@ 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.Migration; using Bit.Core.Billing.Providers.Migration;
#if !OSS #if !OSS
using Bit.Commercial.Core.Utilities; using Bit.Commercial.Core.Utilities;

View File

@ -2,7 +2,7 @@
using Bit.Core; using Bit.Core;
using Bit.Core.Jobs; using Bit.Core.Jobs;
using Bit.Core.Tools.Repositories; using Bit.Core.Tools.Repositories;
using Bit.Core.Tools.Services; using Bit.Core.Tools.SendFeatures.Commands.Interfaces;
using Quartz; using Quartz;
namespace Bit.Admin.Tools.Jobs; namespace Bit.Admin.Tools.Jobs;
@ -32,10 +32,10 @@ public class DeleteSendsJob : BaseJob
} }
using (var scope = _serviceProvider.CreateScope()) using (var scope = _serviceProvider.CreateScope())
{ {
var sendService = scope.ServiceProvider.GetRequiredService<ISendService>(); var nonAnonymousSendCommand = scope.ServiceProvider.GetRequiredService<INonAnonymousSendCommand>();
foreach (var send in sends) foreach (var send in sends)
{ {
await sendService.DeleteSendAsync(send); await nonAnonymousSendCommand.DeleteSendAsync(send);
} }
} }
} }

View File

@ -16,7 +16,7 @@
}, },
"devDependencies": { "devDependencies": {
"css-loader": "7.1.2", "css-loader": "7.1.2",
"expose-loader": "5.0.0", "expose-loader": "5.0.1",
"mini-css-extract-plugin": "2.9.2", "mini-css-extract-plugin": "2.9.2",
"sass": "1.85.0", "sass": "1.85.0",
"sass-loader": "16.0.4", "sass-loader": "16.0.4",
@ -1084,9 +1084,9 @@
} }
}, },
"node_modules/expose-loader": { "node_modules/expose-loader": {
"version": "5.0.0", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/expose-loader/-/expose-loader-5.0.0.tgz", "resolved": "https://registry.npmjs.org/expose-loader/-/expose-loader-5.0.1.tgz",
"integrity": "sha512-BtUqYRmvx1bEY5HN6eK2I9URUZgNmN0x5UANuocaNjXSgfoDlkXt+wyEMe7i5DzDNh2BKJHPc5F4rBwEdSQX6w==", "integrity": "sha512-5YPZuszN/eWND/B+xuq5nIpb/l5TV1HYmdO6SubYtHv+HenVw9/6bn33Mm5reY8DNid7AVtbARvyUD34edfCtg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {

View File

@ -15,7 +15,7 @@
}, },
"devDependencies": { "devDependencies": {
"css-loader": "7.1.2", "css-loader": "7.1.2",
"expose-loader": "5.0.0", "expose-loader": "5.0.1",
"mini-css-extract-plugin": "2.9.2", "mini-css-extract-plugin": "2.9.2",
"sass": "1.85.0", "sass": "1.85.0",
"sass-loader": "16.0.4", "sass-loader": "16.0.4",

View File

@ -0,0 +1,20 @@
#nullable enable
using Bit.Core.Context;
using Bit.Core.Enums;
namespace Bit.Api.AdminConsole.Authorization.Requirements;
public class ManageAccountRecoveryRequirement : IOrganizationRequirement
{
public async Task<bool> AuthorizeAsync(
CurrentContextOrganization? organizationClaims,
Func<Task<bool>> isProviderUserForOrg)
=> organizationClaims switch
{
{ Type: OrganizationUserType.Owner } => true,
{ Type: OrganizationUserType.Admin } => true,
{ Permissions.ManageResetPassword: true } => true,
_ => await isProviderUserForOrg()
};
}

View File

@ -2,13 +2,11 @@
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.Api.Models.Response; using Bit.Api.Models.Response;
using Bit.Core;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Entities; using Bit.Core.Entities;
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;
@ -137,7 +135,6 @@ public class OrganizationDomainController : Controller
[AllowAnonymous] [AllowAnonymous]
[HttpPost("domain/sso/verified")] [HttpPost("domain/sso/verified")]
[RequireFeature(FeatureFlagKeys.VerifiedSsoDomainEndpoint)]
public async Task<VerifiedOrganizationDomainSsoDetailsResponseModel> GetVerifiedOrgDomainSsoDetailsAsync( public async Task<VerifiedOrganizationDomainSsoDetailsResponseModel> GetVerifiedOrgDomainSsoDetailsAsync(
[FromBody] OrganizationDomainSsoDetailsRequestModel model) [FromBody] OrganizationDomainSsoDetailsRequestModel model)
{ {

View File

@ -1,4 +1,5 @@
using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.AdminConsole.Authorization.Requirements;
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Api.Models.Request.Organizations; using Bit.Api.Models.Request.Organizations;
using Bit.Api.Models.Response; using Bit.Api.Models.Response;
@ -162,6 +163,12 @@ 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>> Get(Guid orgId, bool includeGroups = false, bool includeCollections = false)
{ {
if (_featureService.IsEnabled(FeatureFlagKeys.SeparateCustomRolePermissions))
{
return await GetvNextAsync(orgId, includeGroups, includeCollections);
}
var authorized = (await _authorizationService.AuthorizeAsync( var authorized = (await _authorizationService.AuthorizeAsync(
User, new OrganizationScope(orgId), OrganizationUserUserDetailsOperations.ReadAll)).Succeeded; User, new OrganizationScope(orgId), OrganizationUserUserDetailsOperations.ReadAll)).Succeeded;
if (!authorized) if (!authorized)
@ -191,6 +198,37 @@ public class OrganizationUsersController : Controller
return new ListResponseModel<OrganizationUserUserDetailsResponseModel>(responses); return new ListResponseModel<OrganizationUserUserDetailsResponseModel>(responses);
} }
private async Task<ListResponseModel<OrganizationUserUserDetailsResponseModel>> GetvNextAsync(Guid orgId, bool includeGroups = false, bool includeCollections = false)
{
var request = new OrganizationUserUserDetailsQueryRequest
{
OrganizationId = orgId,
IncludeGroups = includeGroups,
IncludeCollections = includeCollections,
};
if ((await _authorizationService.AuthorizeAsync(User, new ManageUsersRequirement())).Succeeded)
{
return GetResultListResponseModel(await _organizationUserUserDetailsQuery.Get(request));
}
if ((await _authorizationService.AuthorizeAsync(User, new ManageAccountRecoveryRequirement())).Succeeded)
{
return GetResultListResponseModel(await _organizationUserUserDetailsQuery.GetAccountRecoveryEnrolledUsers(request));
}
throw new NotFoundException();
}
private ListResponseModel<OrganizationUserUserDetailsResponseModel> GetResultListResponseModel(IEnumerable<(OrganizationUserUserDetails OrgUser,
bool TwoFactorEnabled, bool ClaimedByOrganization)> results)
{
return new ListResponseModel<OrganizationUserUserDetailsResponseModel>(results
.Select(result => new OrganizationUserUserDetailsResponseModel(result))
.ToList());
}
[HttpGet("{id}/groups")] [HttpGet("{id}/groups")]
public async Task<IEnumerable<string>> GetGroups(string orgId, string id) public async Task<IEnumerable<string>> GetGroups(string orgId, string id)
{ {
@ -578,7 +616,6 @@ public class OrganizationUsersController : Controller
new OrganizationUserBulkResponseModel(r.OrganizationUserId, r.ErrorMessage))); new OrganizationUserBulkResponseModel(r.OrganizationUserId, r.ErrorMessage)));
} }
[RequireFeature(FeatureFlagKeys.AccountDeprovisioning)]
[HttpDelete("{id}/delete-account")] [HttpDelete("{id}/delete-account")]
[HttpPost("{id}/delete-account")] [HttpPost("{id}/delete-account")]
public async Task DeleteAccount(Guid orgId, Guid id) public async Task DeleteAccount(Guid orgId, Guid id)
@ -597,7 +634,6 @@ public class OrganizationUsersController : Controller
await _deleteClaimedOrganizationUserAccountCommand.DeleteUserAsync(orgId, id, currentUser.Id); await _deleteClaimedOrganizationUserAccountCommand.DeleteUserAsync(orgId, id, currentUser.Id);
} }
[RequireFeature(FeatureFlagKeys.AccountDeprovisioning)]
[HttpDelete("delete-account")] [HttpDelete("delete-account")]
[HttpPost("delete-account")] [HttpPost("delete-account")]
public async Task<ListResponseModel<OrganizationUserBulkResponseModel>> BulkDeleteAccount(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) public async Task<ListResponseModel<OrganizationUserBulkResponseModel>> BulkDeleteAccount(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model)
@ -722,11 +758,6 @@ public class OrganizationUsersController : Controller
private async Task<IDictionary<Guid, bool>> GetClaimedByOrganizationStatusAsync(Guid orgId, IEnumerable<Guid> userIds) private async Task<IDictionary<Guid, bool>> GetClaimedByOrganizationStatusAsync(Guid orgId, IEnumerable<Guid> userIds)
{ {
if (!_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning))
{
return userIds.ToDictionary(kvp => kvp, kvp => false);
}
var usersOrganizationClaimedStatus = await _getOrganizationUsersClaimedStatusQuery.GetUsersOrganizationClaimedStatusAsync(orgId, userIds); var usersOrganizationClaimedStatus = await _getOrganizationUsersClaimedStatusQuery.GetUsersOrganizationClaimedStatusAsync(orgId, userIds);
return usersOrganizationClaimedStatus; return usersOrganizationClaimedStatus;
} }

View File

@ -25,7 +25,7 @@ using Bit.Core.Auth.Services;
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Providers.Services;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
@ -279,8 +279,7 @@ public class OrganizationsController : Controller
throw new BadRequestException("Your organization's Single Sign-On settings prevent you from leaving."); throw new BadRequestException("Your organization's Single Sign-On settings prevent you from leaving.");
} }
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) if ((await _userService.GetOrganizationsClaimingUserAsync(user.Id)).Any(x => x.Id == id))
&& (await _userService.GetOrganizationsClaimingUserAsync(user.Id)).Any(x => x.Id == id))
{ {
throw new BadRequestException("Claimed user account cannot leave claiming organization. Contact your organization administrator for additional details."); throw new BadRequestException("Claimed user account cannot leave claiming organization. Contact your organization administrator for additional details.");
} }

View File

@ -2,7 +2,6 @@
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;
@ -79,7 +78,7 @@ public class PoliciesController : Controller
return new PolicyDetailResponseModel(new Policy { Type = (PolicyType)type }); return new PolicyDetailResponseModel(new Policy { Type = (PolicyType)type });
} }
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) && policy.Type is PolicyType.SingleOrg) if (policy.Type is PolicyType.SingleOrg)
{ {
return await policy.GetSingleOrgPolicyDetailResponseAsync(_organizationHasVerifiedDomainsQuery); return await policy.GetSingleOrgPolicyDetailResponseAsync(_organizationHasVerifiedDomainsQuery);
} }

View File

@ -2,7 +2,7 @@
using Bit.Api.Billing.Models.Requests; using Bit.Api.Billing.Models.Requests;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Providers.Services;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Models.Business; using Bit.Core.Models.Business;

View File

@ -75,6 +75,8 @@ public class OrganizationCreateRequestModel : IValidatableObject
public string InitiationPath { get; set; } public string InitiationPath { get; set; }
public bool SkipTrial { get; set; }
public virtual OrganizationSignup ToOrganizationSignup(User user) public virtual OrganizationSignup ToOrganizationSignup(User user)
{ {
var orgSignup = new OrganizationSignup var orgSignup = new OrganizationSignup
@ -107,6 +109,7 @@ public class OrganizationCreateRequestModel : IValidatableObject
BillingAddressCountry = BillingAddressCountry, BillingAddressCountry = BillingAddressCountry,
}, },
InitiationPath = InitiationPath, InitiationPath = InitiationPath,
SkipTrial = SkipTrial
}; };
Keys?.ToOrganizationSignup(orgSignup); Keys?.ToOrganizationSignup(orgSignup);

View File

@ -64,6 +64,7 @@ public class OrganizationResponseModel : ResponseModel
LimitItemDeletion = organization.LimitItemDeletion; LimitItemDeletion = organization.LimitItemDeletion;
AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems; AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems;
UseRiskInsights = organization.UseRiskInsights; UseRiskInsights = organization.UseRiskInsights;
UseOrganizationDomains = organization.UseOrganizationDomains;
UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies; UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies;
} }
@ -111,6 +112,7 @@ public class OrganizationResponseModel : ResponseModel
public bool LimitItemDeletion { get; set; } public bool LimitItemDeletion { get; set; }
public bool AllowAdminAccessToAllCollectionItems { get; set; } public bool AllowAdminAccessToAllCollectionItems { get; set; }
public bool UseRiskInsights { get; set; } public bool UseRiskInsights { get; set; }
public bool UseOrganizationDomains { get; set; }
public bool UseAdminSponsoredFamilies { get; set; } public bool UseAdminSponsoredFamilies { get; set; }
} }

View File

@ -126,6 +126,26 @@ public class OrganizationUserUserMiniDetailsResponseModel : ResponseModel
public class OrganizationUserUserDetailsResponseModel : OrganizationUserResponseModel public class OrganizationUserUserDetailsResponseModel : OrganizationUserResponseModel
{ {
public OrganizationUserUserDetailsResponseModel((OrganizationUserUserDetails OrgUser, bool TwoFactorEnabled, bool ClaimedByOrganization) data, string obj = "organizationUserUserDetails")
: base(data.OrgUser, obj)
{
if (data.OrgUser == null)
{
throw new ArgumentNullException(nameof(data.OrgUser));
}
Name = data.OrgUser.Name;
Email = data.OrgUser.Email;
AvatarColor = data.OrgUser.AvatarColor;
TwoFactorEnabled = data.TwoFactorEnabled;
SsoBound = !string.IsNullOrWhiteSpace(data.OrgUser.SsoExternalId);
Collections = data.OrgUser.Collections.Select(c => new SelectionReadOnlyResponseModel(c));
Groups = data.OrgUser.Groups;
// Prevent reset password when using key connector.
ResetPasswordEnrolled = ResetPasswordEnrolled && !data.OrgUser.UsesKeyConnector;
ClaimedByOrganization = data.ClaimedByOrganization;
}
public OrganizationUserUserDetailsResponseModel(OrganizationUserUserDetails organizationUser, public OrganizationUserUserDetailsResponseModel(OrganizationUserUserDetails organizationUser,
bool twoFactorEnabled, bool claimedByOrganization, string obj = "organizationUserUserDetails") bool twoFactorEnabled, bool claimedByOrganization, string obj = "organizationUserUserDetails")
: base(organizationUser, obj) : base(organizationUser, obj)

View File

@ -58,7 +58,8 @@ public class ProfileOrganizationResponseModel : ResponseModel
ProviderName = organization.ProviderName; ProviderName = organization.ProviderName;
ProviderType = organization.ProviderType; ProviderType = organization.ProviderType;
FamilySponsorshipFriendlyName = organization.FamilySponsorshipFriendlyName; FamilySponsorshipFriendlyName = organization.FamilySponsorshipFriendlyName;
FamilySponsorshipAvailable = FamilySponsorshipFriendlyName == null && IsAdminInitiated = organization.IsAdminInitiated ?? false;
FamilySponsorshipAvailable = (FamilySponsorshipFriendlyName == null || IsAdminInitiated) &&
StaticStore.GetSponsoredPlan(PlanSponsorshipType.FamiliesForEnterprise) StaticStore.GetSponsoredPlan(PlanSponsorshipType.FamiliesForEnterprise)
.UsersCanSponsor(organization); .UsersCanSponsor(organization);
ProductTierType = organization.PlanType.GetProductTier(); ProductTierType = organization.PlanType.GetProductTier();
@ -72,6 +73,7 @@ public class ProfileOrganizationResponseModel : ResponseModel
AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems; AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems;
UserIsClaimedByOrganization = organizationIdsClaimingUser.Contains(organization.OrganizationId); UserIsClaimedByOrganization = organizationIdsClaimingUser.Contains(organization.OrganizationId);
UseRiskInsights = organization.UseRiskInsights; UseRiskInsights = organization.UseRiskInsights;
UseOrganizationDomains = organization.UseOrganizationDomains;
UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies; UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies;
if (organization.SsoConfig != null) if (organization.SsoConfig != null)
@ -135,7 +137,6 @@ public class ProfileOrganizationResponseModel : ResponseModel
public bool AllowAdminAccessToAllCollectionItems { get; set; } public bool AllowAdminAccessToAllCollectionItems { get; set; }
/// <summary> /// <summary>
/// Obsolete. /// Obsolete.
///
/// See <see cref="UserIsClaimedByOrganization"/> /// See <see cref="UserIsClaimedByOrganization"/>
/// </summary> /// </summary>
[Obsolete("Please use UserIsClaimedByOrganization instead. This property will be removed in a future version.")] [Obsolete("Please use UserIsClaimedByOrganization instead. This property will be removed in a future version.")]
@ -145,16 +146,15 @@ public class ProfileOrganizationResponseModel : ResponseModel
set => UserIsClaimedByOrganization = value; set => UserIsClaimedByOrganization = value;
} }
/// <summary> /// <summary>
/// Indicates if the organization claims the user. /// Indicates if the user is claimed by the organization.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// An organization claims a user if the user's email domain is verified by the organization and the user is a member of it. /// A user is claimed by an organization if the user's email domain is verified by the organization and the user is a member.
/// The organization must be enabled and able to have verified domains. /// The organization must be enabled and able to have verified domains.
/// </remarks> /// </remarks>
/// <returns>
/// False if the Account Deprovisioning feature flag is disabled.
/// </returns>
public bool UserIsClaimedByOrganization { get; set; } public bool UserIsClaimedByOrganization { get; set; }
public bool UseRiskInsights { get; set; } public bool UseRiskInsights { get; set; }
public bool UseOrganizationDomains { get; set; }
public bool UseAdminSponsoredFamilies { get; set; } public bool UseAdminSponsoredFamilies { get; set; }
public bool IsAdminInitiated { get; set; }
} }

View File

@ -50,6 +50,7 @@ public class ProfileProviderOrganizationResponseModel : ProfileOrganizationRespo
LimitItemDeletion = organization.LimitItemDeletion; LimitItemDeletion = organization.LimitItemDeletion;
AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems; AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems;
UseRiskInsights = organization.UseRiskInsights; UseRiskInsights = organization.UseRiskInsights;
UseOrganizationDomains = organization.UseOrganizationDomains;
UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies; UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies;
} }
} }

View File

@ -76,7 +76,7 @@ public class MembersController : Controller
{ {
return new NotFoundResult(); return new NotFoundResult();
} }
var response = new MemberResponseModel(orgUser, await _userService.TwoFactorIsEnabledAsync(orgUser), var response = new MemberResponseModel(orgUser, await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(orgUser),
collections); collections);
return new JsonResult(response); return new JsonResult(response);
} }
@ -185,7 +185,7 @@ public class MembersController : Controller
{ {
var existingUserDetails = await _organizationUserRepository.GetDetailsByIdAsync(id); var existingUserDetails = await _organizationUserRepository.GetDetailsByIdAsync(id);
response = new MemberResponseModel(existingUserDetails, response = new MemberResponseModel(existingUserDetails,
await _userService.TwoFactorIsEnabledAsync(existingUserDetails), associations); await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(existingUserDetails), associations);
} }
else else
{ {

View File

@ -4,8 +4,6 @@
<MvcRazorCompileOnPublish>false</MvcRazorCompileOnPublish> <MvcRazorCompileOnPublish>false</MvcRazorCompileOnPublish>
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml</DocumentationFile> <DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml</DocumentationFile>
<ANCMPreConfiguredForIIS>true</ANCMPreConfiguredForIIS> <ANCMPreConfiguredForIIS>true</ANCMPreConfiguredForIIS>
<!-- Temp exclusions until warnings are fixed -->
<WarningsNotAsErrors>$(WarningsNotAsErrors);CS8604</WarningsNotAsErrors>
</PropertyGroup> </PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'"> <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">

View File

@ -16,6 +16,7 @@ using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Auth.Models.Api.Request.Accounts;
using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Models.Data;
using Bit.Core.Auth.UserFeatures.TdeOffboardingPassword.Interfaces; using Bit.Core.Auth.UserFeatures.TdeOffboardingPassword.Interfaces;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
@ -45,6 +46,7 @@ public class AccountsController : Controller
private readonly ISetInitialMasterPasswordCommand _setInitialMasterPasswordCommand; private readonly ISetInitialMasterPasswordCommand _setInitialMasterPasswordCommand;
private readonly ITdeOffboardingPasswordCommand _tdeOffboardingPasswordCommand; private readonly ITdeOffboardingPasswordCommand _tdeOffboardingPasswordCommand;
private readonly IRotateUserKeyCommand _rotateUserKeyCommand; private readonly IRotateUserKeyCommand _rotateUserKeyCommand;
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
private readonly IFeatureService _featureService; private readonly IFeatureService _featureService;
private readonly IRotationValidator<IEnumerable<CipherWithIdRequestModel>, IEnumerable<Cipher>> _cipherValidator; private readonly IRotationValidator<IEnumerable<CipherWithIdRequestModel>, IEnumerable<Cipher>> _cipherValidator;
@ -68,6 +70,7 @@ public class AccountsController : Controller
ISetInitialMasterPasswordCommand setInitialMasterPasswordCommand, ISetInitialMasterPasswordCommand setInitialMasterPasswordCommand,
ITdeOffboardingPasswordCommand tdeOffboardingPasswordCommand, ITdeOffboardingPasswordCommand tdeOffboardingPasswordCommand,
IRotateUserKeyCommand rotateUserKeyCommand, IRotateUserKeyCommand rotateUserKeyCommand,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
IFeatureService featureService, IFeatureService featureService,
IRotationValidator<IEnumerable<CipherWithIdRequestModel>, IEnumerable<Cipher>> cipherValidator, IRotationValidator<IEnumerable<CipherWithIdRequestModel>, IEnumerable<Cipher>> cipherValidator,
IRotationValidator<IEnumerable<FolderWithIdRequestModel>, IEnumerable<Folder>> folderValidator, IRotationValidator<IEnumerable<FolderWithIdRequestModel>, IEnumerable<Folder>> folderValidator,
@ -87,6 +90,7 @@ public class AccountsController : Controller
_setInitialMasterPasswordCommand = setInitialMasterPasswordCommand; _setInitialMasterPasswordCommand = setInitialMasterPasswordCommand;
_tdeOffboardingPasswordCommand = tdeOffboardingPasswordCommand; _tdeOffboardingPasswordCommand = tdeOffboardingPasswordCommand;
_rotateUserKeyCommand = rotateUserKeyCommand; _rotateUserKeyCommand = rotateUserKeyCommand;
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
_featureService = featureService; _featureService = featureService;
_cipherValidator = cipherValidator; _cipherValidator = cipherValidator;
_folderValidator = folderValidator; _folderValidator = folderValidator;
@ -389,7 +393,7 @@ public class AccountsController : Controller
await _providerUserRepository.GetManyOrganizationDetailsByUserAsync(user.Id, await _providerUserRepository.GetManyOrganizationDetailsByUserAsync(user.Id,
ProviderUserStatusType.Confirmed); ProviderUserStatusType.Confirmed);
var twoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user); var twoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user);
var hasPremiumFromOrg = await _userService.HasPremiumFromOrganization(user); var hasPremiumFromOrg = await _userService.HasPremiumFromOrganization(user);
var organizationIdsClaimingActiveUser = await GetOrganizationIdsClaimingUserAsync(user.Id); var organizationIdsClaimingActiveUser = await GetOrganizationIdsClaimingUserAsync(user.Id);
@ -423,7 +427,7 @@ public class AccountsController : Controller
await _userService.SaveUserAsync(model.ToUser(user)); await _userService.SaveUserAsync(model.ToUser(user));
var twoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user); var twoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user);
var hasPremiumFromOrg = await _userService.HasPremiumFromOrganization(user); var hasPremiumFromOrg = await _userService.HasPremiumFromOrganization(user);
var organizationIdsClaimingActiveUser = await GetOrganizationIdsClaimingUserAsync(user.Id); var organizationIdsClaimingActiveUser = await GetOrganizationIdsClaimingUserAsync(user.Id);
@ -442,7 +446,7 @@ public class AccountsController : Controller
} }
await _userService.SaveUserAsync(model.ToUser(user), true); await _userService.SaveUserAsync(model.ToUser(user), true);
var userTwoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user); var userTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user);
var userHasPremiumFromOrganization = await _userService.HasPremiumFromOrganization(user); var userHasPremiumFromOrganization = await _userService.HasPremiumFromOrganization(user);
var organizationIdsClaimingActiveUser = await GetOrganizationIdsClaimingUserAsync(user.Id); var organizationIdsClaimingActiveUser = await GetOrganizationIdsClaimingUserAsync(user.Id);
@ -514,9 +518,8 @@ public class AccountsController : Controller
} }
else else
{ {
// If Account Deprovisioning is enabled, we need to check if the user is claimed by any organization. // Check if the user is claimed by any organization.
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) if (await _userService.IsClaimedByAnyOrganizationAsync(user.Id))
&& await _userService.IsClaimedByAnyOrganizationAsync(user.Id))
{ {
throw new BadRequestException("Cannot delete accounts owned by an organization. Contact your organization administrator for additional details."); throw new BadRequestException("Cannot delete accounts owned by an organization. Contact your organization administrator for additional details.");
} }
@ -693,7 +696,6 @@ public class AccountsController : Controller
} }
} }
[RequireFeature(FeatureFlagKeys.NewDeviceVerification)]
[AllowAnonymous] [AllowAnonymous]
[HttpPost("resend-new-device-otp")] [HttpPost("resend-new-device-otp")]
public async Task ResendNewDeviceOtpAsync([FromBody] UnauthenticatedSecretVerificationRequestModel request) public async Task ResendNewDeviceOtpAsync([FromBody] UnauthenticatedSecretVerificationRequestModel request)

View File

@ -1,7 +1,7 @@
#nullable enable #nullable enable
using Bit.Api.Billing.Models.Responses; using Bit.Api.Billing.Models.Responses;
using Bit.Core.Billing.Models.Api.Requests.Accounts;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Services;
using Bit.Core.Billing.Tax.Requests;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;

View File

@ -3,6 +3,7 @@ using Bit.Api.Models.Request;
using Bit.Api.Models.Request.Accounts; using Bit.Api.Models.Request.Accounts;
using Bit.Api.Models.Response; using Bit.Api.Models.Response;
using Bit.Api.Utilities; using Bit.Api.Utilities;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Billing.Models; using Bit.Core.Billing.Models;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Services;
using Bit.Core.Context; using Bit.Core.Context;
@ -22,7 +23,8 @@ namespace Bit.Api.Billing.Controllers;
[Route("accounts")] [Route("accounts")]
[Authorize("Application")] [Authorize("Application")]
public class AccountsController( public class AccountsController(
IUserService userService) : Controller IUserService userService,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery) : Controller
{ {
[HttpPost("premium")] [HttpPost("premium")]
public async Task<PaymentResponseModel> PostPremiumAsync( public async Task<PaymentResponseModel> PostPremiumAsync(
@ -56,7 +58,7 @@ public class AccountsController(
model.PaymentMethodType!.Value, model.AdditionalStorageGb.GetValueOrDefault(0), license, model.PaymentMethodType!.Value, model.AdditionalStorageGb.GetValueOrDefault(0), license,
new TaxInfo { BillingAddressCountry = model.Country, BillingAddressPostalCode = model.PostalCode }); new TaxInfo { BillingAddressCountry = model.Country, BillingAddressPostalCode = model.PostalCode });
var userTwoFactorEnabled = await userService.TwoFactorIsEnabledAsync(user); var userTwoFactorEnabled = await twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user);
var userHasPremiumFromOrganization = await userService.HasPremiumFromOrganization(user); var userHasPremiumFromOrganization = await userService.HasPremiumFromOrganization(user);
var organizationIdsClaimingActiveUser = await GetOrganizationIdsClaimingUserAsync(user.Id); var organizationIdsClaimingActiveUser = await GetOrganizationIdsClaimingUserAsync(user.Id);

View File

@ -1,5 +1,5 @@
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Models.Api.Requests.Organizations; using Bit.Core.Billing.Tax.Requests;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;

View File

@ -1,4 +1,5 @@
#nullable enable #nullable enable
using System.Diagnostics;
using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.Billing.Models.Requests; using Bit.Api.Billing.Models.Requests;
using Bit.Api.Billing.Models.Responses; using Bit.Api.Billing.Models.Responses;
@ -7,7 +8,9 @@ using Bit.Core;
using Bit.Core.Billing.Models; using Bit.Core.Billing.Models;
using Bit.Core.Billing.Models.Sales; using Bit.Core.Billing.Models.Sales;
using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Pricing;
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.Context; using Bit.Core.Context;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
@ -290,14 +293,17 @@ public class OrganizationBillingController(
sale.Organization.PlanType = plan.Type; sale.Organization.PlanType = plan.Type;
sale.Organization.Plan = plan.Name; sale.Organization.Plan = plan.Name;
sale.SubscriptionSetup.SkipTrial = true; sale.SubscriptionSetup.SkipTrial = true;
await organizationBillingService.Finalize(sale);
var org = await organizationRepository.GetByIdAsync(organizationId); if (organizationSignup.PaymentMethodType == null || string.IsNullOrEmpty(organizationSignup.PaymentToken))
if (organizationSignup.PaymentMethodType != null)
{ {
var paymentSource = new TokenizedPaymentSource(organizationSignup.PaymentMethodType.Value, organizationSignup.PaymentToken); return Error.BadRequest("A payment method is required to restart the subscription.");
var taxInformation = TaxInformation.From(organizationSignup.TaxInfo);
await organizationBillingService.UpdatePaymentMethod(org, paymentSource, taxInformation);
} }
var org = await organizationRepository.GetByIdAsync(organizationId);
Debug.Assert(org is not null, "This organization has already been found via this same ID, this should be fine.");
var paymentSource = new TokenizedPaymentSource(organizationSignup.PaymentMethodType.Value, organizationSignup.PaymentToken);
var taxInformation = TaxInformation.From(organizationSignup.TaxInfo);
await organizationBillingService.UpdatePaymentMethod(org, paymentSource, taxInformation);
await organizationBillingService.Finalize(sale);
return TypedResults.Ok(); return TypedResults.Ok();
} }

View File

@ -222,6 +222,20 @@ public class OrganizationSponsorshipsController : Controller
await _revokeSponsorshipCommand.RevokeSponsorshipAsync(existingOrgSponsorship); await _revokeSponsorshipCommand.RevokeSponsorshipAsync(existingOrgSponsorship);
} }
[Authorize("Application")]
[HttpDelete("{sponsoringOrgId}/{sponsoredFriendlyName}/revoke")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task AdminInitiatedRevokeSponsorshipAsync(Guid sponsoringOrgId, string sponsoredFriendlyName)
{
var sponsorships = await _organizationSponsorshipRepository.GetManyBySponsoringOrganizationAsync(sponsoringOrgId);
var existingOrgSponsorship = sponsorships.FirstOrDefault(s => s.FriendlyName != null && s.FriendlyName.Equals(sponsoredFriendlyName, StringComparison.OrdinalIgnoreCase));
if (existingOrgSponsorship == null)
{
throw new BadRequestException("The specified sponsored organization could not be found under the given sponsoring organization.");
}
await _revokeSponsorshipCommand.RevokeSponsorshipAsync(existingOrgSponsorship);
}
[Authorize("Application")] [Authorize("Application")]
[HttpDelete("sponsored/{sponsoredOrgId}")] [HttpDelete("sponsored/{sponsoredOrgId}")]
[HttpPost("sponsored/{sponsoredOrgId}/remove")] [HttpPost("sponsored/{sponsoredOrgId}/remove")]
@ -271,8 +285,11 @@ public class OrganizationSponsorshipsController : Controller
} }
var sponsorships = await _organizationSponsorshipRepository.GetManyBySponsoringOrganizationAsync(sponsoringOrgId); var sponsorships = await _organizationSponsorshipRepository.GetManyBySponsoringOrganizationAsync(sponsoringOrgId);
return new ListResponseModel<OrganizationSponsorshipInvitesResponseModel>(sponsorships.Select(s => return new ListResponseModel<OrganizationSponsorshipInvitesResponseModel>(
new OrganizationSponsorshipInvitesResponseModel(new OrganizationSponsorshipData(s)))); sponsorships
.Where(s => s.IsAdminInitiated)
.Select(s => new OrganizationSponsorshipInvitesResponseModel(new OrganizationSponsorshipData(s)))
);
} }

View File

@ -109,28 +109,6 @@ public class OrganizationsController(
return license; return license;
} }
[HttpPost("{id:guid}/payment")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task PostPayment(Guid id, [FromBody] PaymentRequestModel model)
{
if (!await currentContext.EditPaymentMethods(id))
{
throw new NotFoundException();
}
await organizationService.ReplacePaymentMethodAsync(id, model.PaymentToken,
model.PaymentMethodType.Value, new TaxInfo
{
BillingAddressLine1 = model.Line1,
BillingAddressLine2 = model.Line2,
BillingAddressState = model.State,
BillingAddressCity = model.City,
BillingAddressPostalCode = model.PostalCode,
BillingAddressCountry = model.Country,
TaxIdNumber = model.TaxId,
});
}
[HttpPost("{id:guid}/upgrade")] [HttpPost("{id:guid}/upgrade")]
[SelfHosted(NotSelfHostedOnly = true)] [SelfHosted(NotSelfHostedOnly = true)]
public async Task<PaymentResponseModel> PostUpgrade(Guid id, [FromBody] OrganizationUpgradeRequestModel model) public async Task<PaymentResponseModel> PostUpgrade(Guid id, [FromBody] OrganizationUpgradeRequestModel model)

View File

@ -1,11 +1,14 @@
using Bit.Api.Billing.Models.Requests; using Bit.Api.Billing.Models.Requests;
using Bit.Api.Billing.Models.Responses; using Bit.Api.Billing.Models.Responses;
using Bit.Commercial.Core.Billing.Providers.Services;
using Bit.Core; using Bit.Core;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Repositories; using Bit.Core.Billing.Providers.Models;
using Bit.Core.Billing.Providers.Repositories;
using Bit.Core.Billing.Providers.Services;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Services;
using Bit.Core.Billing.Tax.Models;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Models.BitStripe; using Bit.Core.Models.BitStripe;
using Bit.Core.Services; using Bit.Core.Services;
@ -147,13 +150,33 @@ public class ProviderBillingController(
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id); var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
var getProviderPriceFromStripe = featureService.IsEnabled(FeatureFlagKeys.PM21383_GetProviderPriceFromStripe);
var configuredProviderPlans = await Task.WhenAll(providerPlans.Select(async providerPlan => var configuredProviderPlans = await Task.WhenAll(providerPlans.Select(async providerPlan =>
{ {
var plan = await pricingClient.GetPlanOrThrow(providerPlan.PlanType); var plan = await pricingClient.GetPlanOrThrow(providerPlan.PlanType);
decimal unitAmount;
if (getProviderPriceFromStripe)
{
var priceId = ProviderPriceAdapter.GetPriceId(provider, subscription, plan.Type);
var price = await stripeAdapter.PriceGetAsync(priceId);
unitAmount = price.UnitAmountDecimal.HasValue
? price.UnitAmountDecimal.Value / 100M
: plan.PasswordManager.ProviderPortalSeatPrice;
}
else
{
unitAmount = plan.PasswordManager.ProviderPortalSeatPrice;
}
return new ConfiguredProviderPlan( return new ConfiguredProviderPlan(
providerPlan.Id, providerPlan.Id,
providerPlan.ProviderId, providerPlan.ProviderId,
plan, plan,
unitAmount,
providerPlan.SeatMinimum ?? 0, providerPlan.SeatMinimum ?? 0,
providerPlan.PurchasedSeats ?? 0, providerPlan.PurchasedSeats ?? 0,
providerPlan.AllocatedSeats ?? 0); providerPlan.AllocatedSeats ?? 0);

View File

@ -1,4 +1,4 @@
using Bit.Core.Billing.Services; using Bit.Core.Billing.Tax.Services;
using Bit.Core.Services; using Bit.Core.Services;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Http.HttpResults;

View File

@ -0,0 +1,36 @@
using Bit.Api.Billing.Models.Requests;
using Bit.Core.Billing.Tax.Commands;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.Billing.Controllers;
[Authorize("Application")]
[Route("tax")]
public class TaxController(
IPreviewTaxAmountCommand previewTaxAmountCommand) : BaseBillingController
{
[HttpPost("preview-amount/organization-trial")]
public async Task<IResult> PreviewTaxAmountForOrganizationTrialAsync(
[FromBody] PreviewTaxAmountForOrganizationTrialRequestBody requestBody)
{
var parameters = new OrganizationTrialParameters
{
PlanType = requestBody.PlanType,
ProductType = requestBody.ProductType,
TaxInformation = new OrganizationTrialParameters.TaxInformationDTO
{
Country = requestBody.TaxInformation.Country,
PostalCode = requestBody.TaxInformation.PostalCode,
TaxId = requestBody.TaxInformation.TaxId
}
};
var result = await previewTaxAmountCommand.Run(parameters);
return result.Match<IResult>(
taxAmount => TypedResults.Ok(new { TaxAmount = taxAmount }),
badRequest => Error.BadRequest(badRequest.TranslationKey),
unhandled => Error.ServerError(unhandled.TranslationKey));
}
}

View File

@ -0,0 +1,27 @@
#nullable enable
using System.ComponentModel.DataAnnotations;
using Bit.Core.Billing.Enums;
namespace Bit.Api.Billing.Models.Requests;
public class PreviewTaxAmountForOrganizationTrialRequestBody
{
[Required]
public PlanType PlanType { get; set; }
[Required]
public ProductType ProductType { get; set; }
[Required] public TaxInformationDTO TaxInformation { get; set; } = null!;
public class TaxInformationDTO
{
[Required]
public string Country { get; set; } = null!;
[Required]
public string PostalCode { get; set; } = null!;
public string? TaxId { get; set; }
}
}

View File

@ -1,5 +1,5 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Bit.Core.Billing.Models; using Bit.Core.Billing.Tax.Models;
namespace Bit.Api.Billing.Models.Requests; namespace Bit.Api.Billing.Models.Requests;

View File

@ -1,4 +1,5 @@
using Bit.Core.Billing.Models; using Bit.Core.Billing.Models;
using Bit.Core.Billing.Tax.Models;
namespace Bit.Api.Billing.Models.Responses; namespace Bit.Api.Billing.Models.Responses;

View File

@ -2,6 +2,8 @@
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.Models; using Bit.Core.Billing.Models;
using Bit.Core.Billing.Providers.Models;
using Bit.Core.Billing.Tax.Models;
using Stripe; using Stripe;
namespace Bit.Api.Billing.Models.Responses; namespace Bit.Api.Billing.Models.Responses;
@ -34,7 +36,7 @@ public record ProviderSubscriptionResponse(
.Select(providerPlan => .Select(providerPlan =>
{ {
var plan = providerPlan.Plan; var plan = providerPlan.Plan;
var cost = (providerPlan.SeatMinimum + providerPlan.PurchasedSeats) * plan.PasswordManager.ProviderPortalSeatPrice; var cost = (providerPlan.SeatMinimum + providerPlan.PurchasedSeats) * providerPlan.Price;
var cadence = plan.IsAnnual ? _annualCadence : _monthlyCadence; var cadence = plan.IsAnnual ? _annualCadence : _monthlyCadence;
return new ProviderPlanResponse( return new ProviderPlanResponse(
plan.Name, plan.Name,

View File

@ -1,4 +1,4 @@
using Bit.Core.Billing.Models; using Bit.Core.Billing.Tax.Models;
namespace Bit.Api.Billing.Models.Responses; namespace Bit.Api.Billing.Models.Responses;

View File

@ -1,6 +1,10 @@
using Bit.Api.Models.Request.Organizations; using Bit.Api.AdminConsole.Authorization.Requirements;
using Bit.Api.Models.Request.Organizations;
using Bit.Api.Models.Response;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Models.Api.Response.OrganizationSponsorships;
using Bit.Core.Models.Data.Organizations.OrganizationSponsorships;
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
@ -22,6 +26,7 @@ public class SelfHostedOrganizationSponsorshipsController : Controller
private readonly IRevokeSponsorshipCommand _revokeSponsorshipCommand; private readonly IRevokeSponsorshipCommand _revokeSponsorshipCommand;
private readonly ICurrentContext _currentContext; private readonly ICurrentContext _currentContext;
private readonly IFeatureService _featureService; private readonly IFeatureService _featureService;
private readonly IAuthorizationService _authorizationService;
public SelfHostedOrganizationSponsorshipsController( public SelfHostedOrganizationSponsorshipsController(
ICreateSponsorshipCommand offerSponsorshipCommand, ICreateSponsorshipCommand offerSponsorshipCommand,
@ -30,7 +35,8 @@ public class SelfHostedOrganizationSponsorshipsController : Controller
IOrganizationSponsorshipRepository organizationSponsorshipRepository, IOrganizationSponsorshipRepository organizationSponsorshipRepository,
IOrganizationUserRepository organizationUserRepository, IOrganizationUserRepository organizationUserRepository,
ICurrentContext currentContext, ICurrentContext currentContext,
IFeatureService featureService IFeatureService featureService,
IAuthorizationService authorizationService
) )
{ {
_offerSponsorshipCommand = offerSponsorshipCommand; _offerSponsorshipCommand = offerSponsorshipCommand;
@ -40,6 +46,7 @@ public class SelfHostedOrganizationSponsorshipsController : Controller
_organizationUserRepository = organizationUserRepository; _organizationUserRepository = organizationUserRepository;
_currentContext = currentContext; _currentContext = currentContext;
_featureService = featureService; _featureService = featureService;
_authorizationService = authorizationService;
} }
[HttpPost("{sponsoringOrgId}/families-for-enterprise")] [HttpPost("{sponsoringOrgId}/families-for-enterprise")]
@ -84,4 +91,41 @@ public class SelfHostedOrganizationSponsorshipsController : Controller
await _revokeSponsorshipCommand.RevokeSponsorshipAsync(existingOrgSponsorship); await _revokeSponsorshipCommand.RevokeSponsorshipAsync(existingOrgSponsorship);
} }
[HttpDelete("{sponsoringOrgId}/{sponsoredFriendlyName}/revoke")]
public async Task AdminInitiatedRevokeSponsorshipAsync(Guid sponsoringOrgId, string sponsoredFriendlyName)
{
var sponsorships = await _organizationSponsorshipRepository.GetManyBySponsoringOrganizationAsync(sponsoringOrgId);
var existingOrgSponsorship = sponsorships.FirstOrDefault(s => s.FriendlyName != null && s.FriendlyName.Equals(sponsoredFriendlyName, StringComparison.OrdinalIgnoreCase));
if (existingOrgSponsorship == null)
{
throw new BadRequestException("The specified sponsored organization could not be found under the given sponsoring organization.");
}
await _revokeSponsorshipCommand.RevokeSponsorshipAsync(existingOrgSponsorship);
}
[Authorize("Application")]
[HttpGet("{orgId}/sponsored")]
public async Task<ListResponseModel<OrganizationSponsorshipInvitesResponseModel>> GetSponsoredOrganizations(Guid orgId)
{
var sponsoringOrg = await _organizationRepository.GetByIdAsync(orgId);
if (sponsoringOrg == null)
{
throw new NotFoundException();
}
var authorizationResult = await _authorizationService.AuthorizeAsync(User, orgId, new ManageUsersRequirement());
if (!authorizationResult.Succeeded)
{
throw new UnauthorizedAccessException();
}
var sponsorships = await _organizationSponsorshipRepository.GetManyBySponsoringOrganizationAsync(orgId);
return new ListResponseModel<OrganizationSponsorshipInvitesResponseModel>(
sponsorships
.Where(s => s.IsAdminInitiated)
.Select(s => new OrganizationSponsorshipInvitesResponseModel(new OrganizationSponsorshipData(s)))
);
}
} }

View File

@ -4,18 +4,20 @@ namespace Bit.Api.Tools.Models.Response;
public class MemberCipherDetailsResponseModel public class MemberCipherDetailsResponseModel
{ {
public Guid? UserGuid { get; set; }
public string UserName { get; set; } public string UserName { get; set; }
public string Email { get; set; } public string Email { get; set; }
public bool UsesKeyConnector { get; set; } public bool UsesKeyConnector { get; set; }
/// <summary> /// <summary>
/// A distinct list of the cipher ids associated with /// A distinct list of the cipher ids associated with
/// the organization member /// the organization member
/// </summary> /// </summary>
public IEnumerable<string> CipherIds { get; set; } public IEnumerable<string> CipherIds { get; set; }
public MemberCipherDetailsResponseModel(MemberAccessCipherDetails memberAccessCipherDetails) public MemberCipherDetailsResponseModel(MemberAccessCipherDetails memberAccessCipherDetails)
{ {
this.UserGuid = memberAccessCipherDetails.UserGuid;
this.UserName = memberAccessCipherDetails.UserName; this.UserName = memberAccessCipherDetails.UserName;
this.Email = memberAccessCipherDetails.Email; this.Email = memberAccessCipherDetails.Email;
this.UsesKeyConnector = memberAccessCipherDetails.UsesKeyConnector; this.UsesKeyConnector = memberAccessCipherDetails.UsesKeyConnector;

View File

@ -12,17 +12,17 @@ namespace Bit.Api.KeyManagement.Validators;
/// </summary> /// </summary>
public class SendRotationValidator : IRotationValidator<IEnumerable<SendWithIdRequestModel>, IReadOnlyList<Send>> public class SendRotationValidator : IRotationValidator<IEnumerable<SendWithIdRequestModel>, IReadOnlyList<Send>>
{ {
private readonly ISendService _sendService; private readonly ISendAuthorizationService _sendAuthorizationService;
private readonly ISendRepository _sendRepository; private readonly ISendRepository _sendRepository;
/// <summary> /// <summary>
/// Instantiates a new <see cref="SendRotationValidator"/> /// Instantiates a new <see cref="SendRotationValidator"/>
/// </summary> /// </summary>
/// <param name="sendService">Enables conversion of <see cref="SendWithIdRequestModel"/> to <see cref="Send"/></param> /// <param name="sendAuthorizationService">Enables conversion of <see cref="SendWithIdRequestModel"/> to <see cref="Send"/></param>
/// <param name="sendRepository">Retrieves all user <see cref="Send"/>s</param> /// <param name="sendRepository">Retrieves all user <see cref="Send"/>s</param>
public SendRotationValidator(ISendService sendService, ISendRepository sendRepository) public SendRotationValidator(ISendAuthorizationService sendAuthorizationService, ISendRepository sendRepository)
{ {
_sendService = sendService; _sendAuthorizationService = sendAuthorizationService;
_sendRepository = sendRepository; _sendRepository = sendRepository;
} }
@ -44,7 +44,7 @@ public class SendRotationValidator : IRotationValidator<IEnumerable<SendWithIdRe
throw new BadRequestException("All existing sends must be included in the rotation."); throw new BadRequestException("All existing sends must be included in the rotation.");
} }
result.Add(send.ToSend(existing, _sendService)); result.Add(send.ToSend(existing, _sendAuthorizationService));
} }
return result; return result;

View File

@ -32,6 +32,7 @@ public class PlanResponseModel : ResponseModel
HasTotp = plan.HasTotp; HasTotp = plan.HasTotp;
Has2fa = plan.Has2fa; Has2fa = plan.Has2fa;
HasSso = plan.HasSso; HasSso = plan.HasSso;
HasOrganizationDomains = plan.HasOrganizationDomains;
HasResetPassword = plan.HasResetPassword; HasResetPassword = plan.HasResetPassword;
UsersGetPremium = plan.UsersGetPremium; UsersGetPremium = plan.UsersGetPremium;
UpgradeSortOrder = plan.UpgradeSortOrder; UpgradeSortOrder = plan.UpgradeSortOrder;
@ -71,6 +72,7 @@ public class PlanResponseModel : ResponseModel
public bool Has2fa { get; set; } public bool Has2fa { get; set; }
public bool HasApi { get; set; } public bool HasApi { get; set; }
public bool HasSso { get; set; } public bool HasSso { get; set; }
public bool HasOrganizationDomains { get; set; }
public bool HasResetPassword { get; set; } public bool HasResetPassword { get; set; }
public bool UsersGetPremium { get; set; } public bool UsersGetPremium { get; set; }

View File

@ -35,6 +35,7 @@ using Bit.Core.Services;
using Bit.Core.Tools.ImportFeatures; using Bit.Core.Tools.ImportFeatures;
using Bit.Core.Tools.ReportFeatures; using Bit.Core.Tools.ReportFeatures;
using Bit.Core.Auth.Models.Api.Request; using Bit.Core.Auth.Models.Api.Request;
using Bit.Core.Tools.SendFeatures;
#if !OSS #if !OSS
using Bit.Commercial.Core.SecretsManager; using Bit.Commercial.Core.SecretsManager;
@ -186,6 +187,7 @@ public class Startup
services.AddPhishingDomainServices(globalSettings); services.AddPhishingDomainServices(globalSettings);
services.AddBillingQueries(); services.AddBillingQueries();
services.AddSendServices();
// Authorization Handlers // Authorization Handlers
services.AddAuthorizationHandlers(); services.AddAuthorizationHandlers();

View File

@ -12,6 +12,8 @@ using Bit.Core.Settings;
using Bit.Core.Tools.Enums; using Bit.Core.Tools.Enums;
using Bit.Core.Tools.Models.Data; using Bit.Core.Tools.Models.Data;
using Bit.Core.Tools.Repositories; using Bit.Core.Tools.Repositories;
using Bit.Core.Tools.SendFeatures;
using Bit.Core.Tools.SendFeatures.Commands.Interfaces;
using Bit.Core.Tools.Services; using Bit.Core.Tools.Services;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
@ -25,8 +27,10 @@ public class SendsController : Controller
{ {
private readonly ISendRepository _sendRepository; private readonly ISendRepository _sendRepository;
private readonly IUserService _userService; private readonly IUserService _userService;
private readonly ISendService _sendService; private readonly ISendAuthorizationService _sendAuthorizationService;
private readonly ISendFileStorageService _sendFileStorageService; private readonly ISendFileStorageService _sendFileStorageService;
private readonly IAnonymousSendCommand _anonymousSendCommand;
private readonly INonAnonymousSendCommand _nonAnonymousSendCommand;
private readonly ILogger<SendsController> _logger; private readonly ILogger<SendsController> _logger;
private readonly GlobalSettings _globalSettings; private readonly GlobalSettings _globalSettings;
private readonly ICurrentContext _currentContext; private readonly ICurrentContext _currentContext;
@ -34,7 +38,9 @@ public class SendsController : Controller
public SendsController( public SendsController(
ISendRepository sendRepository, ISendRepository sendRepository,
IUserService userService, IUserService userService,
ISendService sendService, ISendAuthorizationService sendAuthorizationService,
IAnonymousSendCommand anonymousSendCommand,
INonAnonymousSendCommand nonAnonymousSendCommand,
ISendFileStorageService sendFileStorageService, ISendFileStorageService sendFileStorageService,
ILogger<SendsController> logger, ILogger<SendsController> logger,
GlobalSettings globalSettings, GlobalSettings globalSettings,
@ -42,13 +48,16 @@ public class SendsController : Controller
{ {
_sendRepository = sendRepository; _sendRepository = sendRepository;
_userService = userService; _userService = userService;
_sendService = sendService; _sendAuthorizationService = sendAuthorizationService;
_anonymousSendCommand = anonymousSendCommand;
_nonAnonymousSendCommand = nonAnonymousSendCommand;
_sendFileStorageService = sendFileStorageService; _sendFileStorageService = sendFileStorageService;
_logger = logger; _logger = logger;
_globalSettings = globalSettings; _globalSettings = globalSettings;
_currentContext = currentContext; _currentContext = currentContext;
} }
#region Anonymous endpoints
[AllowAnonymous] [AllowAnonymous]
[HttpPost("access/{id}")] [HttpPost("access/{id}")]
public async Task<IActionResult> Access(string id, [FromBody] SendAccessRequestModel model) public async Task<IActionResult> Access(string id, [FromBody] SendAccessRequestModel model)
@ -61,18 +70,19 @@ public class SendsController : Controller
//} //}
var guid = new Guid(CoreHelpers.Base64UrlDecode(id)); var guid = new Guid(CoreHelpers.Base64UrlDecode(id));
var (send, passwordRequired, passwordInvalid) = var send = await _sendRepository.GetByIdAsync(guid);
await _sendService.AccessAsync(guid, model.Password); SendAccessResult sendAuthResult =
if (passwordRequired) await _sendAuthorizationService.AccessAsync(send, model.Password);
if (sendAuthResult.Equals(SendAccessResult.PasswordRequired))
{ {
return new UnauthorizedResult(); return new UnauthorizedResult();
} }
if (passwordInvalid) if (sendAuthResult.Equals(SendAccessResult.PasswordInvalid))
{ {
await Task.Delay(2000); await Task.Delay(2000);
throw new BadRequestException("Invalid password."); throw new BadRequestException("Invalid password.");
} }
if (send == null) if (sendAuthResult.Equals(SendAccessResult.Denied))
{ {
throw new NotFoundException(); throw new NotFoundException();
} }
@ -106,19 +116,19 @@ public class SendsController : Controller
throw new BadRequestException("Could not locate send"); throw new BadRequestException("Could not locate send");
} }
var (url, passwordRequired, passwordInvalid) = await _sendService.GetSendFileDownloadUrlAsync(send, fileId, var (url, result) = await _anonymousSendCommand.GetSendFileDownloadUrlAsync(send, fileId,
model.Password); model.Password);
if (passwordRequired) if (result.Equals(SendAccessResult.PasswordRequired))
{ {
return new UnauthorizedResult(); return new UnauthorizedResult();
} }
if (passwordInvalid) if (result.Equals(SendAccessResult.PasswordInvalid))
{ {
await Task.Delay(2000); await Task.Delay(2000);
throw new BadRequestException("Invalid password."); throw new BadRequestException("Invalid password.");
} }
if (send == null) if (result.Equals(SendAccessResult.Denied))
{ {
throw new NotFoundException(); throw new NotFoundException();
} }
@ -130,6 +140,45 @@ public class SendsController : Controller
}); });
} }
[AllowAnonymous]
[HttpPost("file/validate/azure")]
public async Task<ObjectResult> AzureValidateFile()
{
return await ApiHelpers.HandleAzureEvents(Request, new Dictionary<string, Func<EventGridEvent, Task>>
{
{
"Microsoft.Storage.BlobCreated", async (eventGridEvent) =>
{
try
{
var blobName = eventGridEvent.Subject.Split($"{AzureSendFileStorageService.FilesContainerName}/blobs/")[1];
var sendId = AzureSendFileStorageService.SendIdFromBlobName(blobName);
var send = await _sendRepository.GetByIdAsync(new Guid(sendId));
if (send == null)
{
if (_sendFileStorageService is AzureSendFileStorageService azureSendFileStorageService)
{
await azureSendFileStorageService.DeleteBlobAsync(blobName);
}
return;
}
await _nonAnonymousSendCommand.ConfirmFileSize(send);
}
catch (Exception e)
{
_logger.LogError(e, $"Uncaught exception occurred while handling event grid event: {JsonSerializer.Serialize(eventGridEvent)}");
return;
}
}
}
});
}
#endregion
#region Non-anonymous endpoints
[HttpGet("{id}")] [HttpGet("{id}")]
public async Task<SendResponseModel> Get(string id) public async Task<SendResponseModel> Get(string id)
{ {
@ -157,8 +206,8 @@ public class SendsController : Controller
{ {
model.ValidateCreation(); model.ValidateCreation();
var userId = _userService.GetProperUserId(User).Value; var userId = _userService.GetProperUserId(User).Value;
var send = model.ToSend(userId, _sendService); var send = model.ToSend(userId, _sendAuthorizationService);
await _sendService.SaveSendAsync(send); await _nonAnonymousSendCommand.SaveSendAsync(send);
return new SendResponseModel(send, _globalSettings); return new SendResponseModel(send, _globalSettings);
} }
@ -175,15 +224,15 @@ public class SendsController : Controller
throw new BadRequestException("Invalid content. File size hint is required."); throw new BadRequestException("Invalid content. File size hint is required.");
} }
if (model.FileLength.Value > SendService.MAX_FILE_SIZE) if (model.FileLength.Value > Constants.FileSize501mb)
{ {
throw new BadRequestException($"Max file size is {SendService.MAX_FILE_SIZE_READABLE}."); throw new BadRequestException($"Max file size is {SendFileSettingHelper.MAX_FILE_SIZE_READABLE}.");
} }
model.ValidateCreation(); model.ValidateCreation();
var userId = _userService.GetProperUserId(User).Value; var userId = _userService.GetProperUserId(User).Value;
var (send, data) = model.ToSend(userId, model.File.FileName, _sendService); var (send, data) = model.ToSend(userId, model.File.FileName, _sendAuthorizationService);
var uploadUrl = await _sendService.SaveFileSendAsync(send, data, model.FileLength.Value); var uploadUrl = await _nonAnonymousSendCommand.SaveFileSendAsync(send, data, model.FileLength.Value);
return new SendFileUploadDataResponseModel return new SendFileUploadDataResponseModel
{ {
Url = uploadUrl, Url = uploadUrl,
@ -230,41 +279,7 @@ public class SendsController : Controller
var send = await _sendRepository.GetByIdAsync(new Guid(id)); var send = await _sendRepository.GetByIdAsync(new Guid(id));
await Request.GetFileAsync(async (stream) => await Request.GetFileAsync(async (stream) =>
{ {
await _sendService.UploadFileToExistingSendAsync(stream, send); await _nonAnonymousSendCommand.UploadFileToExistingSendAsync(stream, send);
});
}
[AllowAnonymous]
[HttpPost("file/validate/azure")]
public async Task<ObjectResult> AzureValidateFile()
{
return await ApiHelpers.HandleAzureEvents(Request, new Dictionary<string, Func<EventGridEvent, Task>>
{
{
"Microsoft.Storage.BlobCreated", async (eventGridEvent) =>
{
try
{
var blobName = eventGridEvent.Subject.Split($"{AzureSendFileStorageService.FilesContainerName}/blobs/")[1];
var sendId = AzureSendFileStorageService.SendIdFromBlobName(blobName);
var send = await _sendRepository.GetByIdAsync(new Guid(sendId));
if (send == null)
{
if (_sendFileStorageService is AzureSendFileStorageService azureSendFileStorageService)
{
await azureSendFileStorageService.DeleteBlobAsync(blobName);
}
return;
}
await _sendService.ValidateSendFile(send);
}
catch (Exception e)
{
_logger.LogError(e, $"Uncaught exception occurred while handling event grid event: {JsonSerializer.Serialize(eventGridEvent)}");
return;
}
}
}
}); });
} }
@ -279,7 +294,7 @@ public class SendsController : Controller
throw new NotFoundException(); throw new NotFoundException();
} }
await _sendService.SaveSendAsync(model.ToSend(send, _sendService)); await _nonAnonymousSendCommand.SaveSendAsync(model.ToSend(send, _sendAuthorizationService));
return new SendResponseModel(send, _globalSettings); return new SendResponseModel(send, _globalSettings);
} }
@ -294,7 +309,7 @@ public class SendsController : Controller
} }
send.Password = null; send.Password = null;
await _sendService.SaveSendAsync(send); await _nonAnonymousSendCommand.SaveSendAsync(send);
return new SendResponseModel(send, _globalSettings); return new SendResponseModel(send, _globalSettings);
} }
@ -308,6 +323,8 @@ public class SendsController : Controller
throw new NotFoundException(); throw new NotFoundException();
} }
await _sendService.DeleteSendAsync(send); await _nonAnonymousSendCommand.DeleteSendAsync(send);
} }
#endregion
} }

View File

@ -36,31 +36,31 @@ public class SendRequestModel
public bool? Disabled { get; set; } public bool? Disabled { get; set; }
public bool? HideEmail { get; set; } public bool? HideEmail { get; set; }
public Send ToSend(Guid userId, ISendService sendService) public Send ToSend(Guid userId, ISendAuthorizationService sendAuthorizationService)
{ {
var send = new Send var send = new Send
{ {
Type = Type, Type = Type,
UserId = (Guid?)userId UserId = (Guid?)userId
}; };
ToSend(send, sendService); ToSend(send, sendAuthorizationService);
return send; return send;
} }
public (Send, SendFileData) ToSend(Guid userId, string fileName, ISendService sendService) public (Send, SendFileData) ToSend(Guid userId, string fileName, ISendAuthorizationService sendAuthorizationService)
{ {
var send = ToSendBase(new Send var send = ToSendBase(new Send
{ {
Type = Type, Type = Type,
UserId = (Guid?)userId UserId = (Guid?)userId
}, sendService); }, sendAuthorizationService);
var data = new SendFileData(Name, Notes, fileName); var data = new SendFileData(Name, Notes, fileName);
return (send, data); return (send, data);
} }
public Send ToSend(Send existingSend, ISendService sendService) public Send ToSend(Send existingSend, ISendAuthorizationService sendAuthorizationService)
{ {
existingSend = ToSendBase(existingSend, sendService); existingSend = ToSendBase(existingSend, sendAuthorizationService);
switch (existingSend.Type) switch (existingSend.Type)
{ {
case SendType.File: case SendType.File:
@ -125,7 +125,7 @@ public class SendRequestModel
} }
} }
private Send ToSendBase(Send existingSend, ISendService sendService) private Send ToSendBase(Send existingSend, ISendAuthorizationService authorizationService)
{ {
existingSend.Key = Key; existingSend.Key = Key;
existingSend.ExpirationDate = ExpirationDate; existingSend.ExpirationDate = ExpirationDate;
@ -133,7 +133,7 @@ public class SendRequestModel
existingSend.MaxAccessCount = MaxAccessCount; existingSend.MaxAccessCount = MaxAccessCount;
if (!string.IsNullOrWhiteSpace(Password)) if (!string.IsNullOrWhiteSpace(Password))
{ {
existingSend.Password = sendService.HashPassword(Password); existingSend.Password = authorizationService.HashPassword(Password);
} }
existingSend.Disabled = Disabled.GetValueOrDefault(); existingSend.Disabled = Disabled.GetValueOrDefault();
existingSend.HideEmail = HideEmail.GetValueOrDefault(); existingSend.HideEmail = HideEmail.GetValueOrDefault();

View File

@ -1,31 +0,0 @@
using Bit.Core.Models.Commands;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.Utilities;
public static class CommandResultExtensions
{
public static IActionResult MapToActionResult<T>(this CommandResult<T> commandResult)
{
return commandResult switch
{
NoRecordFoundFailure<T> failure => new ObjectResult(failure.ErrorMessages) { StatusCode = StatusCodes.Status404NotFound },
BadRequestFailure<T> failure => new ObjectResult(failure.ErrorMessages) { StatusCode = StatusCodes.Status400BadRequest },
Failure<T> failure => new ObjectResult(failure.ErrorMessages) { StatusCode = StatusCodes.Status400BadRequest },
Success<T> success => new ObjectResult(success.Value) { StatusCode = StatusCodes.Status200OK },
_ => throw new InvalidOperationException($"Unhandled commandResult type: {commandResult.GetType().Name}")
};
}
public static IActionResult MapToActionResult(this CommandResult commandResult)
{
return commandResult switch
{
NoRecordFoundFailure failure => new ObjectResult(failure.ErrorMessages) { StatusCode = StatusCodes.Status404NotFound },
BadRequestFailure failure => new ObjectResult(failure.ErrorMessages) { StatusCode = StatusCodes.Status400BadRequest },
Failure failure => new ObjectResult(failure.ErrorMessages) { StatusCode = StatusCodes.Status400BadRequest },
Success => new ObjectResult(new { }) { StatusCode = StatusCodes.Status200OK },
_ => throw new InvalidOperationException($"Unhandled commandResult type: {commandResult.GetType().Name}")
};
}
}

View File

@ -315,26 +315,10 @@ public class CiphersController : Controller
{ {
var org = _currentContext.GetOrganization(organizationId); var org = _currentContext.GetOrganization(organizationId);
// If we're not an "admin", we don't need to check the ciphers // If we're not an "admin" or if we're not a provider user we don't need to check the ciphers
if (org is not ({ Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or { Permissions.EditAnyCollection: true })) if (org is not ({ Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or { Permissions.EditAnyCollection: true }) || await _currentContext.ProviderUserForOrgAsync(organizationId))
{ {
// Are we a provider user? If so, we need to be sure we're not restricted return false;
// Once the feature flag is removed, this check can be combined with the above
if (await _currentContext.ProviderUserForOrgAsync(organizationId))
{
// Provider is restricted from editing ciphers, so we're not an "admin"
if (_featureService.IsEnabled(FeatureFlagKeys.RestrictProviderAccess))
{
return false;
}
// Provider is unrestricted, so we're an "admin", don't return early
}
else
{
// Not a provider or admin
return false;
}
} }
// We know we're an "admin", now check the ciphers explicitly (in case admins are restricted) // We know we're an "admin", now check the ciphers explicitly (in case admins are restricted)
@ -350,26 +334,10 @@ public class CiphersController : Controller
var org = _currentContext.GetOrganization(organizationId); var org = _currentContext.GetOrganization(organizationId);
// If we're not an "admin", we don't need to check the ciphers // If we're not an "admin" or if we're a provider user we don't need to check the ciphers
if (org is not ({ Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or { Permissions.EditAnyCollection: true })) if (org is not ({ Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or { Permissions.EditAnyCollection: true }) || await _currentContext.ProviderUserForOrgAsync(organizationId))
{ {
// Are we a provider user? If so, we need to be sure we're not restricted return false;
// Once the feature flag is removed, this check can be combined with the above
if (await _currentContext.ProviderUserForOrgAsync(organizationId))
{
// Provider is restricted from editing ciphers, so we're not an "admin"
if (_featureService.IsEnabled(FeatureFlagKeys.RestrictProviderAccess))
{
return false;
}
// Provider is unrestricted, so we're an "admin", don't return early
}
else
{
// Not a provider or admin
return false;
}
} }
// If the user can edit all ciphers for the organization, just check they all belong to the org // If the user can edit all ciphers for the organization, just check they all belong to the org
@ -462,10 +430,10 @@ public class CiphersController : Controller
return true; return true;
} }
// Provider users can edit all ciphers if RestrictProviderAccess is disabled // Provider users cannot edit ciphers
if (await _currentContext.ProviderUserForOrgAsync(organizationId)) if (await _currentContext.ProviderUserForOrgAsync(organizationId))
{ {
return !_featureService.IsEnabled(FeatureFlagKeys.RestrictProviderAccess); return false;
} }
return false; return false;
@ -485,10 +453,10 @@ public class CiphersController : Controller
return true; return true;
} }
// Provider users can only access organization ciphers if RestrictProviderAccess is disabled // Provider users cannot access organization ciphers
if (await _currentContext.ProviderUserForOrgAsync(organizationId)) if (await _currentContext.ProviderUserForOrgAsync(organizationId))
{ {
return !_featureService.IsEnabled(FeatureFlagKeys.RestrictProviderAccess); return false;
} }
return false; return false;
@ -508,10 +476,10 @@ public class CiphersController : Controller
return true; return true;
} }
// Provider users can only access all ciphers if RestrictProviderAccess is disabled // Provider users cannot access ciphers
if (await _currentContext.ProviderUserForOrgAsync(organizationId)) if (await _currentContext.ProviderUserForOrgAsync(organizationId))
{ {
return !_featureService.IsEnabled(FeatureFlagKeys.RestrictProviderAccess); return false;
} }
return false; return false;
@ -1086,9 +1054,8 @@ public class CiphersController : Controller
throw new BadRequestException(ModelState); throw new BadRequestException(ModelState);
} }
// If Account Deprovisioning is enabled, we need to check if the user is claimed by any organization. // Check if the user is claimed by any organization.
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) if (await _userService.IsClaimedByAnyOrganizationAsync(user.Id))
&& await _userService.IsClaimedByAnyOrganizationAsync(user.Id))
{ {
throw new BadRequestException("Cannot purge accounts owned by an organization. Contact your organization administrator for additional details."); throw new BadRequestException("Cannot purge accounts owned by an organization. Contact your organization administrator for additional details.");
} }

View File

@ -3,6 +3,7 @@ using Bit.Core;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
@ -37,6 +38,7 @@ public class SyncController : Controller
private readonly Version _sshKeyCipherMinimumVersion = new(Constants.SSHKeyCipherMinimumVersion); private readonly Version _sshKeyCipherMinimumVersion = new(Constants.SSHKeyCipherMinimumVersion);
private readonly IFeatureService _featureService; private readonly IFeatureService _featureService;
private readonly IApplicationCacheService _applicationCacheService; private readonly IApplicationCacheService _applicationCacheService;
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
public SyncController( public SyncController(
IUserService userService, IUserService userService,
@ -51,7 +53,8 @@ public class SyncController : Controller
GlobalSettings globalSettings, GlobalSettings globalSettings,
ICurrentContext currentContext, ICurrentContext currentContext,
IFeatureService featureService, IFeatureService featureService,
IApplicationCacheService applicationCacheService) IApplicationCacheService applicationCacheService,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery)
{ {
_userService = userService; _userService = userService;
_folderRepository = folderRepository; _folderRepository = folderRepository;
@ -66,6 +69,7 @@ public class SyncController : Controller
_currentContext = currentContext; _currentContext = currentContext;
_featureService = featureService; _featureService = featureService;
_applicationCacheService = applicationCacheService; _applicationCacheService = applicationCacheService;
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
} }
[HttpGet("")] [HttpGet("")]
@ -102,7 +106,7 @@ public class SyncController : Controller
collectionCiphersGroupDict = collectionCiphers.GroupBy(c => c.CipherId).ToDictionary(s => s.Key); collectionCiphersGroupDict = collectionCiphers.GroupBy(c => c.CipherId).ToDictionary(s => s.Key);
} }
var userTwoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user); var userTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user);
var userHasPremiumFromOrganization = await _userService.HasPremiumFromOrganization(user); var userHasPremiumFromOrganization = await _userService.HasPremiumFromOrganization(user);
var organizationClaimingActiveUser = await _userService.GetOrganizationsClaimingUserAsync(user.Id); var organizationClaimingActiveUser = await _userService.GetOrganizationsClaimingUserAsync(user.Id);
var organizationIdsClaimingActiveUser = organizationClaimingActiveUser.Select(o => o.Id); var organizationIdsClaimingActiveUser = organizationClaimingActiveUser.Select(o => o.Id);

View File

@ -63,6 +63,12 @@ public class FreshdeskController : Controller
note += $"<li>Region: {_billingSettings.FreshDesk.Region}</li>"; note += $"<li>Region: {_billingSettings.FreshDesk.Region}</li>";
var customFields = new Dictionary<string, object>(); var customFields = new Dictionary<string, object>();
var user = await _userRepository.GetByEmailAsync(ticketContactEmail); var user = await _userRepository.GetByEmailAsync(ticketContactEmail);
if (user == null)
{
note += $"<li>No user found: {ticketContactEmail}</li>";
await CreateNote(ticketId, note);
}
if (user != null) if (user != null)
{ {
var userLink = $"{_globalSettings.BaseServiceUri.Admin}/users/edit/{user.Id}"; var userLink = $"{_globalSettings.BaseServiceUri.Admin}/users/edit/{user.Id}";
@ -121,18 +127,7 @@ public class FreshdeskController : Controller
Content = JsonContent.Create(updateBody), Content = JsonContent.Create(updateBody),
}; };
await CallFreshdeskApiAsync(updateRequest); await CallFreshdeskApiAsync(updateRequest);
await CreateNote(ticketId, note);
var noteBody = new Dictionary<string, object>
{
{ "body", $"<ul>{note}</ul>" },
{ "private", true }
};
var noteRequest = new HttpRequestMessage(HttpMethod.Post,
string.Format("https://bitwarden.freshdesk.com/api/v2/tickets/{0}/notes", ticketId))
{
Content = JsonContent.Create(noteBody),
};
await CallFreshdeskApiAsync(noteRequest);
} }
return new OkResult(); return new OkResult();
@ -208,6 +203,21 @@ public class FreshdeskController : Controller
return true; return true;
} }
private async Task CreateNote(string ticketId, string note)
{
var noteBody = new Dictionary<string, object>
{
{ "body", $"<ul>{note}</ul>" },
{ "private", true }
};
var noteRequest = new HttpRequestMessage(HttpMethod.Post,
string.Format("https://bitwarden.freshdesk.com/api/v2/tickets/{0}/notes", ticketId))
{
Content = JsonContent.Create(noteBody),
};
await CallFreshdeskApiAsync(noteRequest);
}
private async Task AddAnswerNoteToTicketAsync(string note, string ticketId) private async Task AddAnswerNoteToTicketAsync(string note, string ticketId)
{ {
// if there is no content, then we don't need to add a note // if there is no content, then we don't need to add a note

View File

@ -1,8 +1,8 @@
using Bit.Billing.Constants; using Bit.Billing.Constants;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Entities;
using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Repositories; using Bit.Core.Billing.Providers.Entities;
using Bit.Core.Billing.Providers.Repositories;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Stripe; using Stripe;

View File

@ -1,11 +1,11 @@
using Bit.Core; using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Constants; 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.Pricing; using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Services.Contracts;
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
@ -25,8 +25,7 @@ public class UpcomingInvoiceHandler(
IStripeEventService stripeEventService, IStripeEventService stripeEventService,
IStripeEventUtilityService stripeEventUtilityService, IStripeEventUtilityService stripeEventUtilityService,
IUserRepository userRepository, IUserRepository userRepository,
IValidateSponsorshipCommand validateSponsorshipCommand, IValidateSponsorshipCommand validateSponsorshipCommand)
IAutomaticTaxFactory automaticTaxFactory)
: IUpcomingInvoiceHandler : IUpcomingInvoiceHandler
{ {
public async Task HandleAsync(Event parsedEvent) public async Task HandleAsync(Event parsedEvent)
@ -46,6 +45,8 @@ public class UpcomingInvoiceHandler(
var (organizationId, userId, providerId) = stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata); var (organizationId, userId, providerId) = stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata);
var setNonUSBusinessUseToReverseCharge = featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge);
if (organizationId.HasValue) if (organizationId.HasValue)
{ {
var organization = await organizationRepository.GetByIdAsync(organizationId.Value); var organization = await organizationRepository.GetByIdAsync(organizationId.Value);
@ -55,7 +56,7 @@ public class UpcomingInvoiceHandler(
return; return;
} }
await TryEnableAutomaticTaxAsync(subscription); await AlignOrganizationTaxConcernsAsync(organization, subscription, parsedEvent.Id, setNonUSBusinessUseToReverseCharge);
var plan = await pricingClient.GetPlanOrThrow(organization.PlanType); var plan = await pricingClient.GetPlanOrThrow(organization.PlanType);
@ -100,7 +101,25 @@ public class UpcomingInvoiceHandler(
return; return;
} }
await TryEnableAutomaticTaxAsync(subscription); if (!subscription.AutomaticTax.Enabled && subscription.Customer.HasRecognizedTaxLocation())
{
try
{
await stripeFacade.UpdateSubscription(subscription.Id,
new SubscriptionUpdateOptions
{
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
});
}
catch (Exception exception)
{
logger.LogError(
exception,
"Failed to set user's ({UserID}) subscription to automatic tax while processing event with ID {EventID}",
user.Id,
parsedEvent.Id);
}
}
if (user.Premium) if (user.Premium)
{ {
@ -116,7 +135,7 @@ public class UpcomingInvoiceHandler(
return; return;
} }
await TryEnableAutomaticTaxAsync(subscription); await AlignProviderTaxConcernsAsync(provider, subscription, parsedEvent.Id, setNonUSBusinessUseToReverseCharge);
await SendUpcomingInvoiceEmailsAsync(new List<string> { provider.BillingEmail }, invoice); await SendUpcomingInvoiceEmailsAsync(new List<string> { provider.BillingEmail }, invoice);
} }
@ -139,50 +158,123 @@ public class UpcomingInvoiceHandler(
} }
} }
private async Task TryEnableAutomaticTaxAsync(Subscription subscription) private async Task AlignOrganizationTaxConcernsAsync(
Organization organization,
Subscription subscription,
string eventId,
bool setNonUSBusinessUseToReverseCharge)
{ {
if (featureService.IsEnabled(FeatureFlagKeys.PM19147_AutomaticTaxImprovements)) var nonUSBusinessUse =
{ organization.PlanType.GetProductTier() != ProductTierType.Families &&
var automaticTaxParameters = new AutomaticTaxFactoryParameters(subscription.Items.Select(x => x.Price.Id)); subscription.Customer.Address.Country != "US";
var automaticTaxStrategy = await automaticTaxFactory.CreateAsync(automaticTaxParameters);
var updateOptions = automaticTaxStrategy.GetUpdateOptions(subscription);
if (updateOptions == null) bool setAutomaticTaxToEnabled;
if (setNonUSBusinessUseToReverseCharge)
{
if (nonUSBusinessUse && subscription.Customer.TaxExempt != StripeConstants.TaxExempt.Reverse)
{ {
return; try
{
await stripeFacade.UpdateCustomer(subscription.CustomerId,
new CustomerUpdateOptions { TaxExempt = StripeConstants.TaxExempt.Reverse });
}
catch (Exception exception)
{
logger.LogError(
exception,
"Failed to set organization's ({OrganizationID}) to reverse tax exemption while processing event with ID {EventID}",
organization.Id,
eventId);
}
} }
await stripeFacade.UpdateSubscription(subscription.Id, updateOptions); setAutomaticTaxToEnabled = true;
return;
} }
else
if (subscription.AutomaticTax.Enabled ||
!subscription.Customer.HasBillingLocation() ||
await IsNonTaxableNonUSBusinessUseSubscription(subscription))
{ {
return; setAutomaticTaxToEnabled =
subscription.Customer.HasRecognizedTaxLocation() &&
(subscription.Customer.Address.Country == "US" ||
(nonUSBusinessUse && subscription.Customer.TaxIds.Any()));
} }
await stripeFacade.UpdateSubscription(subscription.Id, if (!subscription.AutomaticTax.Enabled && setAutomaticTaxToEnabled)
new SubscriptionUpdateOptions {
try
{ {
DefaultTaxRates = [], await stripeFacade.UpdateSubscription(subscription.Id,
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true } new SubscriptionUpdateOptions
}); {
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
});
}
catch (Exception exception)
{
logger.LogError(
exception,
"Failed to set organization's ({OrganizationID}) subscription to automatic tax while processing event with ID {EventID}",
organization.Id,
eventId);
}
}
}
return; private async Task AlignProviderTaxConcernsAsync(
Provider provider,
Subscription subscription,
string eventId,
bool setNonUSBusinessUseToReverseCharge)
{
bool setAutomaticTaxToEnabled;
async Task<bool> IsNonTaxableNonUSBusinessUseSubscription(Subscription localSubscription) if (setNonUSBusinessUseToReverseCharge)
{ {
var familyPriceIds = (await Task.WhenAll( if (subscription.Customer.Address.Country != "US" && subscription.Customer.TaxExempt != StripeConstants.TaxExempt.Reverse)
pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019), {
pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually))) try
.Select(plan => plan.PasswordManager.StripePlanId); {
await stripeFacade.UpdateCustomer(subscription.CustomerId,
new CustomerUpdateOptions { TaxExempt = StripeConstants.TaxExempt.Reverse });
}
catch (Exception exception)
{
logger.LogError(
exception,
"Failed to set provider's ({ProviderID}) to reverse tax exemption while processing event with ID {EventID}",
provider.Id,
eventId);
}
}
return localSubscription.Customer.Address.Country != "US" && setAutomaticTaxToEnabled = true;
localSubscription.Metadata.ContainsKey(StripeConstants.MetadataKeys.OrganizationId) && }
!localSubscription.Items.Select(item => item.Price.Id).Intersect(familyPriceIds).Any() && else
!localSubscription.Customer.TaxIds.Any(); {
setAutomaticTaxToEnabled =
subscription.Customer.HasRecognizedTaxLocation() &&
(subscription.Customer.Address.Country == "US" ||
subscription.Customer.TaxIds.Any());
}
if (!subscription.AutomaticTax.Enabled && setAutomaticTaxToEnabled)
{
try
{
await stripeFacade.UpdateSubscription(subscription.Id,
new SubscriptionUpdateOptions
{
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
});
}
catch (Exception exception)
{
logger.LogError(
exception,
"Failed to set provider's ({ProviderID}) subscription to automatic tax while processing event with ID {EventID}",
provider.Id,
eventId);
}
} }
} }
} }

View File

@ -114,6 +114,11 @@ public class Organization : ITableObject<Guid>, IStorableSubscriber, IRevisable,
/// </summary> /// </summary>
public bool UseRiskInsights { get; set; } public bool UseRiskInsights { get; set; }
/// <summary>
/// If true, the organization can claim domains, which unlocks additional enterprise features
/// </summary>
public bool UseOrganizationDomains { get; set; }
/// <summary> /// <summary>
/// If set to true, admins can initiate organization-issued sponsorships. /// If set to true, admins can initiate organization-issued sponsorships.
/// </summary> /// </summary>
@ -319,5 +324,7 @@ public class Organization : ITableObject<Guid>, IStorableSubscriber, IRevisable,
SmSeats = license.SmSeats; SmSeats = license.SmSeats;
SmServiceAccounts = license.SmServiceAccounts; SmServiceAccounts = license.SmServiceAccounts;
UseRiskInsights = license.UseRiskInsights; UseRiskInsights = license.UseRiskInsights;
UseOrganizationDomains = license.UseOrganizationDomains;
UseAdminSponsoredFamilies = license.UseAdminSponsoredFamilies;
} }
} }

View File

@ -26,6 +26,7 @@ public class OrganizationAbility
LimitItemDeletion = organization.LimitItemDeletion; LimitItemDeletion = organization.LimitItemDeletion;
AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems; AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems;
UseRiskInsights = organization.UseRiskInsights; UseRiskInsights = organization.UseRiskInsights;
UseOrganizationDomains = organization.UseOrganizationDomains;
UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies; UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies;
} }
@ -46,5 +47,6 @@ public class OrganizationAbility
public bool LimitItemDeletion { get; set; } public bool LimitItemDeletion { get; set; }
public bool AllowAdminAccessToAllCollectionItems { get; set; } public bool AllowAdminAccessToAllCollectionItems { get; set; }
public bool UseRiskInsights { get; set; } public bool UseRiskInsights { get; set; }
public bool UseOrganizationDomains { get; set; }
public bool UseAdminSponsoredFamilies { get; set; } public bool UseAdminSponsoredFamilies { get; set; }
} }

View File

@ -59,5 +59,7 @@ public class OrganizationUserOrganizationDetails
public bool LimitItemDeletion { get; set; } public bool LimitItemDeletion { get; set; }
public bool AllowAdminAccessToAllCollectionItems { get; set; } public bool AllowAdminAccessToAllCollectionItems { get; set; }
public bool UseRiskInsights { get; set; } public bool UseRiskInsights { get; set; }
public bool UseOrganizationDomains { get; set; }
public bool UseAdminSponsoredFamilies { get; set; } public bool UseAdminSponsoredFamilies { get; set; }
public bool? IsAdminInitiated { get; set; }
} }

View File

@ -150,6 +150,7 @@ public class SelfHostedOrganizationDetails : Organization
AllowAdminAccessToAllCollectionItems = AllowAdminAccessToAllCollectionItems, AllowAdminAccessToAllCollectionItems = AllowAdminAccessToAllCollectionItems,
Status = Status, Status = Status,
UseRiskInsights = UseRiskInsights, UseRiskInsights = UseRiskInsights,
UseAdminSponsoredFamilies = UseAdminSponsoredFamilies,
}; };
} }
} }

View File

@ -45,6 +45,7 @@ public class ProviderUserOrganizationDetails
public bool LimitItemDeletion { get; set; } public bool LimitItemDeletion { get; set; }
public bool AllowAdminAccessToAllCollectionItems { get; set; } public bool AllowAdminAccessToAllCollectionItems { get; set; }
public bool UseRiskInsights { get; set; } public bool UseRiskInsights { get; set; }
public bool UseOrganizationDomains { get; set; }
public bool UseAdminSponsoredFamilies { get; set; } public bool UseAdminSponsoredFamilies { get; set; }
public ProviderType ProviderType { get; set; } public ProviderType ProviderType { get; set; }
} }

View File

@ -20,7 +20,6 @@ public class VerifyOrganizationDomainCommand(
IDnsResolverService dnsResolverService, IDnsResolverService dnsResolverService,
IEventService eventService, IEventService eventService,
IGlobalSettings globalSettings, IGlobalSettings globalSettings,
IFeatureService featureService,
ICurrentContext currentContext, ICurrentContext currentContext,
ISavePolicyCommand savePolicyCommand, ISavePolicyCommand savePolicyCommand,
IMailService mailService, IMailService mailService,
@ -125,11 +124,8 @@ public class VerifyOrganizationDomainCommand(
private async Task DomainVerificationSideEffectsAsync(OrganizationDomain domain, IActingUser actingUser) private async Task DomainVerificationSideEffectsAsync(OrganizationDomain domain, IActingUser actingUser)
{ {
if (featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)) await EnableSingleOrganizationPolicyAsync(domain.OrganizationId, actingUser);
{ await SendVerifiedDomainUserEmailAsync(domain);
await EnableSingleOrganizationPolicyAsync(domain.OrganizationId, actingUser);
await SendVerifiedDomainUserEmailAsync(domain);
}
} }
private async Task EnableSingleOrganizationPolicyAsync(Guid organizationId, IActingUser actingUser) => private async Task EnableSingleOrganizationPolicyAsync(Guid organizationId, IActingUser actingUser) =>

View File

@ -1,6 +1,7 @@
using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services;
using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
@ -24,6 +25,7 @@ public class AcceptOrgUserCommand : IAcceptOrgUserCommand
private readonly IPolicyService _policyService; private readonly IPolicyService _policyService;
private readonly IMailService _mailService; private readonly IMailService _mailService;
private readonly IUserRepository _userRepository; private readonly IUserRepository _userRepository;
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory; private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
public AcceptOrgUserCommand( public AcceptOrgUserCommand(
@ -34,6 +36,7 @@ public class AcceptOrgUserCommand : IAcceptOrgUserCommand
IPolicyService policyService, IPolicyService policyService,
IMailService mailService, IMailService mailService,
IUserRepository userRepository, IUserRepository userRepository,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory) IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory)
{ {
@ -45,6 +48,7 @@ public class AcceptOrgUserCommand : IAcceptOrgUserCommand
_policyService = policyService; _policyService = policyService;
_mailService = mailService; _mailService = mailService;
_userRepository = userRepository; _userRepository = userRepository;
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
_orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory; _orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory;
} }
@ -192,7 +196,7 @@ public class AcceptOrgUserCommand : IAcceptOrgUserCommand
} }
// Enforce Two Factor Authentication Policy of organization user is trying to join // Enforce Two Factor Authentication Policy of organization user is trying to join
if (!await userService.TwoFactorIsEnabledAsync(user)) if (!await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user))
{ {
var invitedTwoFactorPolicies = await _policyService.GetPoliciesApplicableToUserAsync(user.Id, var invitedTwoFactorPolicies = await _policyService.GetPoliciesApplicableToUserAsync(user.Id,
PolicyType.TwoFactorAuthentication, OrganizationUserStatusType.Invited); PolicyType.TwoFactorAuthentication, OrganizationUserStatusType.Invited);

View File

@ -24,9 +24,7 @@ public class GetOrganizationUsersClaimedStatusQuery : IGetOrganizationUsersClaim
// Users can only be claimed by an Organization that is enabled and can have organization domains // Users can only be claimed by an Organization that is enabled and can have organization domains
var organizationAbility = await _applicationCacheService.GetOrganizationAbilityAsync(organizationId); var organizationAbility = await _applicationCacheService.GetOrganizationAbilityAsync(organizationId);
// TODO: Replace "UseSso" with a new organization ability like "UseOrganizationDomains" (PM-11622). if (organizationAbility is { Enabled: true, UseOrganizationDomains: true })
// Verified domains were tied to SSO, so we currently check the "UseSso" organization ability.
if (organizationAbility is { Enabled: true, UseSso: true })
{ {
// Get all organization users with claimed domains by the organization // Get all organization users with claimed domains by the organization
var organizationUsersWithClaimedDomain = await _organizationUserRepository.GetManyByOrganizationWithClaimedDomainsAsync(organizationId); var organizationUsersWithClaimedDomain = await _organizationUserRepository.GetManyByOrganizationWithClaimedDomainsAsync(organizationId);

View File

@ -1,4 +1,5 @@
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Exceptions;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;

View File

@ -6,4 +6,8 @@ namespace Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
public interface IOrganizationUserUserDetailsQuery public interface IOrganizationUserUserDetailsQuery
{ {
Task<IEnumerable<OrganizationUserUserDetails>> GetOrganizationUserUserDetails(OrganizationUserUserDetailsQueryRequest request); Task<IEnumerable<OrganizationUserUserDetails>> GetOrganizationUserUserDetails(OrganizationUserUserDetailsQueryRequest request);
Task<IEnumerable<(OrganizationUserUserDetails OrgUser, bool TwoFactorEnabled, bool ClaimedByOrganization)>> Get(OrganizationUserUserDetailsQueryRequest request);
Task<IEnumerable<(OrganizationUserUserDetails OrgUser, bool TwoFactorEnabled, bool ClaimedByOrganization)>> GetAccountRecoveryEnrolledUsers(OrganizationUserUserDetailsQueryRequest request);
} }

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