mirror of
https://github.com/bitwarden/server.git
synced 2025-06-13 14:30:50 -05:00
Merge branch 'main' into innovation/archive/server
This commit is contained in:
commit
8ee386f951
23
.github/CODEOWNERS
vendored
23
.github/CODEOWNERS
vendored
@ -20,22 +20,39 @@
|
||||
# Database Operations for database changes
|
||||
src/Sql/** @bitwarden/dept-dbops
|
||||
util/EfShared/** @bitwarden/dept-dbops
|
||||
util/Migrator/** @bitwarden/dept-dbops
|
||||
util/Migrator/** @bitwarden/team-platform-dev # The Platform team owns the Migrator project code
|
||||
util/Migrator/DbScripts/** @bitwarden/dept-dbops
|
||||
util/Migrator/DbScripts_finalization/** @bitwarden/dept-dbops
|
||||
util/Migrator/DbScripts_transition/** @bitwarden/dept-dbops
|
||||
util/Migrator/MySql/** @bitwarden/dept-dbops
|
||||
util/MySqlMigrations/** @bitwarden/dept-dbops
|
||||
util/PostgresMigrations/** @bitwarden/dept-dbops
|
||||
util/SqlServerEFScaffold/** @bitwarden/dept-dbops
|
||||
util/SqliteMigrations/** @bitwarden/dept-dbops
|
||||
|
||||
# Shared util projects
|
||||
util/Setup/** @bitwarden/dept-bre @bitwarden/team-platform-dev
|
||||
|
||||
# Auth team
|
||||
**/Auth @bitwarden/team-auth-dev
|
||||
bitwarden_license/src/Sso @bitwarden/team-auth-dev
|
||||
src/Identity @bitwarden/team-auth-dev
|
||||
src/Core/Identity @bitwarden/team-auth-dev
|
||||
src/Core/IdentityServer @bitwarden/team-auth-dev
|
||||
|
||||
# Key Management team
|
||||
**/KeyManagement @bitwarden/team-key-management-dev
|
||||
|
||||
# Tools team
|
||||
**/Tools @bitwarden/team-tools-dev
|
||||
|
||||
# Dirt (Data Insights & Reporting) team
|
||||
src/Api/Controllers/Dirt @bitwarden/team-data-insights-and-reporting-dev
|
||||
src/Core/Dirt @bitwarden/team-data-insights-and-reporting-dev
|
||||
src/Infrastructure.Dapper/Dirt @bitwarden/team-data-insights-and-reporting-dev
|
||||
test/Api.Test/Dirt @bitwarden/team-data-insights-and-reporting-dev
|
||||
test/Core.Test/Dirt @bitwarden/team-data-insights-and-reporting-dev
|
||||
|
||||
# Vault team
|
||||
**/Vault @bitwarden/team-vault-dev
|
||||
**/Vault/AuthorizationHandlers @bitwarden/team-vault-dev @bitwarden/team-admin-console-dev # joint ownership over authorization handlers that affect organization users
|
||||
@ -66,12 +83,16 @@ src/Admin/Views/Tools @bitwarden/team-billing-dev
|
||||
|
||||
# Platform team
|
||||
.github/workflows/build.yml @bitwarden/team-platform-dev
|
||||
.github/workflows/build_target.yml @bitwarden/team-platform-dev
|
||||
.github/workflows/cleanup-after-pr.yml @bitwarden/team-platform-dev
|
||||
.github/workflows/cleanup-rc-branch.yml @bitwarden/team-platform-dev
|
||||
.github/workflows/repository-management.yml @bitwarden/team-platform-dev
|
||||
.github/workflows/test-database.yml @bitwarden/team-platform-dev
|
||||
.github/workflows/test.yml @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)
|
||||
**/packages.lock.json
|
||||
|
80
.github/renovate.json5
vendored
80
.github/renovate.json5
vendored
@ -9,6 +9,18 @@
|
||||
"nuget",
|
||||
],
|
||||
packageRules: [
|
||||
{
|
||||
// Group all release-related workflows for GitHub Actions together for BRE.
|
||||
groupName: "github-action",
|
||||
matchManagers: ["github-actions"],
|
||||
matchFileNames: [
|
||||
".github/workflows/publish.yml",
|
||||
".github/workflows/release.yml"
|
||||
],
|
||||
commitMessagePrefix: "[deps] BRE:",
|
||||
reviewers: ["team:dept-bre"],
|
||||
addLabels: ["hold"],
|
||||
},
|
||||
{
|
||||
groupName: "dockerfile minor",
|
||||
matchManagers: ["dockerfile"],
|
||||
@ -24,6 +36,16 @@
|
||||
matchManagers: ["github-actions"],
|
||||
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"],
|
||||
commitMessagePrefix: "[deps] BRE:",
|
||||
@ -46,6 +68,7 @@
|
||||
"DuoUniversal",
|
||||
"Fido2.AspNet",
|
||||
"Duende.IdentityServer",
|
||||
"Microsoft.AspNetCore.Authentication.JwtBearer",
|
||||
"Microsoft.Extensions.Identity.Stores",
|
||||
"Otp.NET",
|
||||
"Sustainsys.Saml2.AspNetCore2",
|
||||
@ -66,8 +89,6 @@
|
||||
"CsvHelper",
|
||||
"Kralizek.AutoFixture.Extensions.MockHttp",
|
||||
"Microsoft.AspNetCore.Mvc.Testing",
|
||||
"Microsoft.Extensions.Logging",
|
||||
"Microsoft.Extensions.Logging.Console",
|
||||
"Newtonsoft.Json",
|
||||
"NSubstitute",
|
||||
"Sentry.Serilog",
|
||||
@ -87,9 +108,9 @@
|
||||
reviewers: ["team:team-billing-dev"],
|
||||
},
|
||||
{
|
||||
matchPackagePatterns: ["^Microsoft.Extensions.Logging"],
|
||||
groupName: "Microsoft.Extensions.Logging",
|
||||
description: "Group Microsoft.Extensions.Logging to exclude them from the dotnet monorepo preset",
|
||||
matchPackageNames: ["/^Microsoft\\.EntityFrameworkCore\\./", "/^dotnet-ef/"],
|
||||
groupName: "EntityFrameworkCore",
|
||||
description: "Group EntityFrameworkCore to exclude them from the dotnet monorepo preset",
|
||||
},
|
||||
{
|
||||
matchPackageNames: [
|
||||
@ -104,9 +125,6 @@
|
||||
"Microsoft.EntityFrameworkCore.Relational",
|
||||
"Microsoft.EntityFrameworkCore.Sqlite",
|
||||
"Microsoft.EntityFrameworkCore.SqlServer",
|
||||
"Microsoft.Extensions.Caching.Cosmos",
|
||||
"Microsoft.Extensions.Caching.SqlServer",
|
||||
"Microsoft.Extensions.Caching.StackExchangeRedis",
|
||||
"Npgsql.EntityFrameworkCore.PostgreSQL",
|
||||
"Pomelo.EntityFrameworkCore.MySql",
|
||||
],
|
||||
@ -115,8 +133,8 @@
|
||||
reviewers: ["team:dept-dbops"],
|
||||
},
|
||||
{
|
||||
matchPackageNames: ["CommandDotNet", "YamlDotNet"],
|
||||
description: "DevOps owned dependencies",
|
||||
matchPackageNames: ["YamlDotNet"],
|
||||
description: "BRE owned dependencies",
|
||||
commitMessagePrefix: "[deps] BRE:",
|
||||
reviewers: ["team:dept-bre"],
|
||||
},
|
||||
@ -129,56 +147,40 @@
|
||||
"Azure.Messaging.ServiceBus",
|
||||
"Azure.Storage.Blobs",
|
||||
"Azure.Storage.Queues",
|
||||
"Microsoft.AspNetCore.Authentication.JwtBearer",
|
||||
"LaunchDarkly.ServerSdk",
|
||||
"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",
|
||||
],
|
||||
description: "Platform owned dependencies",
|
||||
commitMessagePrefix: "[deps] Platform:",
|
||||
reviewers: ["team:team-platform-dev"],
|
||||
},
|
||||
{
|
||||
matchPackagePatterns: ["EntityFrameworkCore", "^dotnet-ef"],
|
||||
groupName: "EntityFrameworkCore",
|
||||
description: "Group EntityFrameworkCore to exclude them from the dotnet monorepo preset",
|
||||
},
|
||||
{
|
||||
matchPackageNames: [
|
||||
"AutoMapper.Extensions.Microsoft.DependencyInjection",
|
||||
"AWSSDK.SimpleEmail",
|
||||
"AWSSDK.SQS",
|
||||
"Handlebars.Net",
|
||||
"LaunchDarkly.ServerSdk",
|
||||
"MailKit",
|
||||
"Microsoft.AspNetCore.SignalR.Protocols.MessagePack",
|
||||
"Microsoft.AspNetCore.SignalR.StackExchangeRedis",
|
||||
"Microsoft.Azure.NotificationHubs",
|
||||
"Microsoft.Extensions.Configuration.EnvironmentVariables",
|
||||
"Microsoft.Extensions.Configuration.UserSecrets",
|
||||
"Microsoft.Extensions.Configuration",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions",
|
||||
"Microsoft.Extensions.DependencyInjection",
|
||||
"SendGrid",
|
||||
],
|
||||
description: "Tools owned dependencies",
|
||||
commitMessagePrefix: "[deps] Tools:",
|
||||
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: [
|
||||
"AngleSharp",
|
||||
|
302
.github/workflows/build.yml
vendored
302
.github/workflows/build.yml
vendored
@ -7,22 +7,19 @@ on:
|
||||
- "main"
|
||||
- "rc"
|
||||
- "hotfix-rc"
|
||||
pull_request_target:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
workflow_call:
|
||||
inputs: {}
|
||||
|
||||
env:
|
||||
_AZ_REGISTRY: "bitwardenprod.azurecr.io"
|
||||
_GITHUB_PR_REPO_NAME: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
|
||||
jobs:
|
||||
check-run:
|
||||
name: Check PR run
|
||||
uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main
|
||||
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-22.04
|
||||
needs:
|
||||
- check-run
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
@ -36,104 +33,15 @@ jobs:
|
||||
run: dotnet format --verify-no-changes
|
||||
|
||||
build-artifacts:
|
||||
name: Build artifacts
|
||||
runs-on: ubuntu-22.04
|
||||
name: Build Docker images
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- lint
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- project_name: Admin
|
||||
base_path: ./src
|
||||
node: true
|
||||
- project_name: Api
|
||||
base_path: ./src
|
||||
- project_name: Billing
|
||||
base_path: ./src
|
||||
- project_name: Events
|
||||
base_path: ./src
|
||||
- project_name: EventsProcessor
|
||||
base_path: ./src
|
||||
- project_name: Icons
|
||||
base_path: ./src
|
||||
- project_name: Identity
|
||||
base_path: ./src
|
||||
- project_name: MsSqlMigratorUtility
|
||||
base_path: ./util
|
||||
dotnet: true
|
||||
- project_name: Notifications
|
||||
base_path: ./src
|
||||
- project_name: Scim
|
||||
base_path: ./bitwarden_license/src
|
||||
dotnet: true
|
||||
- project_name: Server
|
||||
base_path: ./util
|
||||
- project_name: Setup
|
||||
base_path: ./util
|
||||
- project_name: Sso
|
||||
base_path: ./bitwarden_license/src
|
||||
node: true
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
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
|
||||
outputs:
|
||||
has_secrets: ${{ steps.check-secrets.outputs.has_secrets }}
|
||||
permissions:
|
||||
security-events: write
|
||||
id-token: write
|
||||
needs:
|
||||
- build-artifacts
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@ -141,6 +49,7 @@ jobs:
|
||||
- project_name: Admin
|
||||
base_path: ./src
|
||||
dotnet: true
|
||||
node: true
|
||||
- project_name: Api
|
||||
base_path: ./src
|
||||
dotnet: true
|
||||
@ -174,9 +83,6 @@ jobs:
|
||||
- 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
|
||||
@ -184,6 +90,14 @@ jobs:
|
||||
base_path: ./bitwarden_license/src
|
||||
dotnet: true
|
||||
steps:
|
||||
- name: Check secrets
|
||||
id: check-secrets
|
||||
env:
|
||||
AZURE_KV_CI_SERVICE_PRINCIPAL: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
|
||||
run: |
|
||||
has_secrets=${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL != '' }}
|
||||
echo "has_secrets=$has_secrets" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
@ -195,13 +109,67 @@ jobs:
|
||||
id: publish-branch-check
|
||||
run: |
|
||||
IFS="," read -a publish_branches <<< $PUBLISH_BRANCHES
|
||||
|
||||
if [[ " ${publish_branches[*]} " =~ " ${GITHUB_REF:11} " ]]; then
|
||||
echo "is_publish_branch=true" >> $GITHUB_ENV
|
||||
else
|
||||
echo "is_publish_branch=false" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- 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 }}
|
||||
if: ${{ matrix.dotnet }}
|
||||
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
|
||||
if: ${{ matrix.dotnet }}
|
||||
with:
|
||||
name: ${{ matrix.project_name }}.zip
|
||||
path: ${{ matrix.base_path }}/${{ matrix.project_name }}/${{ matrix.project_name }}.zip
|
||||
if-no-files-found: error
|
||||
|
||||
########## 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 ##########
|
||||
- name: Log in to Azure - production subscription
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
@ -227,12 +195,18 @@ jobs:
|
||||
- name: Generate Docker image tag
|
||||
id: tag
|
||||
run: |
|
||||
if [[ "${GITHUB_EVENT_NAME}" == "pull_request_target" ]]; then
|
||||
IMAGE_TAG=$(echo "${GITHUB_HEAD_REF}" | sed "s#/#-#g")
|
||||
if [[ "${GITHUB_EVENT_NAME}" == "pull_request" || "${GITHUB_EVENT_NAME}" == "pull_request_target" ]]; then
|
||||
IMAGE_TAG=$(echo "${GITHUB_HEAD_REF}" | sed "s/[^a-zA-Z0-9]/-/g") # Sanitize branch name to alphanumeric only
|
||||
else
|
||||
IMAGE_TAG=$(echo "${GITHUB_REF:11}" | sed "s#/#-#g")
|
||||
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
|
||||
IMAGE_TAG=dev
|
||||
fi
|
||||
@ -263,39 +237,37 @@ jobs:
|
||||
fi
|
||||
echo "tags=$TAGS" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Get build artifact
|
||||
if: ${{ matrix.dotnet }}
|
||||
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
|
||||
with:
|
||||
name: ${{ matrix.project_name }}.zip
|
||||
|
||||
- name: Set up build artifact
|
||||
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: Generate image full name
|
||||
id: cache-name
|
||||
env:
|
||||
PROJECT_NAME: ${{ steps.setup.outputs.project_name }}
|
||||
run: echo "name=${_AZ_REGISTRY}/${PROJECT_NAME}:buildcache" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build Docker image
|
||||
id: build-docker
|
||||
id: build-artifacts
|
||||
uses: docker/build-push-action@67a2d409c0a876cbe6b11854e3e25193efe4e62d # v6.12.0
|
||||
with:
|
||||
context: ${{ matrix.base_path }}/${{ matrix.project_name }}
|
||||
cache-from: type=registry,ref=${{ steps.cache-name.outputs.name }}
|
||||
cache-to: type=registry,ref=${{ steps.cache-name.outputs.name}},mode=max
|
||||
context: .
|
||||
file: ${{ matrix.base_path }}/${{ matrix.project_name }}/Dockerfile
|
||||
platforms: linux/amd64
|
||||
platforms: |
|
||||
linux/amd64,
|
||||
linux/arm/v7,
|
||||
linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.image-tags.outputs.tags }}
|
||||
secrets: |
|
||||
"GH_PAT=${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}"
|
||||
|
||||
- name: Install Cosign
|
||||
if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/main'
|
||||
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
||||
uses: sigstore/cosign-installer@dc72c7d5c4d10cd6bcb8cf6e3fd625a9e5e537da # v3.7.0
|
||||
|
||||
- name: Sign image with Cosign
|
||||
if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/main'
|
||||
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
||||
env:
|
||||
DIGEST: ${{ steps.build-docker.outputs.digest }}
|
||||
DIGEST: ${{ steps.build-artifacts.outputs.digest }}
|
||||
TAGS: ${{ steps.image-tags.outputs.tags }}
|
||||
run: |
|
||||
IFS="," read -a tags <<< "${TAGS}"
|
||||
@ -317,11 +289,13 @@ jobs:
|
||||
uses: github/codeql-action/upload-sarif@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3.28.8
|
||||
with:
|
||||
sarif_file: ${{ steps.container-scan.outputs.sarif }}
|
||||
sha: ${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.sha || github.sha }}
|
||||
ref: ${{ contains(github.event_name, 'pull_request') && format('refs/pull/{0}/head', github.event.pull_request.number) || github.ref }}
|
||||
|
||||
upload:
|
||||
name: Upload
|
||||
runs-on: ubuntu-22.04
|
||||
needs: build-docker
|
||||
runs-on: ubuntu-24.04
|
||||
needs: build-artifacts
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
@ -341,7 +315,7 @@ jobs:
|
||||
|
||||
- name: Make Docker stubs
|
||||
if: |
|
||||
github.event_name != 'pull_request_target'
|
||||
github.event_name != 'pull_request'
|
||||
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
|
||||
run: |
|
||||
# Set proper setup image based on branch
|
||||
@ -383,7 +357,7 @@ jobs:
|
||||
|
||||
- name: Make Docker stub checksums
|
||||
if: |
|
||||
github.event_name != 'pull_request_target'
|
||||
github.event_name != 'pull_request'
|
||||
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
|
||||
run: |
|
||||
sha256sum docker-stub-US.zip > docker-stub-US-sha256.txt
|
||||
@ -391,7 +365,7 @@ jobs:
|
||||
|
||||
- name: Upload Docker stub US artifact
|
||||
if: |
|
||||
github.event_name != 'pull_request_target'
|
||||
github.event_name != 'pull_request'
|
||||
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
with:
|
||||
@ -401,7 +375,7 @@ jobs:
|
||||
|
||||
- name: Upload Docker stub EU artifact
|
||||
if: |
|
||||
github.event_name != 'pull_request_target'
|
||||
github.event_name != 'pull_request'
|
||||
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
with:
|
||||
@ -411,7 +385,7 @@ jobs:
|
||||
|
||||
- name: Upload Docker stub US checksum artifact
|
||||
if: |
|
||||
github.event_name != 'pull_request_target'
|
||||
github.event_name != 'pull_request'
|
||||
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
with:
|
||||
@ -421,7 +395,7 @@ jobs:
|
||||
|
||||
- name: Upload Docker stub EU checksum artifact
|
||||
if: |
|
||||
github.event_name != 'pull_request_target'
|
||||
github.event_name != 'pull_request'
|
||||
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
with:
|
||||
@ -496,7 +470,7 @@ jobs:
|
||||
|
||||
build-mssqlmigratorutility:
|
||||
name: Build MSSQL migrator utility
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- lint
|
||||
defaults:
|
||||
@ -550,11 +524,11 @@ jobs:
|
||||
self-host-build:
|
||||
name: Trigger self-host build
|
||||
if: |
|
||||
github.event_name != 'pull_request_target'
|
||||
github.event_name != 'pull_request'
|
||||
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- build-docker
|
||||
- build-artifacts
|
||||
steps:
|
||||
- name: Log in to Azure - CI subscription
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
@ -585,10 +559,10 @@ jobs:
|
||||
|
||||
trigger-k8s-deploy:
|
||||
name: Trigger k8s deploy
|
||||
if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/main'
|
||||
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-22.04
|
||||
needs:
|
||||
- build-docker
|
||||
- build-artifacts
|
||||
steps:
|
||||
- name: Log in to Azure - CI subscription
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
@ -618,57 +592,20 @@ jobs:
|
||||
}
|
||||
})
|
||||
|
||||
trigger-ee-updates:
|
||||
name: Trigger Ephemeral Environment updates
|
||||
if: |
|
||||
github.event_name == 'pull_request_target'
|
||||
&& contains(github.event.pull_request.labels.*.name, 'ephemeral-environment')
|
||||
runs-on: ubuntu-24.04
|
||||
setup-ephemeral-environment:
|
||||
name: Setup Ephemeral Environment
|
||||
needs:
|
||||
- build-docker
|
||||
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: trigger-ee-updates
|
||||
- build-artifacts
|
||||
if: |
|
||||
github.event_name == 'pull_request_target'
|
||||
needs.build-artifacts.outputs.has_secrets == 'true'
|
||||
&& github.event_name == 'pull_request'
|
||||
&& contains(github.event.pull_request.labels.*.name, 'ephemeral-environment')
|
||||
uses: bitwarden/gh-actions/.github/workflows/_ephemeral_environment_manager.yml@main
|
||||
with:
|
||||
ephemeral_env_branch: process.env.GITHUB_HEAD_REF
|
||||
project: server
|
||||
sync_environment: true
|
||||
pull_request_number: ${{ github.event.number }}
|
||||
secrets: inherit
|
||||
|
||||
|
||||
check-failures:
|
||||
name: Check for failures
|
||||
if: always()
|
||||
@ -676,7 +613,6 @@ jobs:
|
||||
needs:
|
||||
- lint
|
||||
- build-artifacts
|
||||
- build-docker
|
||||
- upload
|
||||
- build-mssqlmigratorutility
|
||||
- self-host-build
|
||||
@ -684,7 +620,7 @@ jobs:
|
||||
steps:
|
||||
- name: Check if any job failed
|
||||
if: |
|
||||
github.event_name != 'pull_request_target'
|
||||
github.event_name != 'pull_request'
|
||||
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
|
||||
&& contains(needs.*.result, 'failure')
|
||||
run: exit 1
|
||||
|
23
.github/workflows/build_target.yml
vendored
Normal file
23
.github/workflows/build_target.yml
vendored
Normal file
@ -0,0 +1,23 @@
|
||||
name: Build on PR Target
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened]
|
||||
branches:
|
||||
- "main"
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
jobs:
|
||||
check-run:
|
||||
name: Check PR run
|
||||
uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main
|
||||
|
||||
run-workflow:
|
||||
name: Run Build on PR Target
|
||||
needs: check-run
|
||||
if: ${{ github.event.pull_request.head.repo.full_name != github.repository }}
|
||||
uses: ./.github/workflows/build.yml
|
||||
secrets: inherit
|
16
.github/workflows/code-references.yml
vendored
16
.github/workflows/code-references.yml
vendored
@ -1,7 +1,10 @@
|
||||
name: Collect code references
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
on:
|
||||
push:
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check-ld-secret:
|
||||
@ -37,12 +40,11 @@ jobs:
|
||||
|
||||
- name: Collect
|
||||
id: collect
|
||||
uses: launchdarkly/find-code-references-in-pull-request@30f4c4ab2949bbf258b797ced2fbf6dea34df9ce # v2.1.0
|
||||
uses: launchdarkly/find-code-references@e3e9da201b87ada54eb4c550c14fb783385c5c8a # v2.13.0
|
||||
with:
|
||||
project-key: default
|
||||
environment-key: dev
|
||||
access-token: ${{ secrets.LD_ACCESS_TOKEN }}
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
accessToken: ${{ secrets.LD_ACCESS_TOKEN }}
|
||||
projKey: default
|
||||
allowTags: true
|
||||
|
||||
- name: Add label
|
||||
if: steps.collect.outputs.any-changed == 'true'
|
||||
|
38
.github/workflows/ephemeral-environment.yml
vendored
38
.github/workflows/ephemeral-environment.yml
vendored
@ -5,34 +5,12 @@ on:
|
||||
types: [labeled]
|
||||
|
||||
jobs:
|
||||
trigger-ee-updates:
|
||||
name: Trigger Ephemeral Environment updates
|
||||
runs-on: ubuntu-24.04
|
||||
setup-ephemeral-environment:
|
||||
name: Setup Ephemeral Environment
|
||||
if: github.event.label.name == 'ephemeral-environment'
|
||||
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
|
||||
}
|
||||
})
|
||||
uses: bitwarden/gh-actions/.github/workflows/_ephemeral_environment_manager.yml@main
|
||||
with:
|
||||
project: server
|
||||
pull_request_number: ${{ github.event.number }}
|
||||
sync_environment: true
|
||||
secrets: inherit
|
||||
|
10
.github/workflows/scan.yml
vendored
10
.github/workflows/scan.yml
vendored
@ -7,8 +7,14 @@ on:
|
||||
- "main"
|
||||
- "rc"
|
||||
- "hotfix-rc"
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
branches-ignore:
|
||||
- main
|
||||
pull_request_target:
|
||||
types: [opened, synchronize]
|
||||
types: [opened, synchronize, reopened]
|
||||
branches:
|
||||
- "main"
|
||||
|
||||
jobs:
|
||||
check-run:
|
||||
@ -49,6 +55,8 @@ jobs:
|
||||
uses: github/codeql-action/upload-sarif@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3.28.8
|
||||
with:
|
||||
sarif_file: cx_result.sarif
|
||||
sha: ${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.sha || github.sha }}
|
||||
ref: ${{ contains(github.event_name, 'pull_request') && format('refs/pull/{0}/head', github.event.pull_request.number) || github.ref }}
|
||||
|
||||
quality:
|
||||
name: Quality scan
|
||||
|
@ -3,7 +3,7 @@
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
|
||||
<Version>2025.3.3</Version>
|
||||
<Version>2025.5.2</Version>
|
||||
|
||||
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
@ -69,5 +69,4 @@
|
||||
</AssemblyAttribute>
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
</Project>
|
||||
|
@ -5,9 +5,6 @@
|
||||
<a href="https://github.com/bitwarden/server/actions/workflows/build.yml?query=branch:main" target="_blank">
|
||||
<img src="https://github.com/bitwarden/server/actions/workflows/build.yml/badge.svg?branch=main" alt="Github Workflow build on main" />
|
||||
</a>
|
||||
<a href="https://hub.docker.com/u/bitwarden/" target="_blank">
|
||||
<img src="https://img.shields.io/docker/pulls/bitwarden/api.svg" alt="DockerHub" />
|
||||
</a>
|
||||
<a href="https://gitter.im/bitwarden/Lobby" target="_blank">
|
||||
<img src="https://badges.gitter.im/bitwarden/Lobby.svg" alt="gitter chat" />
|
||||
</a>
|
||||
@ -26,12 +23,12 @@ Please refer to the [Server Setup Guide](https://contributing.bitwarden.com/gett
|
||||
## Deploy
|
||||
|
||||
<p align="center">
|
||||
<a href="https://hub.docker.com/u/bitwarden/" target="_blank">
|
||||
<a href="https://github.com/orgs/bitwarden/packages" target="_blank">
|
||||
<img src="https://i.imgur.com/SZc8JnH.png" alt="docker" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
You can deploy Bitwarden using Docker containers on Windows, macOS, and Linux distributions. Use the provided PowerShell and Bash scripts to get started quickly. Find all of the Bitwarden images on [Docker Hub](https://hub.docker.com/u/bitwarden/).
|
||||
You can deploy Bitwarden using Docker containers on Windows, macOS, and Linux distributions. Use the provided PowerShell and Bash scripts to get started quickly. Find all of the Bitwarden images on [GitHub Container Registry](https://github.com/orgs/bitwarden/packages).
|
||||
|
||||
Full documentation for deploying Bitwarden with Docker can be found in our help center at: https://help.bitwarden.com/article/install-on-premise/
|
||||
|
||||
|
@ -127,6 +127,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Infrastructure.Dapper.Test"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Events.IntegrationTest", "test\Events.IntegrationTest\Events.IntegrationTest.csproj", "{4F4C63A9-AEE2-48C4-AB86-A5BCD665E401}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Core.IntegrationTest", "test\Core.IntegrationTest\Core.IntegrationTest.csproj", "{3631BA42-6731-4118-A917-DAA43C5032B9}"
|
||||
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
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@ -319,6 +325,18 @@ Global
|
||||
{4F4C63A9-AEE2-48C4-AB86-A5BCD665E401}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{4F4C63A9-AEE2-48C4-AB86-A5BCD665E401}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{4F4C63A9-AEE2-48C4-AB86-A5BCD665E401}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{3631BA42-6731-4118-A917-DAA43C5032B9}.Debug|Any CPU.ActiveCfg = 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.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
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@ -370,6 +388,9 @@ Global
|
||||
{90D85D8F-5577-4570-A96E-5A2E185F0F6F} = {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}
|
||||
{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
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {E01CBF68-2E20-425F-9EDB-E0A6510CA92F}
|
||||
|
@ -3,9 +3,9 @@ using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.Providers.Interfaces;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Billing.Entities;
|
||||
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.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
@ -48,7 +48,7 @@ public class CreateProviderCommand : ICreateProviderCommand
|
||||
await ProviderRepositoryCreateAsync(provider, ProviderStatusType.Created);
|
||||
}
|
||||
|
||||
public async Task CreateMultiOrganizationEnterpriseAsync(Provider provider, string ownerEmail, PlanType plan, int minimumSeats)
|
||||
public async Task CreateBusinessUnitAsync(Provider provider, string ownerEmail, PlanType plan, int minimumSeats)
|
||||
{
|
||||
var providerId = await CreateProviderAsync(provider, ownerEmail);
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Bit.Core.AdminConsole.Providers.Interfaces;
|
||||
@ -6,6 +7,7 @@ using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Providers.Services;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
@ -20,7 +22,6 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
|
||||
private readonly IEventService _eventService;
|
||||
private readonly IMailService _mailService;
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly IOrganizationService _organizationService;
|
||||
private readonly IProviderOrganizationRepository _providerOrganizationRepository;
|
||||
private readonly IStripeAdapter _stripeAdapter;
|
||||
private readonly IFeatureService _featureService;
|
||||
@ -33,7 +34,6 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
|
||||
IEventService eventService,
|
||||
IMailService mailService,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOrganizationService organizationService,
|
||||
IProviderOrganizationRepository providerOrganizationRepository,
|
||||
IStripeAdapter stripeAdapter,
|
||||
IFeatureService featureService,
|
||||
@ -45,7 +45,6 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
|
||||
_eventService = eventService;
|
||||
_mailService = mailService;
|
||||
_organizationRepository = organizationRepository;
|
||||
_organizationService = organizationService;
|
||||
_providerOrganizationRepository = providerOrganizationRepository;
|
||||
_stripeAdapter = stripeAdapter;
|
||||
_featureService = featureService;
|
||||
@ -70,7 +69,7 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
|
||||
|
||||
if (!await _hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync(
|
||||
providerOrganization.OrganizationId,
|
||||
Array.Empty<Guid>(),
|
||||
[],
|
||||
includeProvider: false))
|
||||
{
|
||||
throw new BadRequestException("Organization must have at least one confirmed owner.");
|
||||
@ -95,7 +94,7 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private async Task ResetOrganizationBillingAsync(
|
||||
@ -104,13 +103,19 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
|
||||
IEnumerable<string> organizationOwnerEmails)
|
||||
{
|
||||
if (provider.IsBillable() &&
|
||||
organization.IsValidClient() &&
|
||||
!string.IsNullOrEmpty(organization.GatewayCustomerId))
|
||||
organization.IsValidClient())
|
||||
{
|
||||
await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, new CustomerUpdateOptions
|
||||
// An organization converted to a business unit will not have a Customer since it was given to the business unit.
|
||||
if (string.IsNullOrEmpty(organization.GatewayCustomerId))
|
||||
{
|
||||
await _providerBillingService.CreateCustomerForClientOrganization(provider, organization);
|
||||
}
|
||||
|
||||
var customer = await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, new CustomerUpdateOptions
|
||||
{
|
||||
Description = string.Empty,
|
||||
Email = organization.BillingEmail
|
||||
Email = organization.BillingEmail,
|
||||
Expand = ["tax", "tax_ids"]
|
||||
});
|
||||
|
||||
var plan = await _pricingClient.GetPlanOrThrow(organization.PlanType);
|
||||
@ -120,7 +125,6 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
|
||||
Customer = organization.GatewayCustomerId,
|
||||
CollectionMethod = StripeConstants.CollectionMethod.SendInvoice,
|
||||
DaysUntilDue = 30,
|
||||
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true },
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
{ "organizationId", organization.Id.ToString() }
|
||||
@ -130,6 +134,21 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
|
||||
Items = [new SubscriptionItemOptions { Price = plan.PasswordManager.StripeSeatPlanId, Quantity = organization.Seats }]
|
||||
};
|
||||
|
||||
var setNonUSBusinessUseToReverseCharge = _featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge);
|
||||
|
||||
if (setNonUSBusinessUseToReverseCharge)
|
||||
{
|
||||
subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true };
|
||||
}
|
||||
else if (customer.HasRecognizedTaxLocation())
|
||||
{
|
||||
subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions
|
||||
{
|
||||
Enabled = customer.Address.Country == "US" ||
|
||||
customer.TaxIds.Any()
|
||||
};
|
||||
}
|
||||
|
||||
var subscription = await _stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
|
||||
|
||||
organization.GatewaySubscriptionId = subscription.Id;
|
||||
@ -163,7 +182,7 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
|
||||
await _mailService.SendProviderUpdatePaymentMethod(
|
||||
organization.Id,
|
||||
organization.Name,
|
||||
provider.Name,
|
||||
provider.Name!,
|
||||
organizationOwnerEmails);
|
||||
}
|
||||
}
|
||||
|
@ -5,11 +5,13 @@ using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.Models.Business.Provider;
|
||||
using Bit.Core.AdminConsole.Models.Business.Tokenables;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Billing.Providers.Services;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
@ -52,6 +54,7 @@ public class ProviderService : IProviderService
|
||||
private readonly IApplicationCacheService _applicationCacheService;
|
||||
private readonly IProviderBillingService _providerBillingService;
|
||||
private readonly IPricingClient _pricingClient;
|
||||
private readonly IProviderClientOrganizationSignUpCommand _providerClientOrganizationSignUpCommand;
|
||||
|
||||
public ProviderService(IProviderRepository providerRepository, IProviderUserRepository providerUserRepository,
|
||||
IProviderOrganizationRepository providerOrganizationRepository, IUserRepository userRepository,
|
||||
@ -60,7 +63,8 @@ public class ProviderService : IProviderService
|
||||
IOrganizationRepository organizationRepository, GlobalSettings globalSettings,
|
||||
ICurrentContext currentContext, IStripeAdapter stripeAdapter, IFeatureService featureService,
|
||||
IDataProtectorTokenFactory<ProviderDeleteTokenable> providerDeleteTokenDataFactory,
|
||||
IApplicationCacheService applicationCacheService, IProviderBillingService providerBillingService, IPricingClient pricingClient)
|
||||
IApplicationCacheService applicationCacheService, IProviderBillingService providerBillingService, IPricingClient pricingClient,
|
||||
IProviderClientOrganizationSignUpCommand providerClientOrganizationSignUpCommand)
|
||||
{
|
||||
_providerRepository = providerRepository;
|
||||
_providerUserRepository = providerUserRepository;
|
||||
@ -80,9 +84,10 @@ public class ProviderService : IProviderService
|
||||
_applicationCacheService = applicationCacheService;
|
||||
_providerBillingService = providerBillingService;
|
||||
_pricingClient = pricingClient;
|
||||
_providerClientOrganizationSignUpCommand = providerClientOrganizationSignUpCommand;
|
||||
}
|
||||
|
||||
public async Task<Provider> CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TaxInfo taxInfo = null)
|
||||
public async Task<Provider> CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TaxInfo taxInfo, TokenizedPaymentSource tokenizedPaymentSource = null)
|
||||
{
|
||||
var owner = await _userService.GetUserByIdAsync(ownerUserId);
|
||||
if (owner == null)
|
||||
@ -111,7 +116,20 @@ public class ProviderService : IProviderService
|
||||
{
|
||||
throw new BadRequestException("Both address and postal code are required to set up your provider.");
|
||||
}
|
||||
var customer = await _providerBillingService.SetupCustomer(provider, taxInfo);
|
||||
|
||||
var requireProviderPaymentMethodDuringSetup =
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup);
|
||||
|
||||
if (requireProviderPaymentMethodDuringSetup && tokenizedPaymentSource is not
|
||||
{
|
||||
Type: PaymentMethodType.BankAccount or PaymentMethodType.Card or PaymentMethodType.PayPal,
|
||||
Token: not null and not ""
|
||||
})
|
||||
{
|
||||
throw new BadRequestException("A payment method is required to set up your provider.");
|
||||
}
|
||||
|
||||
var customer = await _providerBillingService.SetupCustomer(provider, taxInfo, tokenizedPaymentSource);
|
||||
provider.GatewayCustomerId = customer.Id;
|
||||
var subscription = await _providerBillingService.SetupSubscription(provider);
|
||||
provider.GatewaySubscriptionId = subscription.Id;
|
||||
@ -546,12 +564,12 @@ public class ProviderService : IProviderService
|
||||
|
||||
ThrowOnInvalidPlanType(provider.Type, organizationSignup.Plan);
|
||||
|
||||
var (organization, _, defaultCollection) = await _organizationService.SignupClientAsync(organizationSignup);
|
||||
var signUpResponse = await _providerClientOrganizationSignUpCommand.SignUpClientOrganizationAsync(organizationSignup);
|
||||
|
||||
var providerOrganization = new ProviderOrganization
|
||||
{
|
||||
ProviderId = providerId,
|
||||
OrganizationId = organization.Id,
|
||||
OrganizationId = signUpResponse.Organization.Id,
|
||||
Key = organizationSignup.OwnerKey,
|
||||
};
|
||||
|
||||
@ -560,12 +578,12 @@ public class ProviderService : IProviderService
|
||||
|
||||
// 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
|
||||
var defaultOwnerAccess = defaultCollection != null
|
||||
var defaultOwnerAccess = signUpResponse.DefaultCollection != null
|
||||
?
|
||||
[
|
||||
new CollectionAccessSelection
|
||||
{
|
||||
Id = defaultCollection.Id,
|
||||
Id = signUpResponse.DefaultCollection.Id,
|
||||
HidePasswords = false,
|
||||
ReadOnly = false,
|
||||
Manage = true
|
||||
@ -573,7 +591,7 @@ public class ProviderService : IProviderService
|
||||
]
|
||||
: 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)[]
|
||||
{
|
||||
(
|
||||
@ -692,10 +710,10 @@ public class ProviderService : IProviderService
|
||||
throw new BadRequestException($"Managed Service Providers cannot manage organizations with the plan type {requestedType}. Only Teams (Monthly) and Enterprise (Monthly) are allowed.");
|
||||
}
|
||||
break;
|
||||
case ProviderType.MultiOrganizationEnterprise:
|
||||
case ProviderType.BusinessUnit:
|
||||
if (requestedType is not (PlanType.EnterpriseMonthly or PlanType.EnterpriseAnnually))
|
||||
{
|
||||
throw new BadRequestException($"Multi-organization Enterprise Providers cannot manage organizations with the plan type {requestedType}. Only Enterprise (Monthly) and Enterprise (Annually) are allowed.");
|
||||
throw new BadRequestException($"Business Unit Providers cannot manage organizations with the plan type {requestedType}. Only Enterprise (Monthly) and Enterprise (Annually) are allowed.");
|
||||
}
|
||||
break;
|
||||
case ProviderType.Reseller:
|
||||
|
@ -1,8 +1,8 @@
|
||||
using System.Globalization;
|
||||
using Bit.Core.Billing.Entities;
|
||||
using Bit.Core.Billing.Providers.Entities;
|
||||
using CsvHelper.Configuration.Attributes;
|
||||
|
||||
namespace Bit.Commercial.Core.Billing.Models;
|
||||
namespace Bit.Commercial.Core.Billing.Providers.Models;
|
||||
|
||||
public class ProviderClientInvoiceReportRow
|
||||
{
|
@ -0,0 +1,462 @@
|
||||
#nullable enable
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Providers.Entities;
|
||||
using Bit.Core.Billing.Providers.Repositories;
|
||||
using Bit.Core.Billing.Providers.Services;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OneOf;
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Commercial.Core.Billing.Providers.Services;
|
||||
|
||||
public class BusinessUnitConverter(
|
||||
IDataProtectionProvider dataProtectionProvider,
|
||||
GlobalSettings globalSettings,
|
||||
ILogger<BusinessUnitConverter> logger,
|
||||
IMailService mailService,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IPricingClient pricingClient,
|
||||
IProviderOrganizationRepository providerOrganizationRepository,
|
||||
IProviderPlanRepository providerPlanRepository,
|
||||
IProviderRepository providerRepository,
|
||||
IProviderUserRepository providerUserRepository,
|
||||
IStripeAdapter stripeAdapter,
|
||||
ISubscriberService subscriberService,
|
||||
IUserRepository userRepository) : IBusinessUnitConverter
|
||||
{
|
||||
private readonly IDataProtector _dataProtector =
|
||||
dataProtectionProvider.CreateProtector($"{nameof(BusinessUnitConverter)}DataProtector");
|
||||
|
||||
public async Task<Guid> FinalizeConversion(
|
||||
Organization organization,
|
||||
Guid userId,
|
||||
string token,
|
||||
string providerKey,
|
||||
string organizationKey)
|
||||
{
|
||||
var user = await userRepository.GetByIdAsync(userId);
|
||||
|
||||
var (subscription, provider, providerOrganization, providerUser) = await ValidateFinalizationAsync(organization, user, token);
|
||||
|
||||
var existingPlan = await pricingClient.GetPlanOrThrow(organization.PlanType);
|
||||
var updatedPlan = await pricingClient.GetPlanOrThrow(existingPlan.IsAnnual ? PlanType.EnterpriseAnnually : PlanType.EnterpriseMonthly);
|
||||
|
||||
// Bring organization under management.
|
||||
organization.Plan = updatedPlan.Name;
|
||||
organization.PlanType = updatedPlan.Type;
|
||||
organization.MaxCollections = updatedPlan.PasswordManager.MaxCollections;
|
||||
organization.MaxStorageGb = updatedPlan.PasswordManager.BaseStorageGb;
|
||||
organization.UsePolicies = updatedPlan.HasPolicies;
|
||||
organization.UseSso = updatedPlan.HasSso;
|
||||
organization.UseOrganizationDomains = updatedPlan.HasOrganizationDomains;
|
||||
organization.UseGroups = updatedPlan.HasGroups;
|
||||
organization.UseEvents = updatedPlan.HasEvents;
|
||||
organization.UseDirectory = updatedPlan.HasDirectory;
|
||||
organization.UseTotp = updatedPlan.HasTotp;
|
||||
organization.Use2fa = updatedPlan.Has2fa;
|
||||
organization.UseApi = updatedPlan.HasApi;
|
||||
organization.UseResetPassword = updatedPlan.HasResetPassword;
|
||||
organization.SelfHost = updatedPlan.HasSelfHost;
|
||||
organization.UsersGetPremium = updatedPlan.UsersGetPremium;
|
||||
organization.UseCustomPermissions = updatedPlan.HasCustomPermissions;
|
||||
organization.UseScim = updatedPlan.HasScim;
|
||||
organization.UseKeyConnector = updatedPlan.HasKeyConnector;
|
||||
organization.MaxStorageGb = updatedPlan.PasswordManager.BaseStorageGb;
|
||||
organization.BillingEmail = provider.BillingEmail!;
|
||||
organization.GatewayCustomerId = null;
|
||||
organization.GatewaySubscriptionId = null;
|
||||
organization.ExpirationDate = null;
|
||||
organization.MaxAutoscaleSeats = null;
|
||||
organization.Status = OrganizationStatusType.Managed;
|
||||
|
||||
// Enable organization access via key exchange.
|
||||
providerOrganization.Key = organizationKey;
|
||||
|
||||
// Complete provider setup.
|
||||
provider.Gateway = GatewayType.Stripe;
|
||||
provider.GatewayCustomerId = subscription.CustomerId;
|
||||
provider.GatewaySubscriptionId = subscription.Id;
|
||||
provider.Status = ProviderStatusType.Billable;
|
||||
|
||||
// Enable provider access via key exchange.
|
||||
providerUser.Key = providerKey;
|
||||
providerUser.Status = ProviderUserStatusType.Confirmed;
|
||||
|
||||
// Stripe requires that we clear all the custom fields from the invoice settings if we want to replace them.
|
||||
await stripeAdapter.CustomerUpdateAsync(subscription.CustomerId, new CustomerUpdateOptions
|
||||
{
|
||||
InvoiceSettings = new CustomerInvoiceSettingsOptions
|
||||
{
|
||||
CustomFields = []
|
||||
}
|
||||
});
|
||||
|
||||
var metadata = new Dictionary<string, string>
|
||||
{
|
||||
[StripeConstants.MetadataKeys.OrganizationId] = string.Empty,
|
||||
[StripeConstants.MetadataKeys.ProviderId] = provider.Id.ToString(),
|
||||
["convertedFrom"] = organization.Id.ToString()
|
||||
};
|
||||
|
||||
var updateCustomer = stripeAdapter.CustomerUpdateAsync(subscription.CustomerId, new CustomerUpdateOptions
|
||||
{
|
||||
InvoiceSettings = new CustomerInvoiceSettingsOptions
|
||||
{
|
||||
CustomFields = [
|
||||
new CustomerInvoiceSettingsCustomFieldOptions
|
||||
{
|
||||
Name = provider.SubscriberType(),
|
||||
Value = provider.DisplayName()?.Length <= 30
|
||||
? provider.DisplayName()
|
||||
: provider.DisplayName()?[..30]
|
||||
}
|
||||
]
|
||||
},
|
||||
Metadata = metadata
|
||||
});
|
||||
|
||||
// Find the existing password manager price on the subscription.
|
||||
var passwordManagerItem = subscription.Items.First(item =>
|
||||
{
|
||||
var priceId = existingPlan.HasNonSeatBasedPasswordManagerPlan()
|
||||
? existingPlan.PasswordManager.StripePlanId
|
||||
: existingPlan.PasswordManager.StripeSeatPlanId;
|
||||
|
||||
return item.Price.Id == priceId;
|
||||
});
|
||||
|
||||
// Get the new business unit price.
|
||||
var updatedPriceId = ProviderPriceAdapter.GetActivePriceId(provider, updatedPlan.Type);
|
||||
|
||||
// Replace the existing password manager price with the new business unit price.
|
||||
var updateSubscription =
|
||||
stripeAdapter.SubscriptionUpdateAsync(subscription.Id,
|
||||
new SubscriptionUpdateOptions
|
||||
{
|
||||
Items = [
|
||||
new SubscriptionItemOptions
|
||||
{
|
||||
Id = passwordManagerItem.Id,
|
||||
Deleted = true
|
||||
},
|
||||
new SubscriptionItemOptions
|
||||
{
|
||||
Price = updatedPriceId,
|
||||
Quantity = organization.Seats
|
||||
}
|
||||
],
|
||||
Metadata = metadata
|
||||
});
|
||||
|
||||
await Task.WhenAll(updateCustomer, updateSubscription);
|
||||
|
||||
// Complete database updates for provider setup.
|
||||
await Task.WhenAll(
|
||||
organizationRepository.ReplaceAsync(organization),
|
||||
providerOrganizationRepository.ReplaceAsync(providerOrganization),
|
||||
providerRepository.ReplaceAsync(provider),
|
||||
providerUserRepository.ReplaceAsync(providerUser));
|
||||
|
||||
return provider.Id;
|
||||
}
|
||||
|
||||
public async Task<OneOf<Guid, List<string>>> InitiateConversion(
|
||||
Organization organization,
|
||||
string providerAdminEmail)
|
||||
{
|
||||
var user = await userRepository.GetByEmailAsync(providerAdminEmail);
|
||||
|
||||
var problems = await ValidateInitiationAsync(organization, user);
|
||||
|
||||
if (problems is { Count: > 0 })
|
||||
{
|
||||
return problems;
|
||||
}
|
||||
|
||||
var provider = await providerRepository.CreateAsync(new Provider
|
||||
{
|
||||
Name = organization.Name,
|
||||
BillingEmail = organization.BillingEmail,
|
||||
Status = ProviderStatusType.Pending,
|
||||
UseEvents = true,
|
||||
Type = ProviderType.BusinessUnit
|
||||
});
|
||||
|
||||
var plan = await pricingClient.GetPlanOrThrow(organization.PlanType);
|
||||
|
||||
var managedPlanType = plan.IsAnnual
|
||||
? PlanType.EnterpriseAnnually
|
||||
: PlanType.EnterpriseMonthly;
|
||||
|
||||
var createProviderOrganization = providerOrganizationRepository.CreateAsync(new ProviderOrganization
|
||||
{
|
||||
ProviderId = provider.Id,
|
||||
OrganizationId = organization.Id
|
||||
});
|
||||
|
||||
var createProviderPlan = providerPlanRepository.CreateAsync(new ProviderPlan
|
||||
{
|
||||
ProviderId = provider.Id,
|
||||
PlanType = managedPlanType,
|
||||
SeatMinimum = 0,
|
||||
PurchasedSeats = organization.Seats,
|
||||
AllocatedSeats = organization.Seats
|
||||
});
|
||||
|
||||
var createProviderUser = providerUserRepository.CreateAsync(new ProviderUser
|
||||
{
|
||||
ProviderId = provider.Id,
|
||||
UserId = user!.Id,
|
||||
Email = user.Email,
|
||||
Status = ProviderUserStatusType.Invited,
|
||||
Type = ProviderUserType.ProviderAdmin
|
||||
});
|
||||
|
||||
await Task.WhenAll(createProviderOrganization, createProviderPlan, createProviderUser);
|
||||
|
||||
await SendInviteAsync(organization, user.Email);
|
||||
|
||||
return provider.Id;
|
||||
}
|
||||
|
||||
public Task ResendConversionInvite(
|
||||
Organization organization,
|
||||
string providerAdminEmail) =>
|
||||
IfConversionInProgressAsync(organization, providerAdminEmail,
|
||||
async (_, _, providerUser) =>
|
||||
{
|
||||
if (!string.IsNullOrEmpty(providerUser.Email))
|
||||
{
|
||||
await SendInviteAsync(organization, providerUser.Email);
|
||||
}
|
||||
});
|
||||
|
||||
public Task ResetConversion(
|
||||
Organization organization,
|
||||
string providerAdminEmail) =>
|
||||
IfConversionInProgressAsync(organization, providerAdminEmail,
|
||||
async (provider, providerOrganization, providerUser) =>
|
||||
{
|
||||
var tasks = new List<Task>
|
||||
{
|
||||
providerOrganizationRepository.DeleteAsync(providerOrganization),
|
||||
providerUserRepository.DeleteAsync(providerUser)
|
||||
};
|
||||
|
||||
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
|
||||
|
||||
if (providerPlans is { Count: > 0 })
|
||||
{
|
||||
tasks.AddRange(providerPlans.Select(providerPlanRepository.DeleteAsync));
|
||||
}
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
await providerRepository.DeleteAsync(provider);
|
||||
});
|
||||
|
||||
#region Utilities
|
||||
|
||||
private async Task IfConversionInProgressAsync(
|
||||
Organization organization,
|
||||
string providerAdminEmail,
|
||||
Func<Provider, ProviderOrganization, ProviderUser, Task> callback)
|
||||
{
|
||||
var user = await userRepository.GetByEmailAsync(providerAdminEmail);
|
||||
|
||||
if (user == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var provider = await providerRepository.GetByOrganizationIdAsync(organization.Id);
|
||||
|
||||
if (provider is not
|
||||
{
|
||||
Type: ProviderType.BusinessUnit,
|
||||
Status: ProviderStatusType.Pending
|
||||
})
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var providerUser = await providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id);
|
||||
|
||||
if (providerUser is
|
||||
{
|
||||
Type: ProviderUserType.ProviderAdmin,
|
||||
Status: ProviderUserStatusType.Invited
|
||||
})
|
||||
{
|
||||
var providerOrganization = await providerOrganizationRepository.GetByOrganizationId(organization.Id);
|
||||
await callback(provider, providerOrganization!, providerUser);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendInviteAsync(
|
||||
Organization organization,
|
||||
string providerAdminEmail)
|
||||
{
|
||||
var token = _dataProtector.Protect(
|
||||
$"BusinessUnitConversionInvite {organization.Id} {providerAdminEmail} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}");
|
||||
|
||||
await mailService.SendBusinessUnitConversionInviteAsync(organization, token, providerAdminEmail);
|
||||
}
|
||||
|
||||
private async Task<(Subscription, Provider, ProviderOrganization, ProviderUser)> ValidateFinalizationAsync(
|
||||
Organization organization,
|
||||
User? user,
|
||||
string token)
|
||||
{
|
||||
if (organization.PlanType.GetProductTier() != ProductTierType.Enterprise)
|
||||
{
|
||||
Fail("Organization must be on an enterprise plan.");
|
||||
}
|
||||
|
||||
var subscription = await subscriberService.GetSubscription(organization);
|
||||
|
||||
if (subscription is not
|
||||
{
|
||||
Status:
|
||||
StripeConstants.SubscriptionStatus.Active or
|
||||
StripeConstants.SubscriptionStatus.Trialing or
|
||||
StripeConstants.SubscriptionStatus.PastDue
|
||||
})
|
||||
{
|
||||
Fail("Organization must have a valid subscription.");
|
||||
}
|
||||
|
||||
if (user == null)
|
||||
{
|
||||
Fail("Provider admin must be a Bitwarden user.");
|
||||
}
|
||||
|
||||
if (!CoreHelpers.TokenIsValid(
|
||||
"BusinessUnitConversionInvite",
|
||||
_dataProtector,
|
||||
token,
|
||||
user.Email,
|
||||
organization.Id,
|
||||
globalSettings.OrganizationInviteExpirationHours))
|
||||
{
|
||||
Fail("Email token is invalid.");
|
||||
}
|
||||
|
||||
var organizationUser =
|
||||
await organizationUserRepository.GetByOrganizationAsync(organization.Id, user.Id);
|
||||
|
||||
if (organizationUser is not
|
||||
{
|
||||
Status: OrganizationUserStatusType.Confirmed
|
||||
})
|
||||
{
|
||||
Fail("Provider admin must be a confirmed member of the organization being converted.");
|
||||
}
|
||||
|
||||
var provider = await providerRepository.GetByOrganizationIdAsync(organization.Id);
|
||||
|
||||
if (provider is not
|
||||
{
|
||||
Type: ProviderType.BusinessUnit,
|
||||
Status: ProviderStatusType.Pending
|
||||
})
|
||||
{
|
||||
Fail("Linked provider is not a pending business unit.");
|
||||
}
|
||||
|
||||
var providerUser = await providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id);
|
||||
|
||||
if (providerUser is not
|
||||
{
|
||||
Type: ProviderUserType.ProviderAdmin,
|
||||
Status: ProviderUserStatusType.Invited
|
||||
})
|
||||
{
|
||||
Fail("Provider admin has not been invited.");
|
||||
}
|
||||
|
||||
var providerOrganization = await providerOrganizationRepository.GetByOrganizationId(organization.Id);
|
||||
|
||||
return (subscription, provider, providerOrganization!, providerUser);
|
||||
|
||||
[DoesNotReturn]
|
||||
void Fail(string scopedError)
|
||||
{
|
||||
logger.LogError("Could not finalize business unit conversion for organization ({OrganizationID}): {Error}",
|
||||
organization.Id, scopedError);
|
||||
throw new BillingException();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<string>?> ValidateInitiationAsync(
|
||||
Organization organization,
|
||||
User? user)
|
||||
{
|
||||
var problems = new List<string>();
|
||||
|
||||
if (organization.PlanType.GetProductTier() != ProductTierType.Enterprise)
|
||||
{
|
||||
problems.Add("Organization must be on an enterprise plan.");
|
||||
}
|
||||
|
||||
var subscription = await subscriberService.GetSubscription(organization);
|
||||
|
||||
if (subscription is not
|
||||
{
|
||||
Status:
|
||||
StripeConstants.SubscriptionStatus.Active or
|
||||
StripeConstants.SubscriptionStatus.Trialing or
|
||||
StripeConstants.SubscriptionStatus.PastDue
|
||||
})
|
||||
{
|
||||
problems.Add("Organization must have a valid subscription.");
|
||||
}
|
||||
|
||||
var providerOrganization = await providerOrganizationRepository.GetByOrganizationId(organization.Id);
|
||||
|
||||
if (providerOrganization != null)
|
||||
{
|
||||
problems.Add("Organization is already linked to a provider.");
|
||||
}
|
||||
|
||||
if (user == null)
|
||||
{
|
||||
problems.Add("Provider admin must be a Bitwarden user.");
|
||||
}
|
||||
else
|
||||
{
|
||||
var organizationUser =
|
||||
await organizationUserRepository.GetByOrganizationAsync(organization.Id, user.Id);
|
||||
|
||||
if (organizationUser is not
|
||||
{
|
||||
Status: OrganizationUserStatusType.Confirmed
|
||||
})
|
||||
{
|
||||
problems.Add("Provider admin must be a confirmed member of the organization being converted.");
|
||||
}
|
||||
}
|
||||
|
||||
return problems.Count == 0 ? null : problems;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
@ -1,48 +1,58 @@
|
||||
using System.Globalization;
|
||||
using Bit.Commercial.Core.Billing.Models;
|
||||
using Bit.Commercial.Core.Billing.Providers.Models;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing;
|
||||
using Bit.Core.Billing.Caches;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Entities;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Models;
|
||||
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.Contracts;
|
||||
using Bit.Core.Billing.Tax.Models;
|
||||
using Bit.Core.Billing.Tax.Services;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Business;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Utilities;
|
||||
using Braintree;
|
||||
using CsvHelper;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Stripe;
|
||||
using static Bit.Core.Billing.Utilities;
|
||||
using Customer = Stripe.Customer;
|
||||
using Subscription = Stripe.Subscription;
|
||||
|
||||
namespace Bit.Commercial.Core.Billing;
|
||||
namespace Bit.Commercial.Core.Billing.Providers.Services;
|
||||
|
||||
public class ProviderBillingService(
|
||||
IBraintreeGateway braintreeGateway,
|
||||
IEventService eventService,
|
||||
IFeatureService featureService,
|
||||
IGlobalSettings globalSettings,
|
||||
ILogger<ProviderBillingService> logger,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IPaymentService paymentService,
|
||||
IPricingClient pricingClient,
|
||||
IProviderInvoiceItemRepository providerInvoiceItemRepository,
|
||||
IProviderOrganizationRepository providerOrganizationRepository,
|
||||
IProviderPlanRepository providerPlanRepository,
|
||||
IProviderUserRepository providerUserRepository,
|
||||
ISetupIntentCache setupIntentCache,
|
||||
IStripeAdapter stripeAdapter,
|
||||
ISubscriberService subscriberService,
|
||||
ITaxService taxService) : IProviderBillingService
|
||||
ITaxService taxService)
|
||||
: IProviderBillingService
|
||||
{
|
||||
[RequireFeature(FeatureFlagKeys.P15179_AddExistingOrgsFromProviderPortal)]
|
||||
public async Task AddExistingOrganization(
|
||||
Provider provider,
|
||||
Organization organization,
|
||||
@ -86,6 +96,7 @@ public class ProviderBillingService(
|
||||
organization.MaxStorageGb = plan.PasswordManager.BaseStorageGb;
|
||||
organization.UsePolicies = plan.HasPolicies;
|
||||
organization.UseSso = plan.HasSso;
|
||||
organization.UseOrganizationDomains = plan.HasOrganizationDomains;
|
||||
organization.UseGroups = plan.HasGroups;
|
||||
organization.UseEvents = plan.HasEvents;
|
||||
organization.UseDirectory = plan.HasDirectory;
|
||||
@ -114,7 +125,7 @@ public class ProviderBillingService(
|
||||
|
||||
/*
|
||||
* 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);
|
||||
|
||||
@ -143,36 +154,29 @@ public class ProviderBillingService(
|
||||
|
||||
public async Task ChangePlan(ChangeProviderPlanCommand command)
|
||||
{
|
||||
var plan = await providerPlanRepository.GetByIdAsync(command.ProviderPlanId);
|
||||
var (provider, providerPlanId, newPlanType) = command;
|
||||
|
||||
if (plan == null)
|
||||
var providerPlan = await providerPlanRepository.GetByIdAsync(providerPlanId);
|
||||
|
||||
if (providerPlan == null)
|
||||
{
|
||||
throw new BadRequestException("Provider plan not found.");
|
||||
}
|
||||
|
||||
if (plan.PlanType == command.NewPlan)
|
||||
if (providerPlan.PlanType == newPlanType)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var oldPlanConfiguration = await pricingClient.GetPlanOrThrow(plan.PlanType);
|
||||
var newPlanConfiguration = await pricingClient.GetPlanOrThrow(command.NewPlan);
|
||||
var subscription = await subscriberService.GetSubscriptionOrThrow(provider);
|
||||
|
||||
plan.PlanType = command.NewPlan;
|
||||
await providerPlanRepository.ReplaceAsync(plan);
|
||||
var oldPriceId = ProviderPriceAdapter.GetPriceId(provider, subscription, providerPlan.PlanType);
|
||||
var newPriceId = ProviderPriceAdapter.GetPriceId(provider, subscription, newPlanType);
|
||||
|
||||
Subscription subscription;
|
||||
try
|
||||
{
|
||||
subscription = await stripeAdapter.ProviderSubscriptionGetAsync(command.GatewaySubscriptionId, plan.ProviderId);
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
throw new ConflictException("Subscription not found.");
|
||||
}
|
||||
providerPlan.PlanType = newPlanType;
|
||||
await providerPlanRepository.ReplaceAsync(providerPlan);
|
||||
|
||||
var oldSubscriptionItem = subscription.Items.SingleOrDefault(x =>
|
||||
x.Price.Id == oldPlanConfiguration.PasswordManager.StripeProviderPortalSeatPlanId);
|
||||
var oldSubscriptionItem = subscription.Items.SingleOrDefault(x => x.Price.Id == oldPriceId);
|
||||
|
||||
var updateOptions = new SubscriptionUpdateOptions
|
||||
{
|
||||
@ -180,7 +184,7 @@ public class ProviderBillingService(
|
||||
[
|
||||
new SubscriptionItemOptions
|
||||
{
|
||||
Price = newPlanConfiguration.PasswordManager.StripeProviderPortalSeatPlanId,
|
||||
Price = newPriceId,
|
||||
Quantity = oldSubscriptionItem!.Quantity
|
||||
},
|
||||
new SubscriptionItemOptions
|
||||
@ -191,12 +195,14 @@ public class ProviderBillingService(
|
||||
]
|
||||
};
|
||||
|
||||
await stripeAdapter.SubscriptionUpdateAsync(command.GatewaySubscriptionId, updateOptions);
|
||||
await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId, updateOptions);
|
||||
|
||||
// Refactor later to ?ChangeClientPlanCommand? (ProviderPlanId, ProviderId, OrganizationId)
|
||||
// 1. Retrieve PlanType and PlanName for ProviderPlan
|
||||
// 2. Assign PlanType & PlanName to Organization
|
||||
var providerOrganizations = await providerOrganizationRepository.GetManyDetailsByProviderAsync(plan.ProviderId);
|
||||
var providerOrganizations = await providerOrganizationRepository.GetManyDetailsByProviderAsync(providerPlan.ProviderId);
|
||||
|
||||
var newPlan = await pricingClient.GetPlanOrThrow(newPlanType);
|
||||
|
||||
foreach (var providerOrganization in providerOrganizations)
|
||||
{
|
||||
@ -205,8 +211,8 @@ public class ProviderBillingService(
|
||||
{
|
||||
throw new ConflictException($"Organization '{providerOrganization.Id}' not found.");
|
||||
}
|
||||
organization.PlanType = command.NewPlan;
|
||||
organization.Plan = newPlanConfiguration.Name;
|
||||
organization.PlanType = newPlanType;
|
||||
organization.Plan = newPlan.Name;
|
||||
await organizationRepository.ReplaceAsync(organization);
|
||||
}
|
||||
}
|
||||
@ -227,7 +233,7 @@ public class ProviderBillingService(
|
||||
|
||||
var providerCustomer = await subscriberService.GetCustomerOrThrow(provider, new CustomerGetOptions
|
||||
{
|
||||
Expand = ["tax_ids"]
|
||||
Expand = ["tax", "tax_ids"]
|
||||
});
|
||||
|
||||
var providerTaxId = providerCustomer.TaxIds.FirstOrDefault();
|
||||
@ -275,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);
|
||||
|
||||
organization.GatewayCustomerId = customer.Id;
|
||||
@ -313,7 +326,6 @@ public class ProviderBillingService(
|
||||
return memoryStream.ToArray();
|
||||
}
|
||||
|
||||
[RequireFeature(FeatureFlagKeys.P15179_AddExistingOrgsFromProviderPortal)]
|
||||
public async Task<IEnumerable<AddableOrganization>> GetAddableOrganizations(
|
||||
Provider provider,
|
||||
Guid userId)
|
||||
@ -400,7 +412,7 @@ public class ProviderBillingService(
|
||||
|
||||
var newlyAssignedSeatTotal = currentlyAssignedSeatTotal + seatAdjustment;
|
||||
|
||||
var update = CurrySeatScalingUpdate(
|
||||
var scaleQuantityTo = CurrySeatScalingUpdate(
|
||||
provider,
|
||||
providerPlan,
|
||||
newlyAssignedSeatTotal);
|
||||
@ -423,9 +435,7 @@ public class ProviderBillingService(
|
||||
else if (currentlyAssignedSeatTotal <= seatMinimum &&
|
||||
newlyAssignedSeatTotal > seatMinimum)
|
||||
{
|
||||
await update(
|
||||
seatMinimum,
|
||||
newlyAssignedSeatTotal);
|
||||
await scaleQuantityTo(newlyAssignedSeatTotal);
|
||||
}
|
||||
/*
|
||||
* Above the limit => Above the limit:
|
||||
@ -434,9 +444,7 @@ public class ProviderBillingService(
|
||||
else if (currentlyAssignedSeatTotal > seatMinimum &&
|
||||
newlyAssignedSeatTotal > seatMinimum)
|
||||
{
|
||||
await update(
|
||||
currentlyAssignedSeatTotal,
|
||||
newlyAssignedSeatTotal);
|
||||
await scaleQuantityTo(newlyAssignedSeatTotal);
|
||||
}
|
||||
/*
|
||||
* Above the limit => Below the limit:
|
||||
@ -445,9 +453,7 @@ public class ProviderBillingService(
|
||||
else if (currentlyAssignedSeatTotal > seatMinimum &&
|
||||
newlyAssignedSeatTotal <= seatMinimum)
|
||||
{
|
||||
await update(
|
||||
currentlyAssignedSeatTotal,
|
||||
seatMinimum);
|
||||
await scaleQuantityTo(seatMinimum);
|
||||
}
|
||||
}
|
||||
|
||||
@ -473,7 +479,8 @@ public class ProviderBillingService(
|
||||
|
||||
public async Task<Customer> SetupCustomer(
|
||||
Provider provider,
|
||||
TaxInfo taxInfo)
|
||||
TaxInfo taxInfo,
|
||||
TokenizedPaymentSource tokenizedPaymentSource = null)
|
||||
{
|
||||
if (taxInfo is not
|
||||
{
|
||||
@ -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))
|
||||
{
|
||||
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}'.",
|
||||
taxInfo.BillingAddressCountry,
|
||||
taxInfo.TaxIdNumber);
|
||||
|
||||
throw new BadRequestException("billingTaxIdTypeInferenceError");
|
||||
}
|
||||
|
||||
@ -542,13 +557,97 @@ public class ProviderBillingService(
|
||||
options.Coupon = provider.DiscountId;
|
||||
}
|
||||
|
||||
var requireProviderPaymentMethodDuringSetup =
|
||||
featureService.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup);
|
||||
|
||||
var braintreeCustomerId = "";
|
||||
|
||||
if (requireProviderPaymentMethodDuringSetup)
|
||||
{
|
||||
if (tokenizedPaymentSource is not
|
||||
{
|
||||
Type: PaymentMethodType.BankAccount or PaymentMethodType.Card or PaymentMethodType.PayPal,
|
||||
Token: not null and not ""
|
||||
})
|
||||
{
|
||||
logger.LogError("Cannot create customer for provider ({ProviderID}) without a payment method", provider.Id);
|
||||
throw new BillingException();
|
||||
}
|
||||
|
||||
var (type, token) = tokenizedPaymentSource;
|
||||
|
||||
// ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault
|
||||
switch (type)
|
||||
{
|
||||
case PaymentMethodType.BankAccount:
|
||||
{
|
||||
var setupIntent =
|
||||
(await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = token }))
|
||||
.FirstOrDefault();
|
||||
|
||||
if (setupIntent == null)
|
||||
{
|
||||
logger.LogError("Cannot create customer for provider ({ProviderID}) without a setup intent for their bank account", provider.Id);
|
||||
throw new BillingException();
|
||||
}
|
||||
|
||||
await setupIntentCache.Set(provider.Id, setupIntent.Id);
|
||||
break;
|
||||
}
|
||||
case PaymentMethodType.Card:
|
||||
{
|
||||
options.PaymentMethod = token;
|
||||
options.InvoiceSettings.DefaultPaymentMethod = token;
|
||||
break;
|
||||
}
|
||||
case PaymentMethodType.PayPal:
|
||||
{
|
||||
braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(provider, token);
|
||||
options.Metadata[BraintreeCustomerIdKey] = braintreeCustomerId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await stripeAdapter.CustomerCreateAsync(options);
|
||||
}
|
||||
catch (StripeException stripeException) when (stripeException.StripeError?.Code == StripeConstants.ErrorCodes.TaxIdInvalid)
|
||||
catch (StripeException stripeException) when (stripeException.StripeError?.Code ==
|
||||
StripeConstants.ErrorCodes.TaxIdInvalid)
|
||||
{
|
||||
throw new BadRequestException("Your tax ID wasn't recognized for your selected country. Please ensure your country and tax ID are valid.");
|
||||
await Revert();
|
||||
throw new BadRequestException(
|
||||
"Your tax ID wasn't recognized for your selected country. Please ensure your country and tax ID are valid.");
|
||||
}
|
||||
catch
|
||||
{
|
||||
await Revert();
|
||||
throw;
|
||||
}
|
||||
|
||||
async Task Revert()
|
||||
{
|
||||
if (requireProviderPaymentMethodDuringSetup && tokenizedPaymentSource != null)
|
||||
{
|
||||
// ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault
|
||||
switch (tokenizedPaymentSource.Type)
|
||||
{
|
||||
case PaymentMethodType.BankAccount:
|
||||
{
|
||||
var setupIntentId = await setupIntentCache.Get(provider.Id);
|
||||
await stripeAdapter.SetupIntentCancel(setupIntentId,
|
||||
new SetupIntentCancelOptions { CancellationReason = "abandoned" });
|
||||
await setupIntentCache.Remove(provider.Id);
|
||||
break;
|
||||
}
|
||||
case PaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId):
|
||||
{
|
||||
await braintreeGateway.Customer.DeleteAsync(braintreeCustomerId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -557,7 +656,8 @@ public class ProviderBillingService(
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(provider);
|
||||
|
||||
var customer = await subscriberService.GetCustomerOrThrow(provider);
|
||||
var customerGetOptions = new CustomerGetOptions { Expand = ["tax", "tax_ids"] };
|
||||
var customer = await subscriberService.GetCustomerOrThrow(provider, customerGetOptions);
|
||||
|
||||
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
|
||||
|
||||
@ -580,36 +680,80 @@ public class ProviderBillingService(
|
||||
throw new BillingException();
|
||||
}
|
||||
|
||||
var priceId = ProviderPriceAdapter.GetActivePriceId(provider, providerPlan.PlanType);
|
||||
|
||||
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
|
||||
{
|
||||
Price = plan.PasswordManager.StripeProviderPortalSeatPlanId,
|
||||
Price = priceId,
|
||||
Quantity = providerPlan.SeatMinimum
|
||||
});
|
||||
}
|
||||
|
||||
var requireProviderPaymentMethodDuringSetup =
|
||||
featureService.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup);
|
||||
|
||||
var setupIntentId = await setupIntentCache.Get(provider.Id);
|
||||
|
||||
var setupIntent = !string.IsNullOrEmpty(setupIntentId)
|
||||
? await stripeAdapter.SetupIntentGet(setupIntentId, new SetupIntentGetOptions
|
||||
{
|
||||
Expand = ["payment_method"]
|
||||
})
|
||||
: null;
|
||||
|
||||
var usePaymentMethod =
|
||||
requireProviderPaymentMethodDuringSetup &&
|
||||
(!string.IsNullOrEmpty(customer.InvoiceSettings.DefaultPaymentMethodId) ||
|
||||
customer.Metadata.ContainsKey(BraintreeCustomerIdKey) ||
|
||||
setupIntent.IsUnverifiedBankAccount());
|
||||
|
||||
int? trialPeriodDays = provider.Type switch
|
||||
{
|
||||
ProviderType.Msp when usePaymentMethod => 14,
|
||||
ProviderType.BusinessUnit when usePaymentMethod => 4,
|
||||
_ => null
|
||||
};
|
||||
|
||||
var subscriptionCreateOptions = new SubscriptionCreateOptions
|
||||
{
|
||||
AutomaticTax = new SubscriptionAutomaticTaxOptions
|
||||
{
|
||||
Enabled = true
|
||||
},
|
||||
CollectionMethod = StripeConstants.CollectionMethod.SendInvoice,
|
||||
CollectionMethod = usePaymentMethod ?
|
||||
StripeConstants.CollectionMethod.ChargeAutomatically : StripeConstants.CollectionMethod.SendInvoice,
|
||||
Customer = customer.Id,
|
||||
DaysUntilDue = 30,
|
||||
DaysUntilDue = usePaymentMethod ? null : 30,
|
||||
Items = subscriptionItemOptionsList,
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
{ "providerId", provider.Id.ToString() }
|
||||
},
|
||||
OffSession = true,
|
||||
ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations
|
||||
ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations,
|
||||
TrialPeriodDays = trialPeriodDays
|
||||
};
|
||||
|
||||
var setNonUSBusinessUseToReverseCharge =
|
||||
featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge);
|
||||
|
||||
if (setNonUSBusinessUseToReverseCharge)
|
||||
{
|
||||
subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true };
|
||||
}
|
||||
else if (customer.HasRecognizedTaxLocation())
|
||||
{
|
||||
subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions
|
||||
{
|
||||
Enabled = customer.Address.Country == "US" ||
|
||||
customer.TaxIds.Any()
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var subscription = await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
|
||||
|
||||
if (subscription.Status == StripeConstants.SubscriptionStatus.Active)
|
||||
if (subscription is
|
||||
{
|
||||
Status: StripeConstants.SubscriptionStatus.Active or StripeConstants.SubscriptionStatus.Trialing
|
||||
})
|
||||
{
|
||||
return subscription;
|
||||
}
|
||||
@ -643,43 +787,37 @@ public class ProviderBillingService(
|
||||
|
||||
public async Task UpdateSeatMinimums(UpdateProviderSeatMinimumsCommand command)
|
||||
{
|
||||
if (command.Configuration.Any(x => x.SeatsMinimum < 0))
|
||||
var (provider, updatedPlanConfigurations) = command;
|
||||
|
||||
if (updatedPlanConfigurations.Any(x => x.SeatsMinimum < 0))
|
||||
{
|
||||
throw new BadRequestException("Provider seat minimums must be at least 0.");
|
||||
}
|
||||
|
||||
Subscription subscription;
|
||||
try
|
||||
{
|
||||
subscription = await stripeAdapter.ProviderSubscriptionGetAsync(command.GatewaySubscriptionId, command.Id);
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
throw new ConflictException("Subscription not found.");
|
||||
}
|
||||
var subscription = await subscriberService.GetSubscriptionOrThrow(provider);
|
||||
|
||||
var subscriptionItemOptionsList = new List<SubscriptionItemOptions>();
|
||||
|
||||
var providerPlans = await providerPlanRepository.GetByProviderId(command.Id);
|
||||
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
|
||||
|
||||
foreach (var newPlanConfiguration in command.Configuration)
|
||||
foreach (var updatedPlanConfiguration in updatedPlanConfigurations)
|
||||
{
|
||||
var (updatedPlanType, updatedSeatMinimum) = updatedPlanConfiguration;
|
||||
|
||||
var providerPlan =
|
||||
providerPlans.Single(providerPlan => providerPlan.PlanType == newPlanConfiguration.Plan);
|
||||
providerPlans.Single(providerPlan => providerPlan.PlanType == updatedPlanType);
|
||||
|
||||
if (providerPlan.SeatMinimum != newPlanConfiguration.SeatsMinimum)
|
||||
if (providerPlan.SeatMinimum != updatedSeatMinimum)
|
||||
{
|
||||
var newPlan = await pricingClient.GetPlanOrThrow(newPlanConfiguration.Plan);
|
||||
|
||||
var priceId = newPlan.PasswordManager.StripeProviderPortalSeatPlanId;
|
||||
var priceId = ProviderPriceAdapter.GetPriceId(provider, subscription, updatedPlanType);
|
||||
|
||||
var subscriptionItem = subscription.Items.First(item => item.Price.Id == priceId);
|
||||
|
||||
if (providerPlan.PurchasedSeats == 0)
|
||||
{
|
||||
if (providerPlan.AllocatedSeats > newPlanConfiguration.SeatsMinimum)
|
||||
if (providerPlan.AllocatedSeats > updatedSeatMinimum)
|
||||
{
|
||||
providerPlan.PurchasedSeats = providerPlan.AllocatedSeats - newPlanConfiguration.SeatsMinimum;
|
||||
providerPlan.PurchasedSeats = providerPlan.AllocatedSeats - updatedSeatMinimum;
|
||||
|
||||
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
|
||||
{
|
||||
@ -694,7 +832,7 @@ public class ProviderBillingService(
|
||||
{
|
||||
Id = subscriptionItem.Id,
|
||||
Price = priceId,
|
||||
Quantity = newPlanConfiguration.SeatsMinimum
|
||||
Quantity = updatedSeatMinimum
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -702,9 +840,9 @@ public class ProviderBillingService(
|
||||
{
|
||||
var totalSeats = providerPlan.SeatMinimum + providerPlan.PurchasedSeats;
|
||||
|
||||
if (newPlanConfiguration.SeatsMinimum <= totalSeats)
|
||||
if (updatedSeatMinimum <= totalSeats)
|
||||
{
|
||||
providerPlan.PurchasedSeats = totalSeats - newPlanConfiguration.SeatsMinimum;
|
||||
providerPlan.PurchasedSeats = totalSeats - updatedSeatMinimum;
|
||||
}
|
||||
else
|
||||
{
|
||||
@ -713,12 +851,12 @@ public class ProviderBillingService(
|
||||
{
|
||||
Id = subscriptionItem.Id,
|
||||
Price = priceId,
|
||||
Quantity = newPlanConfiguration.SeatsMinimum
|
||||
Quantity = updatedSeatMinimum
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
providerPlan.SeatMinimum = newPlanConfiguration.SeatsMinimum;
|
||||
providerPlan.SeatMinimum = updatedSeatMinimum;
|
||||
|
||||
await providerPlanRepository.ReplaceAsync(providerPlan);
|
||||
}
|
||||
@ -726,23 +864,33 @@ public class ProviderBillingService(
|
||||
|
||||
if (subscriptionItemOptionsList.Count > 0)
|
||||
{
|
||||
await stripeAdapter.SubscriptionUpdateAsync(command.GatewaySubscriptionId,
|
||||
await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId,
|
||||
new SubscriptionUpdateOptions { Items = subscriptionItemOptionsList });
|
||||
}
|
||||
}
|
||||
|
||||
private Func<int, int, Task> CurrySeatScalingUpdate(
|
||||
private Func<int, Task> CurrySeatScalingUpdate(
|
||||
Provider provider,
|
||||
ProviderPlan providerPlan,
|
||||
int newlyAssignedSeats) => async (currentlySubscribedSeats, newlySubscribedSeats) =>
|
||||
int newlyAssignedSeats) => async newlySubscribedSeats =>
|
||||
{
|
||||
var plan = await pricingClient.GetPlanOrThrow(providerPlan.PlanType);
|
||||
var subscription = await subscriberService.GetSubscriptionOrThrow(provider);
|
||||
|
||||
await paymentService.AdjustSeats(
|
||||
provider,
|
||||
plan,
|
||||
currentlySubscribedSeats,
|
||||
newlySubscribedSeats);
|
||||
var priceId = ProviderPriceAdapter.GetPriceId(provider, subscription, providerPlan.PlanType);
|
||||
|
||||
var item = subscription.Items.First(item => item.Price.Id == priceId);
|
||||
|
||||
await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId, new SubscriptionUpdateOptions
|
||||
{
|
||||
Items = [
|
||||
new SubscriptionItemOptions
|
||||
{
|
||||
Id = item.Id,
|
||||
Price = priceId,
|
||||
Quantity = newlySubscribedSeats
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
var newlyPurchasedSeats = newlySubscribedSeats > providerPlan.SeatMinimum
|
||||
? newlySubscribedSeats - providerPlan.SeatMinimum
|
||||
@ -786,7 +934,7 @@ public class ProviderBillingService(
|
||||
Provider provider,
|
||||
Organization organization)
|
||||
{
|
||||
if (provider.Type == ProviderType.MultiOrganizationEnterprise)
|
||||
if (provider.Type == ProviderType.BusinessUnit)
|
||||
{
|
||||
return (await providerPlanRepository.GetByProviderId(provider.Id)).First().PlanType;
|
||||
}
|
@ -0,0 +1,133 @@
|
||||
// ReSharper disable SwitchExpressionHandlesSomeKnownEnumValuesWithExceptionInDefault
|
||||
#nullable enable
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.Billing;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Commercial.Core.Billing.Providers.Services;
|
||||
|
||||
public static class ProviderPriceAdapter
|
||||
{
|
||||
public static class MSP
|
||||
{
|
||||
public static class Active
|
||||
{
|
||||
public const string Enterprise = "provider-portal-enterprise-monthly-2025";
|
||||
public const string Teams = "provider-portal-teams-monthly-2025";
|
||||
}
|
||||
|
||||
public static class Legacy
|
||||
{
|
||||
public const string Enterprise = "password-manager-provider-portal-enterprise-monthly-2024";
|
||||
public const string Teams = "password-manager-provider-portal-teams-monthly-2024";
|
||||
public static readonly List<string> List = [Enterprise, Teams];
|
||||
}
|
||||
}
|
||||
|
||||
public static class BusinessUnit
|
||||
{
|
||||
public static class Active
|
||||
{
|
||||
public const string Annually = "business-unit-portal-enterprise-annually-2025";
|
||||
public const string Monthly = "business-unit-portal-enterprise-monthly-2025";
|
||||
}
|
||||
|
||||
public static class Legacy
|
||||
{
|
||||
public const string Annually = "password-manager-provider-portal-enterprise-annually-2024";
|
||||
public const string Monthly = "password-manager-provider-portal-enterprise-monthly-2024";
|
||||
public static readonly List<string> List = [Annually, Monthly];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uses the <paramref name="provider"/>'s <see cref="Provider.Type"/> and <paramref name="subscription"/> to determine
|
||||
/// whether the <paramref name="provider"/> is on active or legacy pricing and then returns a Stripe price ID for the provided
|
||||
/// <paramref name="planType"/> based on that determination.
|
||||
/// </summary>
|
||||
/// <param name="provider">The provider to get the Stripe price ID for.</param>
|
||||
/// <param name="subscription">The provider's subscription.</param>
|
||||
/// <param name="planType">The plan type correlating to the desired Stripe price ID.</param>
|
||||
/// <returns>A Stripe <see cref="Stripe.Price"/> ID.</returns>
|
||||
/// <exception cref="BillingException">Thrown when the provider's type is not <see cref="ProviderType.Msp"/> or <see cref="ProviderType.BusinessUnit"/>.</exception>
|
||||
/// <exception cref="BillingException">Thrown when the provided <see cref="planType"/> does not relate to a Stripe price ID.</exception>
|
||||
public static string GetPriceId(
|
||||
Provider provider,
|
||||
Subscription subscription,
|
||||
PlanType planType)
|
||||
{
|
||||
var priceIds = subscription.Items.Select(item => item.Price.Id);
|
||||
|
||||
var invalidPlanType =
|
||||
new BillingException(message: $"PlanType {planType} does not have an associated provider price in Stripe");
|
||||
|
||||
return provider.Type switch
|
||||
{
|
||||
ProviderType.Msp => MSP.Legacy.List.Intersect(priceIds).Any()
|
||||
? planType switch
|
||||
{
|
||||
PlanType.TeamsMonthly => MSP.Legacy.Teams,
|
||||
PlanType.EnterpriseMonthly => MSP.Legacy.Enterprise,
|
||||
_ => throw invalidPlanType
|
||||
}
|
||||
: planType switch
|
||||
{
|
||||
PlanType.TeamsMonthly => MSP.Active.Teams,
|
||||
PlanType.EnterpriseMonthly => MSP.Active.Enterprise,
|
||||
_ => throw invalidPlanType
|
||||
},
|
||||
ProviderType.BusinessUnit => BusinessUnit.Legacy.List.Intersect(priceIds).Any()
|
||||
? planType switch
|
||||
{
|
||||
PlanType.EnterpriseAnnually => BusinessUnit.Legacy.Annually,
|
||||
PlanType.EnterpriseMonthly => BusinessUnit.Legacy.Monthly,
|
||||
_ => throw invalidPlanType
|
||||
}
|
||||
: planType switch
|
||||
{
|
||||
PlanType.EnterpriseAnnually => BusinessUnit.Active.Annually,
|
||||
PlanType.EnterpriseMonthly => BusinessUnit.Active.Monthly,
|
||||
_ => throw invalidPlanType
|
||||
},
|
||||
_ => throw new BillingException(
|
||||
$"ProviderType {provider.Type} does not have any associated provider price IDs")
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uses the <paramref name="provider"/>'s <see cref="Provider.Type"/> to return the active Stripe price ID for the provided
|
||||
/// <paramref name="planType"/>.
|
||||
/// </summary>
|
||||
/// <param name="provider">The provider to get the Stripe price ID for.</param>
|
||||
/// <param name="planType">The plan type correlating to the desired Stripe price ID.</param>
|
||||
/// <returns>A Stripe <see cref="Stripe.Price"/> ID.</returns>
|
||||
/// <exception cref="BillingException">Thrown when the provider's type is not <see cref="ProviderType.Msp"/> or <see cref="ProviderType.BusinessUnit"/>.</exception>
|
||||
/// <exception cref="BillingException">Thrown when the provided <see cref="planType"/> does not relate to a Stripe price ID.</exception>
|
||||
public static string GetActivePriceId(
|
||||
Provider provider,
|
||||
PlanType planType)
|
||||
{
|
||||
var invalidPlanType =
|
||||
new BillingException(message: $"PlanType {planType} does not have an associated provider price in Stripe");
|
||||
|
||||
return provider.Type switch
|
||||
{
|
||||
ProviderType.Msp => planType switch
|
||||
{
|
||||
PlanType.TeamsMonthly => MSP.Active.Teams,
|
||||
PlanType.EnterpriseMonthly => MSP.Active.Enterprise,
|
||||
_ => throw invalidPlanType
|
||||
},
|
||||
ProviderType.BusinessUnit => planType switch
|
||||
{
|
||||
PlanType.EnterpriseAnnually => BusinessUnit.Active.Annually,
|
||||
PlanType.EnterpriseMonthly => BusinessUnit.Active.Monthly,
|
||||
_ => throw invalidPlanType
|
||||
},
|
||||
_ => throw new BillingException(
|
||||
$"ProviderType {provider.Type} does not have any associated provider price IDs")
|
||||
};
|
||||
}
|
||||
}
|
@ -1,9 +1,10 @@
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.SecretsManager.Queries.Projects.Interfaces;
|
||||
using Bit.Core.SecretsManager.Repositories;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Core.Settings;
|
||||
|
||||
namespace Bit.Commercial.Core.SecretsManager.Queries.Projects;
|
||||
|
||||
@ -11,36 +12,43 @@ public class MaxProjectsQuery : IMaxProjectsQuery
|
||||
{
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly IProjectRepository _projectRepository;
|
||||
private readonly IGlobalSettings _globalSettings;
|
||||
private readonly IPricingClient _pricingClient;
|
||||
|
||||
public MaxProjectsQuery(
|
||||
IOrganizationRepository organizationRepository,
|
||||
IProjectRepository projectRepository)
|
||||
IProjectRepository projectRepository,
|
||||
IGlobalSettings globalSettings,
|
||||
IPricingClient pricingClient)
|
||||
{
|
||||
_organizationRepository = organizationRepository;
|
||||
_projectRepository = projectRepository;
|
||||
_globalSettings = globalSettings;
|
||||
_pricingClient = pricingClient;
|
||||
}
|
||||
|
||||
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);
|
||||
if (org == null)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
// TODO: PRICING -> https://bitwarden.atlassian.net/browse/PM-17122
|
||||
var plan = StaticStore.GetPlan(org.PlanType);
|
||||
if (plan?.SecretsManager == null)
|
||||
var plan = await _pricingClient.GetPlan(org.PlanType);
|
||||
|
||||
if (plan is not { SecretsManager: not null, Type: PlanType.Free })
|
||||
{
|
||||
throw new BadRequestException("Existing plan not found.");
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
if (plan.Type == PlanType.Free)
|
||||
{
|
||||
var projects = await _projectRepository.GetProjectCountByOrganizationIdAsync(organizationId);
|
||||
return ((short? max, bool? overMax))(projects + projectsToAdd > plan.SecretsManager.MaxProjects ? (plan.SecretsManager.MaxProjects, true) : (plan.SecretsManager.MaxProjects, false));
|
||||
}
|
||||
|
||||
return (null, null);
|
||||
var projects = await _projectRepository.GetProjectCountByOrganizationIdAsync(organizationId);
|
||||
return ((short? max, bool? overMax))(projects + projectsToAdd > plan.SecretsManager.MaxProjects ? (plan.SecretsManager.MaxProjects, true) : (plan.SecretsManager.MaxProjects, false));
|
||||
}
|
||||
}
|
||||
|
@ -1,9 +1,9 @@
|
||||
using Bit.Commercial.Core.AdminConsole.Providers;
|
||||
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.Services;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Billing.Providers.Services;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Bit.Commercial.Core.Utilities;
|
||||
@ -16,5 +16,6 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<ICreateProviderCommand, CreateProviderCommand>();
|
||||
services.AddScoped<IRemoveOrganizationFromProviderCommand, RemoveOrganizationFromProviderCommand>();
|
||||
services.AddTransient<IProviderBillingService, ProviderBillingService>();
|
||||
services.AddTransient<IBusinessUnitConverter, BusinessUnitConverter>();
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +0,0 @@
|
||||
*
|
||||
!obj/build-output/publish/*
|
||||
!obj/Docker/empty/
|
||||
!entrypoint.sh
|
@ -1,10 +1,8 @@
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Scim.Groups.Interfaces;
|
||||
using Bit.Scim.Models;
|
||||
using Bit.Scim.Utilities;
|
||||
@ -24,10 +22,8 @@ public class GroupsController : Controller
|
||||
private readonly IGetGroupsListQuery _getGroupsListQuery;
|
||||
private readonly IDeleteGroupCommand _deleteGroupCommand;
|
||||
private readonly IPatchGroupCommand _patchGroupCommand;
|
||||
private readonly IPatchGroupCommandvNext _patchGroupCommandvNext;
|
||||
private readonly IPostGroupCommand _postGroupCommand;
|
||||
private readonly IPutGroupCommand _putGroupCommand;
|
||||
private readonly IFeatureService _featureService;
|
||||
|
||||
public GroupsController(
|
||||
IGroupRepository groupRepository,
|
||||
@ -35,10 +31,8 @@ public class GroupsController : Controller
|
||||
IGetGroupsListQuery getGroupsListQuery,
|
||||
IDeleteGroupCommand deleteGroupCommand,
|
||||
IPatchGroupCommand patchGroupCommand,
|
||||
IPatchGroupCommandvNext patchGroupCommandvNext,
|
||||
IPostGroupCommand postGroupCommand,
|
||||
IPutGroupCommand putGroupCommand,
|
||||
IFeatureService featureService
|
||||
IPutGroupCommand putGroupCommand
|
||||
)
|
||||
{
|
||||
_groupRepository = groupRepository;
|
||||
@ -46,10 +40,8 @@ public class GroupsController : Controller
|
||||
_getGroupsListQuery = getGroupsListQuery;
|
||||
_deleteGroupCommand = deleteGroupCommand;
|
||||
_patchGroupCommand = patchGroupCommand;
|
||||
_patchGroupCommandvNext = patchGroupCommandvNext;
|
||||
_postGroupCommand = postGroupCommand;
|
||||
_putGroupCommand = putGroupCommand;
|
||||
_featureService = featureService;
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
@ -103,21 +95,13 @@ public class GroupsController : Controller
|
||||
[HttpPatch("{id}")]
|
||||
public async Task<IActionResult> Patch(Guid organizationId, Guid id, [FromBody] ScimPatchModel model)
|
||||
{
|
||||
if (_featureService.IsEnabled(FeatureFlagKeys.ShortcutDuplicatePatchRequests))
|
||||
var group = await _groupRepository.GetByIdAsync(id);
|
||||
if (group == null || group.OrganizationId != organizationId)
|
||||
{
|
||||
var group = await _groupRepository.GetByIdAsync(id);
|
||||
if (group == null || group.OrganizationId != organizationId)
|
||||
{
|
||||
throw new NotFoundException("Group not found.");
|
||||
}
|
||||
|
||||
await _patchGroupCommandvNext.PatchGroupAsync(group, model);
|
||||
return new NoContentResult();
|
||||
throw new NotFoundException("Group not found.");
|
||||
}
|
||||
|
||||
var organization = await _organizationRepository.GetByIdAsync(organizationId);
|
||||
await _patchGroupCommand.PatchGroupAsync(organization, id, model);
|
||||
|
||||
await _patchGroupCommand.PatchGroupAsync(group, model);
|
||||
return new NoContentResult();
|
||||
}
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
@ -23,7 +24,7 @@ public class UsersController : Controller
|
||||
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
|
||||
private readonly IPatchUserCommand _patchUserCommand;
|
||||
private readonly IPostUserCommand _postUserCommand;
|
||||
private readonly ILogger<UsersController> _logger;
|
||||
private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand;
|
||||
|
||||
public UsersController(
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
@ -32,7 +33,7 @@ public class UsersController : Controller
|
||||
IRemoveOrganizationUserCommand removeOrganizationUserCommand,
|
||||
IPatchUserCommand patchUserCommand,
|
||||
IPostUserCommand postUserCommand,
|
||||
ILogger<UsersController> logger)
|
||||
IRestoreOrganizationUserCommand restoreOrganizationUserCommand)
|
||||
{
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
_organizationService = organizationService;
|
||||
@ -40,7 +41,7 @@ public class UsersController : Controller
|
||||
_removeOrganizationUserCommand = removeOrganizationUserCommand;
|
||||
_patchUserCommand = patchUserCommand;
|
||||
_postUserCommand = postUserCommand;
|
||||
_logger = logger;
|
||||
_restoreOrganizationUserCommand = restoreOrganizationUserCommand;
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
@ -93,7 +94,7 @@ public class UsersController : Controller
|
||||
|
||||
if (model.Active && orgUser.Status == OrganizationUserStatusType.Revoked)
|
||||
{
|
||||
await _organizationService.RestoreUserAsync(orgUser, EventSystemUser.SCIM);
|
||||
await _restoreOrganizationUserCommand.RestoreUserAsync(orgUser, EventSystemUser.SCIM);
|
||||
}
|
||||
else if (!model.Active && orgUser.Status != OrganizationUserStatusType.Revoked)
|
||||
{
|
||||
|
@ -1,6 +1,50 @@
|
||||
###############################################
|
||||
# Build stage #
|
||||
###############################################
|
||||
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
||||
|
||||
# Docker buildx supplies the value for this arg
|
||||
ARG TARGETPLATFORM
|
||||
|
||||
# Determine proper runtime value for .NET
|
||||
# We put the value in a file to be read by later layers.
|
||||
RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \
|
||||
RID=linux-x64 ; \
|
||||
elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \
|
||||
RID=linux-arm64 ; \
|
||||
elif [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then \
|
||||
RID=linux-arm ; \
|
||||
fi \
|
||||
&& echo "RID=$RID" > /tmp/rid.txt
|
||||
|
||||
# Copy required project files
|
||||
WORKDIR /source
|
||||
COPY . ./
|
||||
|
||||
# Restore project dependencies and tools
|
||||
WORKDIR /source/bitwarden_license/src/Scim
|
||||
RUN . /tmp/rid.txt && dotnet restore -r $RID
|
||||
|
||||
# Build project
|
||||
RUN . /tmp/rid.txt && dotnet publish \
|
||||
-c release \
|
||||
--no-restore \
|
||||
--self-contained \
|
||||
/p:PublishSingleFile=true \
|
||||
-r $RID \
|
||||
-o out
|
||||
|
||||
###############################################
|
||||
# App stage #
|
||||
###############################################
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:8.0
|
||||
|
||||
ARG TARGETPLATFORM
|
||||
LABEL com.bitwarden.product="bitwarden"
|
||||
ENV ASPNETCORE_ENVIRONMENT=Production
|
||||
ENV ASPNETCORE_URLS=http://+:5000
|
||||
ENV SSL_CERT_DIR=/etc/bitwarden/ca-certificates
|
||||
EXPOSE 5000
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
@ -9,11 +53,10 @@ RUN apt-get update \
|
||||
krb5-user \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV ASPNETCORE_URLS http://+:5000
|
||||
# Copy app from the build stage
|
||||
WORKDIR /app
|
||||
EXPOSE 5000
|
||||
COPY obj/build-output/publish .
|
||||
COPY entrypoint.sh /
|
||||
COPY --from=build /source/bitwarden_license/src/Scim/out /app
|
||||
COPY ./bitwarden_license/src/Scim/entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
HEALTHCHECK CMD curl -f http://localhost:5000/alive || exit 1
|
||||
|
@ -5,5 +5,5 @@ namespace Bit.Scim.Groups.Interfaces;
|
||||
|
||||
public interface IPatchGroupCommand
|
||||
{
|
||||
Task PatchGroupAsync(Organization organization, Guid id, ScimPatchModel model);
|
||||
Task PatchGroupAsync(Group group, ScimPatchModel model);
|
||||
}
|
||||
|
@ -1,9 +0,0 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Scim.Models;
|
||||
|
||||
namespace Bit.Scim.Groups.Interfaces;
|
||||
|
||||
public interface IPatchGroupCommandvNext
|
||||
{
|
||||
Task PatchGroupAsync(Group group, ScimPatchModel model);
|
||||
}
|
@ -5,8 +5,10 @@ using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Scim.Groups.Interfaces;
|
||||
using Bit.Scim.Models;
|
||||
using Bit.Scim.Utilities;
|
||||
|
||||
namespace Bit.Scim.Groups;
|
||||
|
||||
@ -16,118 +18,137 @@ public class PatchGroupCommand : IPatchGroupCommand
|
||||
private readonly IGroupService _groupService;
|
||||
private readonly IUpdateGroupCommand _updateGroupCommand;
|
||||
private readonly ILogger<PatchGroupCommand> _logger;
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
|
||||
public PatchGroupCommand(
|
||||
IGroupRepository groupRepository,
|
||||
IGroupService groupService,
|
||||
IUpdateGroupCommand updateGroupCommand,
|
||||
ILogger<PatchGroupCommand> logger)
|
||||
ILogger<PatchGroupCommand> logger,
|
||||
IOrganizationRepository organizationRepository)
|
||||
{
|
||||
_groupRepository = groupRepository;
|
||||
_groupService = groupService;
|
||||
_updateGroupCommand = updateGroupCommand;
|
||||
_logger = logger;
|
||||
_organizationRepository = organizationRepository;
|
||||
}
|
||||
|
||||
public async Task PatchGroupAsync(Organization organization, Guid id, ScimPatchModel model)
|
||||
public async Task PatchGroupAsync(Group group, ScimPatchModel model)
|
||||
{
|
||||
var group = await _groupRepository.GetByIdAsync(id);
|
||||
if (group == null || group.OrganizationId != organization.Id)
|
||||
{
|
||||
throw new NotFoundException("Group not found.");
|
||||
}
|
||||
|
||||
var operationHandled = false;
|
||||
foreach (var operation in model.Operations)
|
||||
{
|
||||
// Replace operations
|
||||
if (operation.Op?.ToLowerInvariant() == "replace")
|
||||
{
|
||||
// Replace a list of members
|
||||
if (operation.Path?.ToLowerInvariant() == "members")
|
||||
await HandleOperationAsync(group, operation);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleOperationAsync(Group group, ScimPatchModel.OperationModel operation)
|
||||
{
|
||||
switch (operation.Op?.ToLowerInvariant())
|
||||
{
|
||||
// Replace a list of members
|
||||
case PatchOps.Replace when operation.Path?.ToLowerInvariant() == PatchPaths.Members:
|
||||
{
|
||||
var ids = GetOperationValueIds(operation.Value);
|
||||
await _groupRepository.UpdateUsersAsync(group.Id, ids);
|
||||
operationHandled = true;
|
||||
break;
|
||||
}
|
||||
// Replace group name from path
|
||||
else if (operation.Path?.ToLowerInvariant() == "displayname")
|
||||
|
||||
// Replace group name from path
|
||||
case PatchOps.Replace when operation.Path?.ToLowerInvariant() == PatchPaths.DisplayName:
|
||||
{
|
||||
group.Name = operation.Value.GetString();
|
||||
var organization = await _organizationRepository.GetByIdAsync(group.OrganizationId);
|
||||
if (organization == null)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
await _updateGroupCommand.UpdateGroupAsync(group, organization, EventSystemUser.SCIM);
|
||||
operationHandled = true;
|
||||
break;
|
||||
}
|
||||
// Replace group name from value object
|
||||
else if (string.IsNullOrWhiteSpace(operation.Path) &&
|
||||
operation.Value.TryGetProperty("displayName", out var displayNameProperty))
|
||||
|
||||
// Replace group name from value object
|
||||
case PatchOps.Replace when
|
||||
string.IsNullOrWhiteSpace(operation.Path) &&
|
||||
operation.Value.TryGetProperty("displayName", out var displayNameProperty):
|
||||
{
|
||||
group.Name = displayNameProperty.GetString();
|
||||
var organization = await _organizationRepository.GetByIdAsync(group.OrganizationId);
|
||||
if (organization == null)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
await _updateGroupCommand.UpdateGroupAsync(group, organization, EventSystemUser.SCIM);
|
||||
operationHandled = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Add a single member
|
||||
else if (operation.Op?.ToLowerInvariant() == "add" &&
|
||||
case PatchOps.Add when
|
||||
!string.IsNullOrWhiteSpace(operation.Path) &&
|
||||
operation.Path.ToLowerInvariant().StartsWith("members[value eq "))
|
||||
{
|
||||
var addId = GetOperationPathId(operation.Path);
|
||||
if (addId.HasValue)
|
||||
operation.Path.StartsWith("members[value eq ", StringComparison.OrdinalIgnoreCase) &&
|
||||
TryGetOperationPathId(operation.Path, out var addId):
|
||||
{
|
||||
await AddMembersAsync(group, [addId]);
|
||||
break;
|
||||
}
|
||||
|
||||
// Add a list of members
|
||||
case PatchOps.Add when
|
||||
operation.Path?.ToLowerInvariant() == PatchPaths.Members:
|
||||
{
|
||||
await AddMembersAsync(group, GetOperationValueIds(operation.Value));
|
||||
break;
|
||||
}
|
||||
|
||||
// Remove a single member
|
||||
case PatchOps.Remove when
|
||||
!string.IsNullOrWhiteSpace(operation.Path) &&
|
||||
operation.Path.StartsWith("members[value eq ", StringComparison.OrdinalIgnoreCase) &&
|
||||
TryGetOperationPathId(operation.Path, out var removeId):
|
||||
{
|
||||
await _groupService.DeleteUserAsync(group, removeId, EventSystemUser.SCIM);
|
||||
break;
|
||||
}
|
||||
|
||||
// Remove a list of members
|
||||
case PatchOps.Remove when
|
||||
operation.Path?.ToLowerInvariant() == PatchPaths.Members:
|
||||
{
|
||||
var orgUserIds = (await _groupRepository.GetManyUserIdsByIdAsync(group.Id)).ToHashSet();
|
||||
orgUserIds.Add(addId.Value);
|
||||
foreach (var v in GetOperationValueIds(operation.Value))
|
||||
{
|
||||
orgUserIds.Remove(v);
|
||||
}
|
||||
await _groupRepository.UpdateUsersAsync(group.Id, orgUserIds);
|
||||
operationHandled = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Add a list of members
|
||||
else if (operation.Op?.ToLowerInvariant() == "add" &&
|
||||
operation.Path?.ToLowerInvariant() == "members")
|
||||
{
|
||||
var orgUserIds = (await _groupRepository.GetManyUserIdsByIdAsync(group.Id)).ToHashSet();
|
||||
foreach (var v in GetOperationValueIds(operation.Value))
|
||||
{
|
||||
orgUserIds.Add(v);
|
||||
}
|
||||
await _groupRepository.UpdateUsersAsync(group.Id, orgUserIds);
|
||||
operationHandled = true;
|
||||
}
|
||||
// Remove a single member
|
||||
else if (operation.Op?.ToLowerInvariant() == "remove" &&
|
||||
!string.IsNullOrWhiteSpace(operation.Path) &&
|
||||
operation.Path.ToLowerInvariant().StartsWith("members[value eq "))
|
||||
{
|
||||
var removeId = GetOperationPathId(operation.Path);
|
||||
if (removeId.HasValue)
|
||||
{
|
||||
await _groupService.DeleteUserAsync(group, removeId.Value, EventSystemUser.SCIM);
|
||||
operationHandled = true;
|
||||
}
|
||||
}
|
||||
// Remove a list of members
|
||||
else if (operation.Op?.ToLowerInvariant() == "remove" &&
|
||||
operation.Path?.ToLowerInvariant() == "members")
|
||||
{
|
||||
var orgUserIds = (await _groupRepository.GetManyUserIdsByIdAsync(group.Id)).ToHashSet();
|
||||
foreach (var v in GetOperationValueIds(operation.Value))
|
||||
{
|
||||
orgUserIds.Remove(v);
|
||||
}
|
||||
await _groupRepository.UpdateUsersAsync(group.Id, orgUserIds);
|
||||
operationHandled = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!operationHandled)
|
||||
{
|
||||
_logger.LogWarning("Group patch operation not handled: {0} : ",
|
||||
string.Join(", ", model.Operations.Select(o => $"{o.Op}:{o.Path}")));
|
||||
default:
|
||||
{
|
||||
_logger.LogWarning("Group patch operation not handled: {OperationOp}:{OperationPath}", operation.Op, operation.Path);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private List<Guid> GetOperationValueIds(JsonElement objArray)
|
||||
private async Task AddMembersAsync(Group group, HashSet<Guid> usersToAdd)
|
||||
{
|
||||
var ids = new List<Guid>();
|
||||
// Azure Entra ID is known to send redundant "add" requests for each existing member every time any member
|
||||
// is removed. To avoid excessive load on the database, we check against the high availability replica and
|
||||
// return early if they already exist.
|
||||
var groupMembers = await _groupRepository.GetManyUserIdsByIdAsync(group.Id, useReadOnlyReplica: true);
|
||||
if (usersToAdd.IsSubsetOf(groupMembers))
|
||||
{
|
||||
_logger.LogDebug("Ignoring duplicate SCIM request to add members {Members} to group {Group}", usersToAdd, group.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
await _groupRepository.AddGroupUsersByIdAsync(group.Id, usersToAdd);
|
||||
}
|
||||
|
||||
private static HashSet<Guid> GetOperationValueIds(JsonElement objArray)
|
||||
{
|
||||
var ids = new HashSet<Guid>();
|
||||
foreach (var obj in objArray.EnumerateArray())
|
||||
{
|
||||
if (obj.TryGetProperty("value", out var valueProperty))
|
||||
@ -141,13 +162,9 @@ public class PatchGroupCommand : IPatchGroupCommand
|
||||
return ids;
|
||||
}
|
||||
|
||||
private Guid? GetOperationPathId(string path)
|
||||
private static bool TryGetOperationPathId(string path, out Guid pathId)
|
||||
{
|
||||
// Parse Guid from string like: members[value eq "{GUID}"}]
|
||||
if (Guid.TryParse(path.Substring(18).Replace("\"]", string.Empty), out var id))
|
||||
{
|
||||
return id;
|
||||
}
|
||||
return null;
|
||||
return Guid.TryParse(path.Substring(18).Replace("\"]", string.Empty), out pathId);
|
||||
}
|
||||
}
|
||||
|
@ -1,170 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Scim.Groups.Interfaces;
|
||||
using Bit.Scim.Models;
|
||||
using Bit.Scim.Utilities;
|
||||
|
||||
namespace Bit.Scim.Groups;
|
||||
|
||||
public class PatchGroupCommandvNext : IPatchGroupCommandvNext
|
||||
{
|
||||
private readonly IGroupRepository _groupRepository;
|
||||
private readonly IGroupService _groupService;
|
||||
private readonly IUpdateGroupCommand _updateGroupCommand;
|
||||
private readonly ILogger<PatchGroupCommandvNext> _logger;
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
|
||||
public PatchGroupCommandvNext(
|
||||
IGroupRepository groupRepository,
|
||||
IGroupService groupService,
|
||||
IUpdateGroupCommand updateGroupCommand,
|
||||
ILogger<PatchGroupCommandvNext> logger,
|
||||
IOrganizationRepository organizationRepository)
|
||||
{
|
||||
_groupRepository = groupRepository;
|
||||
_groupService = groupService;
|
||||
_updateGroupCommand = updateGroupCommand;
|
||||
_logger = logger;
|
||||
_organizationRepository = organizationRepository;
|
||||
}
|
||||
|
||||
public async Task PatchGroupAsync(Group group, ScimPatchModel model)
|
||||
{
|
||||
foreach (var operation in model.Operations)
|
||||
{
|
||||
await HandleOperationAsync(group, operation);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleOperationAsync(Group group, ScimPatchModel.OperationModel operation)
|
||||
{
|
||||
switch (operation.Op?.ToLowerInvariant())
|
||||
{
|
||||
// Replace a list of members
|
||||
case PatchOps.Replace when operation.Path?.ToLowerInvariant() == PatchPaths.Members:
|
||||
{
|
||||
var ids = GetOperationValueIds(operation.Value);
|
||||
await _groupRepository.UpdateUsersAsync(group.Id, ids);
|
||||
break;
|
||||
}
|
||||
|
||||
// Replace group name from path
|
||||
case PatchOps.Replace when operation.Path?.ToLowerInvariant() == PatchPaths.DisplayName:
|
||||
{
|
||||
group.Name = operation.Value.GetString();
|
||||
var organization = await _organizationRepository.GetByIdAsync(group.OrganizationId);
|
||||
if (organization == null)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
await _updateGroupCommand.UpdateGroupAsync(group, organization, EventSystemUser.SCIM);
|
||||
break;
|
||||
}
|
||||
|
||||
// Replace group name from value object
|
||||
case PatchOps.Replace when
|
||||
string.IsNullOrWhiteSpace(operation.Path) &&
|
||||
operation.Value.TryGetProperty("displayName", out var displayNameProperty):
|
||||
{
|
||||
group.Name = displayNameProperty.GetString();
|
||||
var organization = await _organizationRepository.GetByIdAsync(group.OrganizationId);
|
||||
if (organization == null)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
await _updateGroupCommand.UpdateGroupAsync(group, organization, EventSystemUser.SCIM);
|
||||
break;
|
||||
}
|
||||
|
||||
// Add a single member
|
||||
case PatchOps.Add when
|
||||
!string.IsNullOrWhiteSpace(operation.Path) &&
|
||||
operation.Path.StartsWith("members[value eq ", StringComparison.OrdinalIgnoreCase) &&
|
||||
TryGetOperationPathId(operation.Path, out var addId):
|
||||
{
|
||||
await AddMembersAsync(group, [addId]);
|
||||
break;
|
||||
}
|
||||
|
||||
// Add a list of members
|
||||
case PatchOps.Add when
|
||||
operation.Path?.ToLowerInvariant() == PatchPaths.Members:
|
||||
{
|
||||
await AddMembersAsync(group, GetOperationValueIds(operation.Value));
|
||||
break;
|
||||
}
|
||||
|
||||
// Remove a single member
|
||||
case PatchOps.Remove when
|
||||
!string.IsNullOrWhiteSpace(operation.Path) &&
|
||||
operation.Path.StartsWith("members[value eq ", StringComparison.OrdinalIgnoreCase) &&
|
||||
TryGetOperationPathId(operation.Path, out var removeId):
|
||||
{
|
||||
await _groupService.DeleteUserAsync(group, removeId, EventSystemUser.SCIM);
|
||||
break;
|
||||
}
|
||||
|
||||
// Remove a list of members
|
||||
case PatchOps.Remove when
|
||||
operation.Path?.ToLowerInvariant() == PatchPaths.Members:
|
||||
{
|
||||
var orgUserIds = (await _groupRepository.GetManyUserIdsByIdAsync(group.Id)).ToHashSet();
|
||||
foreach (var v in GetOperationValueIds(operation.Value))
|
||||
{
|
||||
orgUserIds.Remove(v);
|
||||
}
|
||||
await _groupRepository.UpdateUsersAsync(group.Id, orgUserIds);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
{
|
||||
_logger.LogWarning("Group patch operation not handled: {OperationOp}:{OperationPath}", operation.Op, operation.Path);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddMembersAsync(Group group, HashSet<Guid> usersToAdd)
|
||||
{
|
||||
// Azure Entra ID is known to send redundant "add" requests for each existing member every time any member
|
||||
// is removed. To avoid excessive load on the database, we check against the high availability replica and
|
||||
// return early if they already exist.
|
||||
var groupMembers = await _groupRepository.GetManyUserIdsByIdAsync(group.Id, useReadOnlyReplica: true);
|
||||
if (usersToAdd.IsSubsetOf(groupMembers))
|
||||
{
|
||||
_logger.LogDebug("Ignoring duplicate SCIM request to add members {Members} to group {Group}", usersToAdd, group.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
await _groupRepository.AddGroupUsersByIdAsync(group.Id, usersToAdd);
|
||||
}
|
||||
|
||||
private static HashSet<Guid> GetOperationValueIds(JsonElement objArray)
|
||||
{
|
||||
var ids = new HashSet<Guid>();
|
||||
foreach (var obj in objArray.EnumerateArray())
|
||||
{
|
||||
if (obj.TryGetProperty("value", out var valueProperty))
|
||||
{
|
||||
if (valueProperty.TryGetGuid(out var guid))
|
||||
{
|
||||
ids.Add(guid);
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
private static bool TryGetOperationPathId(string path, out Guid pathId)
|
||||
{
|
||||
// Parse Guid from string like: members[value eq "{GUID}"}]
|
||||
return Guid.TryParse(path.Substring(18).Replace("\"]", string.Empty), out pathId);
|
||||
}
|
||||
}
|
@ -1,8 +1,11 @@
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.Models.Business;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Business;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Core.Utilities;
|
||||
using OrganizationUserInvite = Bit.Core.Models.Business.OrganizationUserInvite;
|
||||
|
||||
namespace Bit.Scim.Models;
|
||||
|
||||
@ -10,7 +13,8 @@ public class ScimUserRequestModel : BaseScimUserModel
|
||||
{
|
||||
public ScimUserRequestModel()
|
||||
: base(false)
|
||||
{ }
|
||||
{
|
||||
}
|
||||
|
||||
public OrganizationUserInvite ToOrganizationUserInvite(ScimProviderType scimProvider)
|
||||
{
|
||||
@ -25,6 +29,31 @@ public class ScimUserRequestModel : BaseScimUserModel
|
||||
};
|
||||
}
|
||||
|
||||
public InviteOrganizationUsersRequest ToRequest(
|
||||
ScimProviderType scimProvider,
|
||||
InviteOrganization inviteOrganization,
|
||||
DateTimeOffset performedAt)
|
||||
{
|
||||
var email = EmailForInvite(scimProvider);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(email) || !Active)
|
||||
{
|
||||
throw new BadRequestException();
|
||||
}
|
||||
|
||||
return new InviteOrganizationUsersRequest(
|
||||
invites:
|
||||
[
|
||||
new Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models.OrganizationUserInvite(
|
||||
email: email,
|
||||
externalId: ExternalIdForInvite()
|
||||
)
|
||||
],
|
||||
inviteOrganization: inviteOrganization,
|
||||
performedBy: Guid.Empty, // SCIM does not have a user id
|
||||
performedAt: performedAt);
|
||||
}
|
||||
|
||||
private string EmailForInvite(ScimProviderType scimProvider)
|
||||
{
|
||||
var email = PrimaryEmail?.ToLowerInvariant();
|
||||
|
@ -8,7 +8,7 @@ using Bit.Core.Utilities;
|
||||
using Bit.Scim.Context;
|
||||
using Bit.Scim.Utilities;
|
||||
using Bit.SharedWeb.Utilities;
|
||||
using Duende.IdentityModel;
|
||||
using IdentityModel;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Stripe;
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
@ -11,15 +12,18 @@ public class PatchUserCommand : IPatchUserCommand
|
||||
{
|
||||
private readonly IOrganizationUserRepository _organizationUserRepository;
|
||||
private readonly IOrganizationService _organizationService;
|
||||
private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand;
|
||||
private readonly ILogger<PatchUserCommand> _logger;
|
||||
|
||||
public PatchUserCommand(
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IOrganizationService organizationService,
|
||||
IRestoreOrganizationUserCommand restoreOrganizationUserCommand,
|
||||
ILogger<PatchUserCommand> logger)
|
||||
{
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
_organizationService = organizationService;
|
||||
_restoreOrganizationUserCommand = restoreOrganizationUserCommand;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@ -71,7 +75,7 @@ public class PatchUserCommand : IPatchUserCommand
|
||||
{
|
||||
if (active && orgUser.Status == OrganizationUserStatusType.Revoked)
|
||||
{
|
||||
await _organizationService.RestoreUserAsync(orgUser, EventSystemUser.SCIM);
|
||||
await _restoreOrganizationUserCommand.RestoreUserAsync(orgUser, EventSystemUser.SCIM);
|
||||
return true;
|
||||
}
|
||||
else if (!active && orgUser.Status != OrganizationUserStatusType.Revoked)
|
||||
|
@ -1,4 +1,14 @@
|
||||
using Bit.Core.Enums;
|
||||
#nullable enable
|
||||
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.Models.Business;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||
using Bit.Core.AdminConsole.Utilities.Commands;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||
using Bit.Core.Repositories;
|
||||
@ -6,34 +16,83 @@ using Bit.Core.Services;
|
||||
using Bit.Scim.Context;
|
||||
using Bit.Scim.Models;
|
||||
using Bit.Scim.Users.Interfaces;
|
||||
using static Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors.ErrorMapper;
|
||||
|
||||
namespace Bit.Scim.Users;
|
||||
|
||||
public class PostUserCommand : IPostUserCommand
|
||||
public class PostUserCommand(
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IOrganizationService organizationService,
|
||||
IPaymentService paymentService,
|
||||
IScimContext scimContext,
|
||||
IFeatureService featureService,
|
||||
IInviteOrganizationUsersCommand inviteOrganizationUsersCommand,
|
||||
TimeProvider timeProvider,
|
||||
IPricingClient pricingClient)
|
||||
: IPostUserCommand
|
||||
{
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly IOrganizationUserRepository _organizationUserRepository;
|
||||
private readonly IOrganizationService _organizationService;
|
||||
private readonly IPaymentService _paymentService;
|
||||
private readonly IScimContext _scimContext;
|
||||
|
||||
public PostUserCommand(
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IOrganizationService organizationService,
|
||||
IPaymentService paymentService,
|
||||
IScimContext scimContext)
|
||||
public async Task<OrganizationUserUserDetails?> PostUserAsync(Guid organizationId, ScimUserRequestModel model)
|
||||
{
|
||||
_organizationRepository = organizationRepository;
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
_organizationService = organizationService;
|
||||
_paymentService = paymentService;
|
||||
_scimContext = scimContext;
|
||||
if (featureService.IsEnabled(FeatureFlagKeys.ScimInviteUserOptimization) is false)
|
||||
{
|
||||
return await InviteScimOrganizationUserAsync(model, organizationId, scimContext.RequestScimProvider);
|
||||
}
|
||||
|
||||
return await InviteScimOrganizationUserAsync_vNext(model, organizationId, scimContext.RequestScimProvider);
|
||||
}
|
||||
|
||||
public async Task<OrganizationUserUserDetails> PostUserAsync(Guid organizationId, ScimUserRequestModel model)
|
||||
private async Task<OrganizationUserUserDetails?> InviteScimOrganizationUserAsync_vNext(
|
||||
ScimUserRequestModel model,
|
||||
Guid organizationId,
|
||||
ScimProviderType scimProvider)
|
||||
{
|
||||
var organization = await organizationRepository.GetByIdAsync(organizationId);
|
||||
|
||||
if (organization is null)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var plan = await pricingClient.GetPlanOrThrow(organization.PlanType);
|
||||
|
||||
var request = model.ToRequest(
|
||||
scimProvider: scimProvider,
|
||||
inviteOrganization: new InviteOrganization(organization, plan),
|
||||
performedAt: timeProvider.GetUtcNow());
|
||||
|
||||
var orgUsers = await organizationUserRepository
|
||||
.GetManyDetailsByOrganizationAsync(request.InviteOrganization.OrganizationId);
|
||||
|
||||
if (orgUsers.Any(existingUser =>
|
||||
request.Invites.First().Email.Equals(existingUser.Email, StringComparison.OrdinalIgnoreCase) ||
|
||||
request.Invites.First().ExternalId.Equals(existingUser.ExternalId, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
throw new ConflictException("User already exists.");
|
||||
}
|
||||
|
||||
var result = await inviteOrganizationUsersCommand.InviteScimOrganizationUserAsync(request);
|
||||
|
||||
var invitedOrganizationUserId = result switch
|
||||
{
|
||||
Success<ScimInviteOrganizationUsersResponse> success => success.Value.InvitedUser.Id,
|
||||
Failure<ScimInviteOrganizationUsersResponse> { Error.Message: NoUsersToInviteError.Code } => (Guid?)null,
|
||||
Failure<ScimInviteOrganizationUsersResponse> failure => throw MapToBitException(failure.Error),
|
||||
_ => throw new InvalidOperationException()
|
||||
};
|
||||
|
||||
var organizationUser = invitedOrganizationUserId.HasValue
|
||||
? await organizationUserRepository.GetDetailsByIdAsync(invitedOrganizationUserId.Value)
|
||||
: null;
|
||||
|
||||
return organizationUser;
|
||||
}
|
||||
|
||||
private async Task<OrganizationUserUserDetails?> InviteScimOrganizationUserAsync(
|
||||
ScimUserRequestModel model,
|
||||
Guid organizationId,
|
||||
ScimProviderType scimProvider)
|
||||
{
|
||||
var scimProvider = _scimContext.RequestScimProvider;
|
||||
var invite = model.ToOrganizationUserInvite(scimProvider);
|
||||
|
||||
var email = invite.Emails.Single();
|
||||
@ -44,7 +103,7 @@ public class PostUserCommand : IPostUserCommand
|
||||
throw new BadRequestException();
|
||||
}
|
||||
|
||||
var orgUsers = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId);
|
||||
var orgUsers = await organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId);
|
||||
var orgUserByEmail = orgUsers.FirstOrDefault(ou => ou.Email?.ToLowerInvariant() == email);
|
||||
if (orgUserByEmail != null)
|
||||
{
|
||||
@ -57,13 +116,21 @@ public class PostUserCommand : IPostUserCommand
|
||||
throw new ConflictException();
|
||||
}
|
||||
|
||||
var organization = await _organizationRepository.GetByIdAsync(organizationId);
|
||||
var hasStandaloneSecretsManager = await _paymentService.HasSecretsManagerStandalone(organization);
|
||||
var organization = await organizationRepository.GetByIdAsync(organizationId);
|
||||
|
||||
if (organization == null)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var hasStandaloneSecretsManager = await paymentService.HasSecretsManagerStandalone(organization);
|
||||
invite.AccessSecretsManager = hasStandaloneSecretsManager;
|
||||
|
||||
var invitedOrgUser = await _organizationService.InviteUserAsync(organizationId, invitingUserId: null, EventSystemUser.SCIM,
|
||||
invite, externalId);
|
||||
var orgUser = await _organizationUserRepository.GetDetailsByIdAsync(invitedOrgUser.Id);
|
||||
var invitedOrgUser = await organizationService.InviteUserAsync(organizationId, invitingUserId: null,
|
||||
EventSystemUser.SCIM,
|
||||
invite,
|
||||
externalId);
|
||||
var orgUser = await organizationUserRepository.GetDetailsByIdAsync(invitedOrgUser.Id);
|
||||
|
||||
return orgUser;
|
||||
}
|
||||
|
@ -3,7 +3,7 @@ using System.Text.Encodings.Web;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Scim.Context;
|
||||
using Duende.IdentityModel;
|
||||
using IdentityModel;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
@ -10,7 +10,6 @@ public static class ScimServiceCollectionExtensions
|
||||
public static void AddScimGroupCommands(this IServiceCollection services)
|
||||
{
|
||||
services.AddScoped<IPatchGroupCommand, PatchGroupCommand>();
|
||||
services.AddScoped<IPatchGroupCommandvNext, PatchGroupCommandvNext>();
|
||||
services.AddScoped<IPostGroupCommand, PostGroupCommand>();
|
||||
services.AddScoped<IPutGroupCommand, PutGroupCommand>();
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Setup
|
||||
|
||||
@ -19,31 +19,42 @@ then
|
||||
LGID=65534
|
||||
fi
|
||||
|
||||
# Create user and group
|
||||
if [ "$(id -u)" = "0" ]
|
||||
then
|
||||
# Create user and group
|
||||
|
||||
groupadd -o -g $LGID $GROUPNAME >/dev/null 2>&1 ||
|
||||
groupmod -o -g $LGID $GROUPNAME >/dev/null 2>&1
|
||||
useradd -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 ||
|
||||
usermod -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1
|
||||
mkhomedir_helper $USERNAME
|
||||
groupadd -o -g $LGID $GROUPNAME >/dev/null 2>&1 ||
|
||||
groupmod -o -g $LGID $GROUPNAME >/dev/null 2>&1
|
||||
useradd -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 ||
|
||||
usermod -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1
|
||||
mkhomedir_helper $USERNAME
|
||||
|
||||
# The rest...
|
||||
# The rest...
|
||||
|
||||
chown -R $USERNAME:$GROUPNAME /app
|
||||
mkdir -p /etc/bitwarden/core
|
||||
mkdir -p /etc/bitwarden/logs
|
||||
mkdir -p /etc/bitwarden/ca-certificates
|
||||
chown -R $USERNAME:$GROUPNAME /etc/bitwarden
|
||||
chown -R $USERNAME:$GROUPNAME /app
|
||||
mkdir -p /etc/bitwarden/core
|
||||
mkdir -p /etc/bitwarden/logs
|
||||
mkdir -p /etc/bitwarden/ca-certificates
|
||||
chown -R $USERNAME:$GROUPNAME /etc/bitwarden
|
||||
|
||||
if [[ $globalSettings__selfHosted == "true" ]]; then
|
||||
cp /etc/bitwarden/ca-certificates/*.crt /usr/local/share/ca-certificates/ >/dev/null 2>&1 \
|
||||
&& update-ca-certificates
|
||||
if [[ -f "/etc/bitwarden/kerberos/bitwarden.keytab" && -f "/etc/bitwarden/kerberos/krb5.conf" ]]; then
|
||||
chown -R $USERNAME:$GROUPNAME /etc/bitwarden/kerberos
|
||||
fi
|
||||
|
||||
gosu_cmd="gosu $USERNAME:$GROUPNAME"
|
||||
else
|
||||
gosu_cmd=""
|
||||
fi
|
||||
|
||||
if [[ -f "/etc/bitwarden/kerberos/bitwarden.keytab" && -f "/etc/bitwarden/kerberos/krb5.conf" ]]; then
|
||||
chown -R $USERNAME:$GROUPNAME /etc/bitwarden/kerberos
|
||||
cp -f /etc/bitwarden/kerberos/krb5.conf /etc/krb5.conf
|
||||
gosu $USERNAME:$GROUPNAME kinit $globalSettings__kerberosUser -k -t /etc/bitwarden/kerberos/bitwarden.keytab
|
||||
cp -f /etc/bitwarden/kerberos/krb5.conf /etc/krb5.conf
|
||||
$gosu_cmd kinit $globalSettings__kerberosUser -k -t /etc/bitwarden/kerberos/bitwarden.keytab
|
||||
fi
|
||||
|
||||
exec gosu $USERNAME:$GROUPNAME dotnet /app/Scim.dll
|
||||
if [[ $globalSettings__selfHosted == "true" ]]; then
|
||||
if [[ -z $globalSettings__identityServer__certificateLocation ]]; then
|
||||
export globalSettings__identityServer__certificateLocation=/etc/bitwarden/identity/identity.pfx
|
||||
fi
|
||||
fi
|
||||
|
||||
exec $gosu_cmd /app/Scim
|
||||
|
@ -19,10 +19,10 @@ using Bit.Core.Tokens;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Sso.Models;
|
||||
using Bit.Sso.Utilities;
|
||||
using Duende.IdentityModel;
|
||||
using Duende.IdentityServer;
|
||||
using Duende.IdentityServer.Services;
|
||||
using Duende.IdentityServer.Stores;
|
||||
using IdentityModel;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
@ -1,6 +1,50 @@
|
||||
###############################################
|
||||
# Build stage #
|
||||
###############################################
|
||||
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
||||
|
||||
# Docker buildx supplies the value for this arg
|
||||
ARG TARGETPLATFORM
|
||||
|
||||
# Determine proper runtime value for .NET
|
||||
# We put the value in a file to be read by later layers.
|
||||
RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \
|
||||
RID=linux-x64 ; \
|
||||
elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \
|
||||
RID=linux-arm64 ; \
|
||||
elif [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then \
|
||||
RID=linux-arm ; \
|
||||
fi \
|
||||
&& echo "RID=$RID" > /tmp/rid.txt
|
||||
|
||||
# Copy required project files
|
||||
WORKDIR /source
|
||||
COPY . ./
|
||||
|
||||
# Restore project dependencies and tools
|
||||
WORKDIR /source/bitwarden_license/src/Sso
|
||||
RUN . /tmp/rid.txt && dotnet restore -r $RID
|
||||
|
||||
# Build project
|
||||
RUN . /tmp/rid.txt && dotnet publish \
|
||||
-c release \
|
||||
--no-restore \
|
||||
--self-contained \
|
||||
/p:PublishSingleFile=true \
|
||||
-r $RID \
|
||||
-o out
|
||||
|
||||
###############################################
|
||||
# App stage #
|
||||
###############################################
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:8.0
|
||||
|
||||
ARG TARGETPLATFORM
|
||||
LABEL com.bitwarden.product="bitwarden"
|
||||
ENV ASPNETCORE_ENVIRONMENT=Production
|
||||
ENV ASPNETCORE_URLS=http://+:5000
|
||||
ENV SSL_CERT_DIR=/etc/bitwarden/ca-certificates
|
||||
EXPOSE 5000
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
@ -9,11 +53,10 @@ RUN apt-get update \
|
||||
krb5-user \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV ASPNETCORE_URLS http://+:5000
|
||||
# Copy app from the build stage
|
||||
WORKDIR /app
|
||||
EXPOSE 5000
|
||||
COPY obj/build-output/publish .
|
||||
COPY entrypoint.sh /
|
||||
COPY --from=build /source/bitwarden_license/src/Sso/out /app
|
||||
COPY ./bitwarden_license/src/Sso/entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
HEALTHCHECK CMD curl -f http://localhost:5000/alive || exit 1
|
||||
|
@ -7,9 +7,9 @@ using Bit.Core.Settings;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Sso.Models;
|
||||
using Bit.Sso.Utilities;
|
||||
using Duende.IdentityModel;
|
||||
using Duende.IdentityServer;
|
||||
using Duende.IdentityServer.Infrastructure;
|
||||
using IdentityModel;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Setup
|
||||
|
||||
@ -19,37 +19,42 @@ then
|
||||
LGID=65534
|
||||
fi
|
||||
|
||||
# Create user and group
|
||||
if [ "$(id -u)" = "0" ]
|
||||
then
|
||||
# Create user and group
|
||||
|
||||
groupadd -o -g $LGID $GROUPNAME >/dev/null 2>&1 ||
|
||||
groupmod -o -g $LGID $GROUPNAME >/dev/null 2>&1
|
||||
useradd -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 ||
|
||||
usermod -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1
|
||||
mkhomedir_helper $USERNAME
|
||||
groupadd -o -g $LGID $GROUPNAME >/dev/null 2>&1 ||
|
||||
groupmod -o -g $LGID $GROUPNAME >/dev/null 2>&1
|
||||
useradd -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 ||
|
||||
usermod -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1
|
||||
mkhomedir_helper $USERNAME
|
||||
|
||||
# The rest...
|
||||
# The rest...
|
||||
|
||||
mkdir -p /etc/bitwarden/identity
|
||||
mkdir -p /etc/bitwarden/core
|
||||
mkdir -p /etc/bitwarden/logs
|
||||
mkdir -p /etc/bitwarden/ca-certificates
|
||||
chown -R $USERNAME:$GROUPNAME /etc/bitwarden
|
||||
chown -R $USERNAME:$GROUPNAME /app
|
||||
mkdir -p /etc/bitwarden/core
|
||||
mkdir -p /etc/bitwarden/logs
|
||||
mkdir -p /etc/bitwarden/ca-certificates
|
||||
chown -R $USERNAME:$GROUPNAME /etc/bitwarden
|
||||
|
||||
if [[ $globalSettings__selfHosted == "true" ]]; then
|
||||
cp /etc/bitwarden/identity/identity.pfx /app/identity.pfx
|
||||
fi
|
||||
if [[ -f "/etc/bitwarden/kerberos/bitwarden.keytab" && -f "/etc/bitwarden/kerberos/krb5.conf" ]]; then
|
||||
chown -R $USERNAME:$GROUPNAME /etc/bitwarden/kerberos
|
||||
fi
|
||||
|
||||
chown -R $USERNAME:$GROUPNAME /app
|
||||
|
||||
if [[ $globalSettings__selfHosted == "true" ]]; then
|
||||
cp /etc/bitwarden/ca-certificates/*.crt /usr/local/share/ca-certificates/ >/dev/null 2>&1 \
|
||||
&& update-ca-certificates
|
||||
gosu_cmd="gosu $USERNAME:$GROUPNAME"
|
||||
else
|
||||
gosu_cmd=""
|
||||
fi
|
||||
|
||||
if [[ -f "/etc/bitwarden/kerberos/bitwarden.keytab" && -f "/etc/bitwarden/kerberos/krb5.conf" ]]; then
|
||||
chown -R $USERNAME:$GROUPNAME /etc/bitwarden/kerberos
|
||||
cp -f /etc/bitwarden/kerberos/krb5.conf /etc/krb5.conf
|
||||
gosu $USERNAME:$GROUPNAME kinit $globalSettings__kerberosUser -k -t /etc/bitwarden/kerberos/bitwarden.keytab
|
||||
cp -f /etc/bitwarden/kerberos/krb5.conf /etc/krb5.conf
|
||||
$gosu_cmd kinit $globalSettings__kerberosUser -k -t /etc/bitwarden/kerberos/bitwarden.keytab
|
||||
fi
|
||||
|
||||
exec gosu $USERNAME:$GROUPNAME dotnet /app/Sso.dll
|
||||
if [[ $globalSettings__selfHosted == "true" ]]; then
|
||||
if [[ -z $globalSettings__identityServer__certificateLocation ]]; then
|
||||
export globalSettings__identityServer__certificateLocation=/etc/bitwarden/identity/identity.pfx
|
||||
fi
|
||||
fi
|
||||
|
||||
exec $gosu_cmd /app/Sso
|
||||
|
237
bitwarden_license/src/Sso/package-lock.json
generated
237
bitwarden_license/src/Sso/package-lock.json
generated
@ -9,17 +9,17 @@
|
||||
"version": "0.0.0",
|
||||
"license": "-",
|
||||
"dependencies": {
|
||||
"bootstrap": "5.3.3",
|
||||
"bootstrap": "5.3.6",
|
||||
"font-awesome": "4.7.0",
|
||||
"jquery": "3.7.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"css-loader": "7.1.2",
|
||||
"expose-loader": "5.0.0",
|
||||
"expose-loader": "5.0.1",
|
||||
"mini-css-extract-plugin": "2.9.2",
|
||||
"sass": "1.85.0",
|
||||
"sass-loader": "16.0.4",
|
||||
"webpack": "5.97.1",
|
||||
"sass": "1.88.0",
|
||||
"sass-loader": "16.0.5",
|
||||
"webpack": "5.99.8",
|
||||
"webpack-cli": "5.1.4"
|
||||
}
|
||||
},
|
||||
@ -441,9 +441,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
|
||||
"integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==",
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
|
||||
"integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@ -455,13 +455,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.13.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.5.tgz",
|
||||
"integrity": "sha512-+lTU0PxZXn0Dr1NBtC7Y8cR21AJr87dLLU953CWA6pMxxv/UDc7jYAY90upcrie1nRcD6XNG5HOYEDtgW5TxAg==",
|
||||
"version": "22.15.21",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.21.tgz",
|
||||
"integrity": "sha512-EV/37Td6c+MgKAbkcLG6vqZ2zEYHD7bvSrzqqs2RIhbA6w3x+Dqz8MZM3sP6kGTeLrdoOgKZe+Xja7tUB2DNkQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.20.0"
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@webassemblyjs/ast": {
|
||||
@ -687,9 +687,9 @@
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.14.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz",
|
||||
"integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==",
|
||||
"version": "8.14.1",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
|
||||
"integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
@ -748,9 +748,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/bootstrap": {
|
||||
"version": "5.3.3",
|
||||
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.3.tgz",
|
||||
"integrity": "sha512-8HLCdWgyoMguSO9o+aH+iuZ+aht+mzW0u3HIMzVu7Srrpv7EBBxTnrFlSCskwdY1+EOFQSm7uMJhNQHkdPcmjg==",
|
||||
"version": "5.3.6",
|
||||
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.6.tgz",
|
||||
"integrity": "sha512-jX0GAcRzvdwISuvArXn3m7KZscWWFAf1MKBcnzaN02qWMb3jpMoUX4/qgeiGzqyIb4ojulRzs89UCUmGcFSzTA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@ -781,9 +781,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.24.4",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz",
|
||||
"integrity": "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==",
|
||||
"version": "4.24.5",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.5.tgz",
|
||||
"integrity": "sha512-FDToo4Wo82hIdgc1CQ+NQD0hEhmpPjrZ3hiUgwgOG6IuTdlpr8jdjyG24P6cNP1yJpTLzS5OcGgSw0xmDU1/Tw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@ -801,10 +801,10 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"caniuse-lite": "^1.0.30001688",
|
||||
"electron-to-chromium": "^1.5.73",
|
||||
"caniuse-lite": "^1.0.30001716",
|
||||
"electron-to-chromium": "^1.5.149",
|
||||
"node-releases": "^2.0.19",
|
||||
"update-browserslist-db": "^1.1.1"
|
||||
"update-browserslist-db": "^1.1.3"
|
||||
},
|
||||
"bin": {
|
||||
"browserslist": "cli.js"
|
||||
@ -821,9 +821,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001700",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001700.tgz",
|
||||
"integrity": "sha512-2S6XIXwaE7K7erT8dY+kLQcpa5ms63XlRkMkReXjle+kf6c5g38vyMl+Z5y8dSxOFDhcFe+nxnn261PLxBSQsQ==",
|
||||
"version": "1.0.30001718",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001718.tgz",
|
||||
"integrity": "sha512-AflseV1ahcSunK53NfEs9gFWgOEmzr0f+kaMFA4xiLZlr9Hzt7HxcSpIFcnNCUkz6R6dWKa54rUz3HUmI3nVcw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@ -975,9 +975,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.103",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.103.tgz",
|
||||
"integrity": "sha512-P6+XzIkfndgsrjROJWfSvVEgNHtPgbhVyTkwLjUM2HU/h7pZRORgaTlHqfAikqxKmdJMLW8fftrdGWbd/Ds0FA==",
|
||||
"version": "1.5.155",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.155.tgz",
|
||||
"integrity": "sha512-ps5KcGGmwL8VaeJlvlDlu4fORQpv3+GIcF5I3f9tUKUlJ/wsysh6HU8P5L1XWRYeXfA0oJd4PyM8ds8zTFf6Ng==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
@ -1009,9 +1009,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.6.0.tgz",
|
||||
"integrity": "sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==",
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
|
||||
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@ -1083,9 +1083,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/expose-loader": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/expose-loader/-/expose-loader-5.0.0.tgz",
|
||||
"integrity": "sha512-BtUqYRmvx1bEY5HN6eK2I9URUZgNmN0x5UANuocaNjXSgfoDlkXt+wyEMe7i5DzDNh2BKJHPc5F4rBwEdSQX6w==",
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/expose-loader/-/expose-loader-5.0.1.tgz",
|
||||
"integrity": "sha512-5YPZuszN/eWND/B+xuq5nIpb/l5TV1HYmdO6SubYtHv+HenVw9/6bn33Mm5reY8DNid7AVtbARvyUD34edfCtg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@ -1106,13 +1106,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-json-stable-stringify": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
||||
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz",
|
||||
@ -1248,9 +1241,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/immutable": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.0.3.tgz",
|
||||
"integrity": "sha512-P8IdPQHq3lA1xVeBRi5VPqUm5HDgKnx0Ru51wZz5mjxHr5n3RWhjIpOFU7ybkUxfB+5IToy+OLaHYDBIWsv+uw==",
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.2.tgz",
|
||||
"integrity": "sha512-qHKXW1q6liAk1Oys6umoaZbDRqjcjgSrbnrifHsfsttza7zcvRAsL7mMV6xWcyhwQy7Xj5v4hhbr6b+iDYwlmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@ -1501,9 +1494,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.8",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz",
|
||||
"integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==",
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@ -1754,16 +1747,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/randombytes": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
|
||||
@ -1877,9 +1860,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sass": {
|
||||
"version": "1.85.0",
|
||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.85.0.tgz",
|
||||
"integrity": "sha512-3ToiC1xZ1Y8aU7+CkgCI/tqyuPXEmYGJXO7H4uqp0xkLXUqp88rQQ4j1HmP37xSJLbCJPaIiv+cT1y+grssrww==",
|
||||
"version": "1.88.0",
|
||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.88.0.tgz",
|
||||
"integrity": "sha512-sF6TWQqjFvr4JILXzG4ucGOLELkESHL+I5QJhh7CNaE+Yge0SI+ehCatsXhJ7ymU1hAFcIS3/PBpjdIbXoyVbg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@ -1898,9 +1881,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/sass-loader": {
|
||||
"version": "16.0.4",
|
||||
"resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-16.0.4.tgz",
|
||||
"integrity": "sha512-LavLbgbBGUt3wCiYzhuLLu65+fWXaXLmq7YxivLhEqmiupCFZ5sKUAipK3do6V80YSU0jvSxNhEdT13IXNr3rg==",
|
||||
"version": "16.0.5",
|
||||
"resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-16.0.5.tgz",
|
||||
"integrity": "sha512-oL+CMBXrj6BZ/zOq4os+UECPL+bWqt6OAC6DWS8Ln8GZRcMDjlJ4JC3FBDuHJdYaFWIdKNIBYmtZtK2MaMkNIw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@ -1939,9 +1922,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/schema-utils": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.0.tgz",
|
||||
"integrity": "sha512-Gf9qqc58SpCA/xdziiHz35F4GNIWYWZrEshUc/G/r5BnLph6xpKuLeoJoQuj5WfBIx/eQLf+hmVPYHaxJu7V2g==",
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz",
|
||||
"integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@ -1959,9 +1942,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
|
||||
"integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
|
||||
"version": "7.7.2",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
|
||||
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
@ -2078,9 +2061,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz",
|
||||
"integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==",
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.2.tgz",
|
||||
"integrity": "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@ -2088,14 +2071,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/terser": {
|
||||
"version": "5.39.0",
|
||||
"resolved": "https://registry.npmjs.org/terser/-/terser-5.39.0.tgz",
|
||||
"integrity": "sha512-LBAhFyLho16harJoWMg/nZsQYgTrg5jXOn2nCYjRUcZZEdE3qa2zb8QEDRUGVZBW4rlazf2fxkg8tztybTaqWw==",
|
||||
"version": "5.39.2",
|
||||
"resolved": "https://registry.npmjs.org/terser/-/terser-5.39.2.tgz",
|
||||
"integrity": "sha512-yEPUmWve+VA78bI71BW70Dh0TuV4HHd+I5SHOAfS1+QBOmvmCiiffgjR8ryyEd3KIfvPGFqoADt8LdQ6XpXIvg==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"@jridgewell/source-map": "^0.3.3",
|
||||
"acorn": "^8.8.2",
|
||||
"acorn": "^8.14.0",
|
||||
"commander": "^2.20.0",
|
||||
"source-map-support": "~0.5.20"
|
||||
},
|
||||
@ -2107,9 +2090,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/terser-webpack-plugin": {
|
||||
"version": "5.3.11",
|
||||
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.11.tgz",
|
||||
"integrity": "sha512-RVCsMfuD0+cTt3EwX8hSl2Ks56EbFHWmhluwcqoPKtBnfjiT6olaq7PRIRfhyU8nnC2MrnDrBLfrD/RGE+cVXQ==",
|
||||
"version": "5.3.14",
|
||||
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz",
|
||||
"integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@ -2156,16 +2139,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.20.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
|
||||
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.2.tgz",
|
||||
"integrity": "sha512-PPypAm5qvlD7XMZC3BujecnaOxwhrtoFR+Dqkk5Aa/6DssiH0ibKoketaj9w8LP7Bont1rYeoV5plxD7RTEPRg==",
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
|
||||
"integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@ -2193,16 +2176,6 @@
|
||||
"browserslist": ">= 4.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uri-js": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
||||
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
@ -2211,9 +2184,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/watchpack": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz",
|
||||
"integrity": "sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw==",
|
||||
"version": "2.4.4",
|
||||
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz",
|
||||
"integrity": "sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@ -2225,14 +2198,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/webpack": {
|
||||
"version": "5.97.1",
|
||||
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.97.1.tgz",
|
||||
"integrity": "sha512-EksG6gFY3L1eFMROS/7Wzgrii5mBAFe4rIr3r2BTfo7bcc+DWwFZ4OJ/miOuHJO/A85HwyI4eQ0F6IKXesO7Fg==",
|
||||
"version": "5.99.8",
|
||||
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.99.8.tgz",
|
||||
"integrity": "sha512-lQ3CPiSTpfOnrEGeXDwoq5hIGzSjmwD72GdfVzF7CQAI7t47rJG9eDWvcEkEn3CUQymAElVvDg3YNTlCYj+qUQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/eslint-scope": "^3.7.7",
|
||||
"@types/estree": "^1.0.6",
|
||||
"@types/json-schema": "^7.0.15",
|
||||
"@webassemblyjs/ast": "^1.14.1",
|
||||
"@webassemblyjs/wasm-edit": "^1.14.1",
|
||||
"@webassemblyjs/wasm-parser": "^1.14.1",
|
||||
@ -2249,9 +2223,9 @@
|
||||
"loader-runner": "^4.2.0",
|
||||
"mime-types": "^2.1.27",
|
||||
"neo-async": "^2.6.2",
|
||||
"schema-utils": "^3.2.0",
|
||||
"schema-utils": "^4.3.2",
|
||||
"tapable": "^2.1.1",
|
||||
"terser-webpack-plugin": "^5.3.10",
|
||||
"terser-webpack-plugin": "^5.3.11",
|
||||
"watchpack": "^2.4.1",
|
||||
"webpack-sources": "^3.2.3"
|
||||
},
|
||||
@ -2352,59 +2326,6 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/webpack/node_modules/ajv": {
|
||||
"version": "6.12.6",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
"json-schema-traverse": "^0.4.1",
|
||||
"uri-js": "^4.2.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/webpack/node_modules/ajv-keywords": {
|
||||
"version": "3.5.2",
|
||||
"resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz",
|
||||
"integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"ajv": "^6.9.1"
|
||||
}
|
||||
},
|
||||
"node_modules/webpack/node_modules/json-schema-traverse": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/webpack/node_modules/schema-utils": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz",
|
||||
"integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/json-schema": "^7.0.8",
|
||||
"ajv": "^6.12.5",
|
||||
"ajv-keywords": "^3.5.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.13.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
@ -8,17 +8,17 @@
|
||||
"build": "webpack"
|
||||
},
|
||||
"dependencies": {
|
||||
"bootstrap": "5.3.3",
|
||||
"bootstrap": "5.3.6",
|
||||
"font-awesome": "4.7.0",
|
||||
"jquery": "3.7.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"css-loader": "7.1.2",
|
||||
"expose-loader": "5.0.0",
|
||||
"expose-loader": "5.0.1",
|
||||
"mini-css-extract-plugin": "2.9.2",
|
||||
"sass": "1.85.0",
|
||||
"sass-loader": "16.0.4",
|
||||
"webpack": "5.97.1",
|
||||
"sass": "1.88.0",
|
||||
"sass-loader": "16.0.5",
|
||||
"webpack": "5.99.8",
|
||||
"webpack-cli": "5.1.4"
|
||||
}
|
||||
}
|
||||
|
@ -63,7 +63,7 @@ public class CreateProviderCommandTests
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task CreateMultiOrganizationEnterpriseAsync_Success(
|
||||
public async Task CreateBusinessUnitAsync_Success(
|
||||
Provider provider,
|
||||
User user,
|
||||
PlanType plan,
|
||||
@ -71,13 +71,13 @@ public class CreateProviderCommandTests
|
||||
SutProvider<CreateProviderCommand> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
provider.Type = ProviderType.MultiOrganizationEnterprise;
|
||||
provider.Type = ProviderType.BusinessUnit;
|
||||
|
||||
var userRepository = sutProvider.GetDependency<IUserRepository>();
|
||||
userRepository.GetByEmailAsync(user.Email).Returns(user);
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.CreateMultiOrganizationEnterpriseAsync(provider, user.Email, plan, minimumSeats);
|
||||
await sutProvider.Sut.CreateBusinessUnitAsync(provider, user.Email, plan, minimumSeats);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<IProviderRepository>().ReceivedWithAnyArgs().CreateAsync(provider);
|
||||
@ -85,7 +85,7 @@ public class CreateProviderCommandTests
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task CreateMultiOrganizationEnterpriseAsync_UserIdIsInvalid_Throws(
|
||||
public async Task CreateBusinessUnitAsync_UserIdIsInvalid_Throws(
|
||||
Provider provider,
|
||||
SutProvider<CreateProviderCommand> sutProvider)
|
||||
{
|
||||
@ -94,7 +94,7 @@ public class CreateProviderCommandTests
|
||||
|
||||
// Act
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
() => sutProvider.Sut.CreateMultiOrganizationEnterpriseAsync(provider, default, default, default));
|
||||
() => sutProvider.Sut.CreateBusinessUnitAsync(provider, default, default, default));
|
||||
|
||||
// Assert
|
||||
Assert.Contains("Invalid owner.", exception.Message);
|
||||
|
@ -1,4 +1,5 @@
|
||||
using Bit.Commercial.Core.AdminConsole.Providers;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
@ -7,6 +8,7 @@ using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Providers.Services;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
@ -223,6 +225,19 @@ public class RemoveOrganizationFromProviderCommandTests
|
||||
|
||||
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
|
||||
{
|
||||
Id = "subscription_id"
|
||||
@ -264,6 +279,97 @@ public class RemoveOrganizationFromProviderCommandTests
|
||||
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
|
||||
{
|
||||
Id = "customer_id",
|
||||
Address = new Address
|
||||
{
|
||||
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 stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(options =>
|
||||
options.Customer == organization.GatewayCustomerId &&
|
||||
options.CollectionMethod == StripeConstants.CollectionMethod.SendInvoice &&
|
||||
options.DaysUntilDue == 30 &&
|
||||
options.AutomaticTax.Enabled == true &&
|
||||
options.Metadata["organizationId"] == organization.Id.ToString() &&
|
||||
options.OffSession == true &&
|
||||
options.ProrationBehavior == StripeConstants.ProrationBehavior.CreateProrations &&
|
||||
options.Items.First().Price == teamsMonthlyPlan.PasswordManager.StripeSeatPlanId &&
|
||||
options.Items.First().Quantity == organization.Seats));
|
||||
|
||||
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"));
|
||||
}
|
||||
|
||||
private static Subscription GetSubscription(string subscriptionId) =>
|
||||
new()
|
||||
{
|
||||
|
@ -1,14 +1,17 @@
|
||||
using Bit.Commercial.Core.AdminConsole.Services;
|
||||
using Bit.Commercial.Core.Test.AdminConsole.AutoFixture;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.Models.Business.Provider;
|
||||
using Bit.Core.AdminConsole.Models.Business.Tokenables;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Billing.Providers.Services;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
@ -38,7 +41,7 @@ public class ProviderServiceTests
|
||||
public async Task CompleteSetupAsync_UserIdIsInvalid_Throws(SutProvider<ProviderService> sutProvider)
|
||||
{
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
() => sutProvider.Sut.CompleteSetupAsync(default, default, default, default));
|
||||
() => sutProvider.Sut.CompleteSetupAsync(default, default, default, default, null));
|
||||
Assert.Contains("Invalid owner.", exception.Message);
|
||||
}
|
||||
|
||||
@ -50,12 +53,85 @@ public class ProviderServiceTests
|
||||
userService.GetUserByIdAsync(user.Id).Returns(user);
|
||||
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
() => sutProvider.Sut.CompleteSetupAsync(provider, user.Id, default, default));
|
||||
() => sutProvider.Sut.CompleteSetupAsync(provider, user.Id, default, default, null));
|
||||
Assert.Contains("Invalid token.", exception.Message);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task CompleteSetupAsync_Success(User user, Provider provider, string key, TaxInfo taxInfo,
|
||||
public async Task CompleteSetupAsync_InvalidTaxInfo_ThrowsBadRequestException(
|
||||
User user,
|
||||
Provider provider,
|
||||
string key,
|
||||
TaxInfo taxInfo,
|
||||
TokenizedPaymentSource tokenizedPaymentSource,
|
||||
[ProviderUser] ProviderUser providerUser,
|
||||
SutProvider<ProviderService> sutProvider)
|
||||
{
|
||||
providerUser.ProviderId = provider.Id;
|
||||
providerUser.UserId = user.Id;
|
||||
var userService = sutProvider.GetDependency<IUserService>();
|
||||
userService.GetUserByIdAsync(user.Id).Returns(user);
|
||||
|
||||
var providerUserRepository = sutProvider.GetDependency<IProviderUserRepository>();
|
||||
providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id).Returns(providerUser);
|
||||
|
||||
var dataProtectionProvider = DataProtectionProvider.Create("ApplicationName");
|
||||
var protector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector");
|
||||
sutProvider.GetDependency<IDataProtectionProvider>().CreateProtector("ProviderServiceDataProtector")
|
||||
.Returns(protector);
|
||||
|
||||
sutProvider.Create();
|
||||
|
||||
var token = protector.Protect($"ProviderSetupInvite {provider.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}");
|
||||
|
||||
taxInfo.BillingAddressCountry = null;
|
||||
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
|
||||
sutProvider.Sut.CompleteSetupAsync(provider, user.Id, token, key, taxInfo, tokenizedPaymentSource));
|
||||
|
||||
Assert.Equal("Both address and postal code are required to set up your provider.", exception.Message);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task CompleteSetupAsync_InvalidTokenizedPaymentSource_ThrowsBadRequestException(
|
||||
User user,
|
||||
Provider provider,
|
||||
string key,
|
||||
TaxInfo taxInfo,
|
||||
TokenizedPaymentSource tokenizedPaymentSource,
|
||||
[ProviderUser] ProviderUser providerUser,
|
||||
SutProvider<ProviderService> sutProvider)
|
||||
{
|
||||
providerUser.ProviderId = provider.Id;
|
||||
providerUser.UserId = user.Id;
|
||||
var userService = sutProvider.GetDependency<IUserService>();
|
||||
userService.GetUserByIdAsync(user.Id).Returns(user);
|
||||
|
||||
var providerUserRepository = sutProvider.GetDependency<IProviderUserRepository>();
|
||||
providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id).Returns(providerUser);
|
||||
|
||||
var dataProtectionProvider = DataProtectionProvider.Create("ApplicationName");
|
||||
var protector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector");
|
||||
sutProvider.GetDependency<IDataProtectionProvider>().CreateProtector("ProviderServiceDataProtector")
|
||||
.Returns(protector);
|
||||
|
||||
sutProvider.Create();
|
||||
|
||||
var token = protector.Protect($"ProviderSetupInvite {provider.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}");
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
|
||||
|
||||
tokenizedPaymentSource = tokenizedPaymentSource with { Type = PaymentMethodType.BitPay };
|
||||
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
|
||||
sutProvider.Sut.CompleteSetupAsync(provider, user.Id, token, key, taxInfo, tokenizedPaymentSource));
|
||||
|
||||
Assert.Equal("A payment method is required to set up your provider.", exception.Message);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task CompleteSetupAsync_Success(User user, Provider provider, string key, TaxInfo taxInfo, TokenizedPaymentSource tokenizedPaymentSource,
|
||||
[ProviderUser] ProviderUser providerUser,
|
||||
SutProvider<ProviderService> sutProvider)
|
||||
{
|
||||
@ -75,7 +151,7 @@ public class ProviderServiceTests
|
||||
var providerBillingService = sutProvider.GetDependency<IProviderBillingService>();
|
||||
|
||||
var customer = new Customer { Id = "customer_id" };
|
||||
providerBillingService.SetupCustomer(provider, taxInfo).Returns(customer);
|
||||
providerBillingService.SetupCustomer(provider, taxInfo, tokenizedPaymentSource).Returns(customer);
|
||||
|
||||
var subscription = new Subscription { Id = "subscription_id" };
|
||||
providerBillingService.SetupSubscription(provider).Returns(subscription);
|
||||
@ -84,7 +160,7 @@ public class ProviderServiceTests
|
||||
|
||||
var token = protector.Protect($"ProviderSetupInvite {provider.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}");
|
||||
|
||||
await sutProvider.Sut.CompleteSetupAsync(provider, user.Id, token, key, taxInfo);
|
||||
await sutProvider.Sut.CompleteSetupAsync(provider, user.Id, token, key, taxInfo, tokenizedPaymentSource);
|
||||
|
||||
await sutProvider.GetDependency<IProviderRepository>().Received().UpsertAsync(Arg.Is<Provider>(
|
||||
p =>
|
||||
@ -642,8 +718,8 @@ public class ProviderServiceTests
|
||||
|
||||
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
|
||||
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
|
||||
sutProvider.GetDependency<IOrganizationService>().SignupClientAsync(organizationSignup)
|
||||
.Returns((organization, null as OrganizationUser, new Collection()));
|
||||
sutProvider.GetDependency<IProviderClientOrganizationSignUpCommand>().SignUpClientOrganizationAsync(organizationSignup)
|
||||
.Returns(new ProviderClientOrganizationSignUpResponse(organization, new Collection()));
|
||||
|
||||
var providerOrganization =
|
||||
await sutProvider.Sut.CreateOrganizationAsync(provider.Id, organizationSignup, clientOwnerEmail, user);
|
||||
@ -680,8 +756,8 @@ public class ProviderServiceTests
|
||||
|
||||
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
|
||||
|
||||
sutProvider.GetDependency<IOrganizationService>().SignupClientAsync(organizationSignup)
|
||||
.Returns((organization, null as OrganizationUser, new Collection()));
|
||||
sutProvider.GetDependency<IProviderClientOrganizationSignUpCommand>().SignUpClientOrganizationAsync(organizationSignup)
|
||||
.Returns(new ProviderClientOrganizationSignUpResponse(organization, new Collection()));
|
||||
|
||||
await Assert.ThrowsAsync<BadRequestException>(() =>
|
||||
sutProvider.Sut.CreateOrganizationAsync(provider.Id, organizationSignup, clientOwnerEmail, user));
|
||||
@ -707,8 +783,8 @@ public class ProviderServiceTests
|
||||
|
||||
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
|
||||
|
||||
sutProvider.GetDependency<IOrganizationService>().SignupClientAsync(organizationSignup)
|
||||
.Returns((organization, null as OrganizationUser, new Collection()));
|
||||
sutProvider.GetDependency<IProviderClientOrganizationSignUpCommand>().SignUpClientOrganizationAsync(organizationSignup)
|
||||
.Returns(new ProviderClientOrganizationSignUpResponse(organization, new Collection()));
|
||||
|
||||
var providerOrganization = await sutProvider.Sut.CreateOrganizationAsync(provider.Id, organizationSignup, clientOwnerEmail, user);
|
||||
|
||||
@ -746,8 +822,8 @@ public class ProviderServiceTests
|
||||
|
||||
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
|
||||
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
|
||||
sutProvider.GetDependency<IOrganizationService>().SignupClientAsync(organizationSignup)
|
||||
.Returns((organization, null as OrganizationUser, defaultCollection));
|
||||
sutProvider.GetDependency<IProviderClientOrganizationSignUpCommand>().SignUpClientOrganizationAsync(organizationSignup)
|
||||
.Returns(new ProviderClientOrganizationSignUpResponse(organization, defaultCollection));
|
||||
|
||||
var providerOrganization =
|
||||
await sutProvider.Sut.CreateOrganizationAsync(provider.Id, organizationSignup, clientOwnerEmail, user);
|
||||
|
@ -0,0 +1,501 @@
|
||||
#nullable enable
|
||||
using System.Text;
|
||||
using Bit.Commercial.Core.Billing.Providers.Services;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Providers.Entities;
|
||||
using Bit.Core.Billing.Providers.Repositories;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Stripe;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Commercial.Core.Test.Billing.Providers;
|
||||
|
||||
public class BusinessUnitConverterTests
|
||||
{
|
||||
private readonly IDataProtectionProvider _dataProtectionProvider = Substitute.For<IDataProtectionProvider>();
|
||||
private readonly GlobalSettings _globalSettings = new();
|
||||
private readonly ILogger<BusinessUnitConverter> _logger = Substitute.For<ILogger<BusinessUnitConverter>>();
|
||||
private readonly IMailService _mailService = Substitute.For<IMailService>();
|
||||
private readonly IOrganizationRepository _organizationRepository = Substitute.For<IOrganizationRepository>();
|
||||
private readonly IOrganizationUserRepository _organizationUserRepository = Substitute.For<IOrganizationUserRepository>();
|
||||
private readonly IPricingClient _pricingClient = Substitute.For<IPricingClient>();
|
||||
private readonly IProviderOrganizationRepository _providerOrganizationRepository = Substitute.For<IProviderOrganizationRepository>();
|
||||
private readonly IProviderPlanRepository _providerPlanRepository = Substitute.For<IProviderPlanRepository>();
|
||||
private readonly IProviderRepository _providerRepository = Substitute.For<IProviderRepository>();
|
||||
private readonly IProviderUserRepository _providerUserRepository = Substitute.For<IProviderUserRepository>();
|
||||
private readonly IStripeAdapter _stripeAdapter = Substitute.For<IStripeAdapter>();
|
||||
private readonly ISubscriberService _subscriberService = Substitute.For<ISubscriberService>();
|
||||
private readonly IUserRepository _userRepository = Substitute.For<IUserRepository>();
|
||||
|
||||
private BusinessUnitConverter BuildConverter() => new(
|
||||
_dataProtectionProvider,
|
||||
_globalSettings,
|
||||
_logger,
|
||||
_mailService,
|
||||
_organizationRepository,
|
||||
_organizationUserRepository,
|
||||
_pricingClient,
|
||||
_providerOrganizationRepository,
|
||||
_providerPlanRepository,
|
||||
_providerRepository,
|
||||
_providerUserRepository,
|
||||
_stripeAdapter,
|
||||
_subscriberService,
|
||||
_userRepository);
|
||||
|
||||
#region FinalizeConversion
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task FinalizeConversion_Succeeds_ReturnsProviderId(
|
||||
Organization organization,
|
||||
Guid userId,
|
||||
string providerKey,
|
||||
string organizationKey)
|
||||
{
|
||||
organization.PlanType = PlanType.EnterpriseAnnually2020;
|
||||
|
||||
var enterpriseAnnually2020 = StaticStore.GetPlan(PlanType.EnterpriseAnnually2020);
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Id = "subscription_id",
|
||||
CustomerId = "customer_id",
|
||||
Status = StripeConstants.SubscriptionStatus.Active,
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = [
|
||||
new SubscriptionItem
|
||||
{
|
||||
Id = "subscription_item_id",
|
||||
Price = new Price
|
||||
{
|
||||
Id = enterpriseAnnually2020.PasswordManager.StripeSeatPlanId
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
_subscriberService.GetSubscription(organization).Returns(subscription);
|
||||
|
||||
var user = new User
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Email = "provider-admin@example.com"
|
||||
};
|
||||
|
||||
_userRepository.GetByIdAsync(userId).Returns(user);
|
||||
|
||||
var token = SetupDataProtection(organization, user.Email);
|
||||
|
||||
var organizationUser = new OrganizationUser { Status = OrganizationUserStatusType.Confirmed };
|
||||
|
||||
_organizationUserRepository.GetByOrganizationAsync(organization.Id, user.Id)
|
||||
.Returns(organizationUser);
|
||||
|
||||
var provider = new Provider
|
||||
{
|
||||
Type = ProviderType.BusinessUnit,
|
||||
Status = ProviderStatusType.Pending
|
||||
};
|
||||
|
||||
_providerRepository.GetByOrganizationIdAsync(organization.Id).Returns(provider);
|
||||
|
||||
var providerUser = new ProviderUser
|
||||
{
|
||||
Type = ProviderUserType.ProviderAdmin,
|
||||
Status = ProviderUserStatusType.Invited
|
||||
};
|
||||
|
||||
_providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id).Returns(providerUser);
|
||||
|
||||
var providerOrganization = new ProviderOrganization();
|
||||
|
||||
_providerOrganizationRepository.GetByOrganizationId(organization.Id).Returns(providerOrganization);
|
||||
|
||||
_pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually2020)
|
||||
.Returns(enterpriseAnnually2020);
|
||||
|
||||
var enterpriseAnnually = StaticStore.GetPlan(PlanType.EnterpriseAnnually);
|
||||
|
||||
_pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually)
|
||||
.Returns(enterpriseAnnually);
|
||||
|
||||
var businessUnitConverter = BuildConverter();
|
||||
|
||||
await businessUnitConverter.FinalizeConversion(organization, userId, token, providerKey, organizationKey);
|
||||
|
||||
await _stripeAdapter.Received(2).CustomerUpdateAsync(subscription.CustomerId, Arg.Any<CustomerUpdateOptions>());
|
||||
|
||||
var updatedPriceId = ProviderPriceAdapter.GetActivePriceId(provider, enterpriseAnnually.Type);
|
||||
|
||||
await _stripeAdapter.Received(1).SubscriptionUpdateAsync(subscription.Id, Arg.Is<SubscriptionUpdateOptions>(
|
||||
arguments =>
|
||||
arguments.Items.Count == 2 &&
|
||||
arguments.Items[0].Id == "subscription_item_id" &&
|
||||
arguments.Items[0].Deleted == true &&
|
||||
arguments.Items[1].Price == updatedPriceId &&
|
||||
arguments.Items[1].Quantity == organization.Seats));
|
||||
|
||||
await _organizationRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(arguments =>
|
||||
arguments.PlanType == PlanType.EnterpriseAnnually &&
|
||||
arguments.Status == OrganizationStatusType.Managed &&
|
||||
arguments.GatewayCustomerId == null &&
|
||||
arguments.GatewaySubscriptionId == null));
|
||||
|
||||
await _providerOrganizationRepository.Received(1).ReplaceAsync(Arg.Is<ProviderOrganization>(arguments =>
|
||||
arguments.Key == organizationKey));
|
||||
|
||||
await _providerRepository.Received(1).ReplaceAsync(Arg.Is<Provider>(arguments =>
|
||||
arguments.Gateway == GatewayType.Stripe &&
|
||||
arguments.GatewayCustomerId == subscription.CustomerId &&
|
||||
arguments.GatewaySubscriptionId == subscription.Id &&
|
||||
arguments.Status == ProviderStatusType.Billable));
|
||||
|
||||
await _providerUserRepository.Received(1).ReplaceAsync(Arg.Is<ProviderUser>(arguments =>
|
||||
arguments.Key == providerKey &&
|
||||
arguments.Status == ProviderUserStatusType.Confirmed));
|
||||
}
|
||||
|
||||
/*
|
||||
* Because the validation for finalization is not an applicative like initialization is,
|
||||
* I'm just testing one specific failure here. I don't see much value in testing every single opportunity for failure.
|
||||
*/
|
||||
[Theory, BitAutoData]
|
||||
public async Task FinalizeConversion_ValidationFails_ThrowsBillingException(
|
||||
Organization organization,
|
||||
Guid userId,
|
||||
string token,
|
||||
string providerKey,
|
||||
string organizationKey)
|
||||
{
|
||||
organization.PlanType = PlanType.EnterpriseAnnually2020;
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Status = StripeConstants.SubscriptionStatus.Canceled
|
||||
};
|
||||
|
||||
_subscriberService.GetSubscription(organization).Returns(subscription);
|
||||
|
||||
var businessUnitConverter = BuildConverter();
|
||||
|
||||
await Assert.ThrowsAsync<BillingException>(() =>
|
||||
businessUnitConverter.FinalizeConversion(organization, userId, token, providerKey, organizationKey));
|
||||
|
||||
await _organizationUserRepository.DidNotReceiveWithAnyArgs()
|
||||
.GetByOrganizationAsync(Arg.Any<Guid>(), Arg.Any<Guid>());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region InitiateConversion
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task InitiateConversion_Succeeds_ReturnsProviderId(
|
||||
Organization organization,
|
||||
string providerAdminEmail)
|
||||
{
|
||||
organization.PlanType = PlanType.EnterpriseAnnually;
|
||||
|
||||
_subscriberService.GetSubscription(organization).Returns(new Subscription
|
||||
{
|
||||
Status = StripeConstants.SubscriptionStatus.Active
|
||||
});
|
||||
|
||||
var user = new User
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Email = providerAdminEmail
|
||||
};
|
||||
|
||||
_userRepository.GetByEmailAsync(providerAdminEmail).Returns(user);
|
||||
|
||||
var organizationUser = new OrganizationUser { Status = OrganizationUserStatusType.Confirmed };
|
||||
|
||||
_organizationUserRepository.GetByOrganizationAsync(organization.Id, user.Id)
|
||||
.Returns(organizationUser);
|
||||
|
||||
var provider = new Provider { Id = Guid.NewGuid() };
|
||||
|
||||
_providerRepository.CreateAsync(Arg.Is<Provider>(argument =>
|
||||
argument.Name == organization.Name &&
|
||||
argument.BillingEmail == organization.BillingEmail &&
|
||||
argument.Status == ProviderStatusType.Pending &&
|
||||
argument.Type == ProviderType.BusinessUnit)).Returns(provider);
|
||||
|
||||
var plan = StaticStore.GetPlan(organization.PlanType);
|
||||
|
||||
_pricingClient.GetPlanOrThrow(organization.PlanType).Returns(plan);
|
||||
|
||||
var token = SetupDataProtection(organization, providerAdminEmail);
|
||||
|
||||
var businessUnitConverter = BuildConverter();
|
||||
|
||||
var result = await businessUnitConverter.InitiateConversion(organization, providerAdminEmail);
|
||||
|
||||
Assert.True(result.IsT0);
|
||||
|
||||
var providerId = result.AsT0;
|
||||
|
||||
Assert.Equal(provider.Id, providerId);
|
||||
|
||||
await _providerOrganizationRepository.Received(1).CreateAsync(
|
||||
Arg.Is<ProviderOrganization>(argument =>
|
||||
argument.ProviderId == provider.Id &&
|
||||
argument.OrganizationId == organization.Id));
|
||||
|
||||
await _providerPlanRepository.Received(1).CreateAsync(
|
||||
Arg.Is<ProviderPlan>(argument =>
|
||||
argument.ProviderId == provider.Id &&
|
||||
argument.PlanType == PlanType.EnterpriseAnnually &&
|
||||
argument.SeatMinimum == 0 &&
|
||||
argument.PurchasedSeats == organization.Seats &&
|
||||
argument.AllocatedSeats == organization.Seats));
|
||||
|
||||
await _providerUserRepository.Received(1).CreateAsync(
|
||||
Arg.Is<ProviderUser>(argument =>
|
||||
argument.ProviderId == provider.Id &&
|
||||
argument.UserId == user.Id &&
|
||||
argument.Email == user.Email &&
|
||||
argument.Status == ProviderUserStatusType.Invited &&
|
||||
argument.Type == ProviderUserType.ProviderAdmin));
|
||||
|
||||
await _mailService.Received(1).SendBusinessUnitConversionInviteAsync(
|
||||
organization,
|
||||
token,
|
||||
user.Email);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task InitiateConversion_ValidationFails_ReturnsErrors(
|
||||
Organization organization,
|
||||
string providerAdminEmail)
|
||||
{
|
||||
organization.PlanType = PlanType.TeamsMonthly;
|
||||
|
||||
_subscriberService.GetSubscription(organization).Returns(new Subscription
|
||||
{
|
||||
Status = StripeConstants.SubscriptionStatus.Canceled
|
||||
});
|
||||
|
||||
var user = new User
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Email = providerAdminEmail
|
||||
};
|
||||
|
||||
_providerOrganizationRepository.GetByOrganizationId(organization.Id)
|
||||
.Returns(new ProviderOrganization());
|
||||
|
||||
_userRepository.GetByEmailAsync(providerAdminEmail).Returns(user);
|
||||
|
||||
var organizationUser = new OrganizationUser { Status = OrganizationUserStatusType.Invited };
|
||||
|
||||
_organizationUserRepository.GetByOrganizationAsync(organization.Id, user.Id)
|
||||
.Returns(organizationUser);
|
||||
|
||||
var businessUnitConverter = BuildConverter();
|
||||
|
||||
var result = await businessUnitConverter.InitiateConversion(organization, providerAdminEmail);
|
||||
|
||||
Assert.True(result.IsT1);
|
||||
|
||||
var problems = result.AsT1;
|
||||
|
||||
Assert.Contains("Organization must be on an enterprise plan.", problems);
|
||||
|
||||
Assert.Contains("Organization must have a valid subscription.", problems);
|
||||
|
||||
Assert.Contains("Organization is already linked to a provider.", problems);
|
||||
|
||||
Assert.Contains("Provider admin must be a confirmed member of the organization being converted.", problems);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ResendConversionInvite
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ResendConversionInvite_ConversionInProgress_Succeeds(
|
||||
Organization organization,
|
||||
string providerAdminEmail)
|
||||
{
|
||||
SetupConversionInProgress(organization, providerAdminEmail);
|
||||
|
||||
var token = SetupDataProtection(organization, providerAdminEmail);
|
||||
|
||||
var businessUnitConverter = BuildConverter();
|
||||
|
||||
await businessUnitConverter.ResendConversionInvite(organization, providerAdminEmail);
|
||||
|
||||
await _mailService.Received(1).SendBusinessUnitConversionInviteAsync(
|
||||
organization,
|
||||
token,
|
||||
providerAdminEmail);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ResendConversionInvite_NoConversionInProgress_DoesNothing(
|
||||
Organization organization,
|
||||
string providerAdminEmail)
|
||||
{
|
||||
SetupDataProtection(organization, providerAdminEmail);
|
||||
|
||||
var businessUnitConverter = BuildConverter();
|
||||
|
||||
await businessUnitConverter.ResendConversionInvite(organization, providerAdminEmail);
|
||||
|
||||
await _mailService.DidNotReceiveWithAnyArgs().SendBusinessUnitConversionInviteAsync(
|
||||
Arg.Any<Organization>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ResetConversion
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ResetConversion_ConversionInProgress_Succeeds(
|
||||
Organization organization,
|
||||
string providerAdminEmail)
|
||||
{
|
||||
var (provider, providerOrganization, providerUser, providerPlan) = SetupConversionInProgress(organization, providerAdminEmail);
|
||||
|
||||
var businessUnitConverter = BuildConverter();
|
||||
|
||||
await businessUnitConverter.ResetConversion(organization, providerAdminEmail);
|
||||
|
||||
await _providerOrganizationRepository.Received(1)
|
||||
.DeleteAsync(providerOrganization);
|
||||
|
||||
await _providerUserRepository.Received(1)
|
||||
.DeleteAsync(providerUser);
|
||||
|
||||
await _providerPlanRepository.Received(1)
|
||||
.DeleteAsync(providerPlan);
|
||||
|
||||
await _providerRepository.Received(1)
|
||||
.DeleteAsync(provider);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ResetConversion_NoConversionInProgress_DoesNothing(
|
||||
Organization organization,
|
||||
string providerAdminEmail)
|
||||
{
|
||||
var businessUnitConverter = BuildConverter();
|
||||
|
||||
await businessUnitConverter.ResetConversion(organization, providerAdminEmail);
|
||||
|
||||
await _providerOrganizationRepository.DidNotReceiveWithAnyArgs()
|
||||
.DeleteAsync(Arg.Any<ProviderOrganization>());
|
||||
|
||||
await _providerUserRepository.DidNotReceiveWithAnyArgs()
|
||||
.DeleteAsync(Arg.Any<ProviderUser>());
|
||||
|
||||
await _providerPlanRepository.DidNotReceiveWithAnyArgs()
|
||||
.DeleteAsync(Arg.Any<ProviderPlan>());
|
||||
|
||||
await _providerRepository.DidNotReceiveWithAnyArgs()
|
||||
.DeleteAsync(Arg.Any<Provider>());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Utilities
|
||||
|
||||
private string SetupDataProtection(
|
||||
Organization organization,
|
||||
string providerAdminEmail)
|
||||
{
|
||||
var dataProtector = new MockDataProtector(organization, providerAdminEmail);
|
||||
_dataProtectionProvider.CreateProtector($"{nameof(BusinessUnitConverter)}DataProtector").Returns(dataProtector);
|
||||
return dataProtector.Protect(dataProtector.Token);
|
||||
}
|
||||
|
||||
private (Provider, ProviderOrganization, ProviderUser, ProviderPlan) SetupConversionInProgress(
|
||||
Organization organization,
|
||||
string providerAdminEmail)
|
||||
{
|
||||
var user = new User { Id = Guid.NewGuid() };
|
||||
|
||||
_userRepository.GetByEmailAsync(providerAdminEmail).Returns(user);
|
||||
|
||||
var provider = new Provider
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Type = ProviderType.BusinessUnit,
|
||||
Status = ProviderStatusType.Pending
|
||||
};
|
||||
|
||||
_providerRepository.GetByOrganizationIdAsync(organization.Id).Returns(provider);
|
||||
|
||||
var providerUser = new ProviderUser
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ProviderId = provider.Id,
|
||||
UserId = user.Id,
|
||||
Type = ProviderUserType.ProviderAdmin,
|
||||
Status = ProviderUserStatusType.Invited,
|
||||
Email = providerAdminEmail
|
||||
};
|
||||
|
||||
_providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id)
|
||||
.Returns(providerUser);
|
||||
|
||||
var providerOrganization = new ProviderOrganization
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OrganizationId = organization.Id,
|
||||
ProviderId = provider.Id
|
||||
};
|
||||
|
||||
_providerOrganizationRepository.GetByOrganizationId(organization.Id)
|
||||
.Returns(providerOrganization);
|
||||
|
||||
var providerPlan = new ProviderPlan
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ProviderId = provider.Id,
|
||||
PlanType = PlanType.EnterpriseAnnually
|
||||
};
|
||||
|
||||
_providerPlanRepository.GetByProviderId(provider.Id).Returns([providerPlan]);
|
||||
|
||||
return (provider, providerOrganization, providerUser, providerPlan);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
public class MockDataProtector(
|
||||
Organization organization,
|
||||
string providerAdminEmail) : IDataProtector
|
||||
{
|
||||
public string Token = $"BusinessUnitConversionInvite {organization.Id} {providerAdminEmail} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}";
|
||||
|
||||
public IDataProtector CreateProtector(string purpose) => this;
|
||||
|
||||
public byte[] Protect(byte[] plaintext) => Encoding.UTF8.GetBytes(Token);
|
||||
|
||||
public byte[] Unprotect(byte[] protectedData) => Encoding.UTF8.GetBytes(Token);
|
||||
}
|
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,151 @@
|
||||
using Bit.Commercial.Core.Billing.Providers.Services;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Stripe;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Commercial.Core.Test.Billing.Providers;
|
||||
|
||||
public class ProviderPriceAdapterTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("password-manager-provider-portal-enterprise-monthly-2024", PlanType.EnterpriseMonthly)]
|
||||
[InlineData("password-manager-provider-portal-teams-monthly-2024", PlanType.TeamsMonthly)]
|
||||
public void GetPriceId_MSP_Legacy_Succeeds(string priceId, PlanType planType)
|
||||
{
|
||||
var provider = new Provider
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Type = ProviderType.Msp
|
||||
};
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem { Price = new Price { Id = priceId } }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
var result = ProviderPriceAdapter.GetPriceId(provider, subscription, planType);
|
||||
|
||||
Assert.Equal(result, priceId);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("provider-portal-enterprise-monthly-2025", PlanType.EnterpriseMonthly)]
|
||||
[InlineData("provider-portal-teams-monthly-2025", PlanType.TeamsMonthly)]
|
||||
public void GetPriceId_MSP_Active_Succeeds(string priceId, PlanType planType)
|
||||
{
|
||||
var provider = new Provider
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Type = ProviderType.Msp
|
||||
};
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem { Price = new Price { Id = priceId } }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
var result = ProviderPriceAdapter.GetPriceId(provider, subscription, planType);
|
||||
|
||||
Assert.Equal(result, priceId);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("password-manager-provider-portal-enterprise-annually-2024", PlanType.EnterpriseAnnually)]
|
||||
[InlineData("password-manager-provider-portal-enterprise-monthly-2024", PlanType.EnterpriseMonthly)]
|
||||
public void GetPriceId_BusinessUnit_Legacy_Succeeds(string priceId, PlanType planType)
|
||||
{
|
||||
var provider = new Provider
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Type = ProviderType.BusinessUnit
|
||||
};
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem { Price = new Price { Id = priceId } }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
var result = ProviderPriceAdapter.GetPriceId(provider, subscription, planType);
|
||||
|
||||
Assert.Equal(result, priceId);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("business-unit-portal-enterprise-annually-2025", PlanType.EnterpriseAnnually)]
|
||||
[InlineData("business-unit-portal-enterprise-monthly-2025", PlanType.EnterpriseMonthly)]
|
||||
public void GetPriceId_BusinessUnit_Active_Succeeds(string priceId, PlanType planType)
|
||||
{
|
||||
var provider = new Provider
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Type = ProviderType.BusinessUnit
|
||||
};
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem { Price = new Price { Id = priceId } }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
var result = ProviderPriceAdapter.GetPriceId(provider, subscription, planType);
|
||||
|
||||
Assert.Equal(result, priceId);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("provider-portal-enterprise-monthly-2025", PlanType.EnterpriseMonthly)]
|
||||
[InlineData("provider-portal-teams-monthly-2025", PlanType.TeamsMonthly)]
|
||||
public void GetActivePriceId_MSP_Succeeds(string priceId, PlanType planType)
|
||||
{
|
||||
var provider = new Provider
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Type = ProviderType.Msp
|
||||
};
|
||||
|
||||
var result = ProviderPriceAdapter.GetActivePriceId(provider, planType);
|
||||
|
||||
Assert.Equal(result, priceId);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("business-unit-portal-enterprise-annually-2025", PlanType.EnterpriseAnnually)]
|
||||
[InlineData("business-unit-portal-enterprise-monthly-2025", PlanType.EnterpriseMonthly)]
|
||||
public void GetActivePriceId_BusinessUnit_Succeeds(string priceId, PlanType planType)
|
||||
{
|
||||
var provider = new Provider
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Type = ProviderType.BusinessUnit
|
||||
};
|
||||
|
||||
var result = ProviderPriceAdapter.GetActivePriceId(provider, planType);
|
||||
|
||||
Assert.Equal(result, priceId);
|
||||
}
|
||||
}
|
@ -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.Attributes;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Commercial.Core.Test.Billing;
|
||||
namespace Bit.Commercial.Core.Test.Billing.Tax;
|
||||
|
||||
[SutProviderCustomize]
|
||||
public class TaxServiceTests
|
@ -1,9 +1,12 @@
|
||||
using Bit.Commercial.Core.SecretsManager.Queries.Projects;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.SecretsManager.Repositories;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using NSubstitute;
|
||||
@ -15,11 +18,26 @@ namespace Bit.Commercial.Core.Test.SecretsManager.Queries.Projects;
|
||||
[SutProviderCustomize]
|
||||
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]
|
||||
[BitAutoData]
|
||||
public async Task GetByOrgIdAsync_OrganizationIsNull_ThrowsNotFound(SutProvider<MaxProjectsQuery> sutProvider,
|
||||
Guid organizationId)
|
||||
{
|
||||
sutProvider.GetDependency<IGlobalSettings>().SelfHosted.Returns(false);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(default).ReturnsNull();
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.GetByOrgIdAsync(organizationId, 1));
|
||||
@ -28,26 +46,6 @@ public class MaxProjectsQueryTests
|
||||
.GetProjectCountByOrganizationIdAsync(organizationId);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData(PlanType.FamiliesAnnually2019)]
|
||||
[BitAutoData(PlanType.Custom)]
|
||||
[BitAutoData(PlanType.FamiliesAnnually)]
|
||||
public async Task GetByOrgIdAsync_SmPlanIsNull_ThrowsBadRequest(PlanType planType,
|
||||
SutProvider<MaxProjectsQuery> sutProvider, Organization organization)
|
||||
{
|
||||
organization.PlanType = planType;
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetByIdAsync(organization.Id)
|
||||
.Returns(organization);
|
||||
|
||||
await Assert.ThrowsAsync<BadRequestException>(
|
||||
async () => await sutProvider.Sut.GetByOrgIdAsync(organization.Id, 1));
|
||||
|
||||
await sutProvider.GetDependency<IProjectRepository>()
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
.GetProjectCountByOrganizationIdAsync(organization.Id);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData(PlanType.TeamsMonthly2019)]
|
||||
[BitAutoData(PlanType.TeamsMonthly2020)]
|
||||
@ -65,9 +63,14 @@ public class MaxProjectsQueryTests
|
||||
public async Task GetByOrgIdAsync_SmNoneFreePlans_ReturnsNull(PlanType planType,
|
||||
SutProvider<MaxProjectsQuery> sutProvider, Organization organization)
|
||||
{
|
||||
sutProvider.GetDependency<IGlobalSettings>().SelfHosted.Returns(false);
|
||||
|
||||
organization.PlanType = planType;
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
|
||||
|
||||
sutProvider.GetDependency<IPricingClient>().GetPlan(organization.PlanType)
|
||||
.Returns(StaticStore.GetPlan(organization.PlanType));
|
||||
|
||||
var (limit, overLimit) = await sutProvider.Sut.GetByOrgIdAsync(organization.Id, 1);
|
||||
|
||||
Assert.Null(limit);
|
||||
@ -110,6 +113,9 @@ public class MaxProjectsQueryTests
|
||||
sutProvider.GetDependency<IProjectRepository>().GetProjectCountByOrganizationIdAsync(organization.Id)
|
||||
.Returns(projects);
|
||||
|
||||
sutProvider.GetDependency<IPricingClient>().GetPlan(organization.PlanType)
|
||||
.Returns(StaticStore.GetPlan(organization.PlanType));
|
||||
|
||||
var (max, overMax) = await sutProvider.Sut.GetByOrgIdAsync(organization.Id, projectsToAdd);
|
||||
|
||||
Assert.NotNull(max);
|
||||
|
@ -20,6 +20,7 @@ public class GroupsControllerPatchTests : IClassFixture<ScimApplicationFactory>,
|
||||
{
|
||||
var databaseContext = _factory.GetDatabaseContext();
|
||||
_factory.ReinitializeDbForTests(databaseContext);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
|
@ -1,251 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Scim.Groups.Interfaces;
|
||||
using Bit.Scim.IntegrationTest.Factories;
|
||||
using Bit.Scim.Models;
|
||||
using Bit.Scim.Utilities;
|
||||
using Bit.Test.Common.Helpers;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ExceptionExtensions;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Scim.IntegrationTest.Controllers.v2;
|
||||
|
||||
public class GroupsControllerPatchTestsvNext : IClassFixture<ScimApplicationFactory>, IAsyncLifetime
|
||||
{
|
||||
private readonly ScimApplicationFactory _factory;
|
||||
|
||||
public GroupsControllerPatchTestsvNext(ScimApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
|
||||
// Enable the feature flag for new PatchGroupsCommand and stub out the old command to be safe
|
||||
_factory.SubstituteService((IFeatureService featureService)
|
||||
=> featureService.IsEnabled(FeatureFlagKeys.ShortcutDuplicatePatchRequests).Returns(true));
|
||||
_factory.SubstituteService((IPatchGroupCommand patchGroupCommand)
|
||||
=> patchGroupCommand.PatchGroupAsync(Arg.Any<Organization>(), Arg.Any<Guid>(), Arg.Any<ScimPatchModel>())
|
||||
.ThrowsAsync(new Exception("This test suite should be testing the vNext command, but the existing command was called.")));
|
||||
}
|
||||
|
||||
public Task InitializeAsync()
|
||||
{
|
||||
var databaseContext = _factory.GetDatabaseContext();
|
||||
_factory.ReinitializeDbForTests(databaseContext);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
Task IAsyncLifetime.DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
[Fact]
|
||||
public async Task Patch_ReplaceDisplayName_Success()
|
||||
{
|
||||
var organizationId = ScimApplicationFactory.TestOrganizationId1;
|
||||
var groupId = ScimApplicationFactory.TestGroupId1;
|
||||
var newDisplayName = "Patch Display Name";
|
||||
var inputModel = new ScimPatchModel
|
||||
{
|
||||
Operations = new List<ScimPatchModel.OperationModel>()
|
||||
{
|
||||
new ScimPatchModel.OperationModel
|
||||
{
|
||||
Op = "replace",
|
||||
Value = JsonDocument.Parse($"{{\"displayName\":\"{newDisplayName}\"}}").RootElement
|
||||
}
|
||||
},
|
||||
Schemas = new List<string>() { ScimConstants.Scim2SchemaGroup }
|
||||
};
|
||||
|
||||
var context = await _factory.GroupsPatchAsync(organizationId, groupId, inputModel);
|
||||
|
||||
Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode);
|
||||
|
||||
var databaseContext = _factory.GetDatabaseContext();
|
||||
var group = databaseContext.Groups.FirstOrDefault(g => g.Id == groupId);
|
||||
Assert.Equal(newDisplayName, group.Name);
|
||||
|
||||
Assert.Equal(ScimApplicationFactory.InitialGroupUsersCount, databaseContext.GroupUsers.Count());
|
||||
Assert.True(databaseContext.GroupUsers.Any(gu => gu.OrganizationUserId == ScimApplicationFactory.TestOrganizationUserId1));
|
||||
Assert.True(databaseContext.GroupUsers.Any(gu => gu.OrganizationUserId == ScimApplicationFactory.TestOrganizationUserId4));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Patch_ReplaceMembers_Success()
|
||||
{
|
||||
var organizationId = ScimApplicationFactory.TestOrganizationId1;
|
||||
var groupId = ScimApplicationFactory.TestGroupId1;
|
||||
var inputModel = new ScimPatchModel
|
||||
{
|
||||
Operations = new List<ScimPatchModel.OperationModel>()
|
||||
{
|
||||
new ScimPatchModel.OperationModel
|
||||
{
|
||||
Op = "replace",
|
||||
Path = "members",
|
||||
Value = JsonDocument.Parse($"[{{\"value\":\"{ScimApplicationFactory.TestOrganizationUserId2}\"}}]").RootElement
|
||||
}
|
||||
},
|
||||
Schemas = new List<string>() { ScimConstants.Scim2SchemaGroup }
|
||||
};
|
||||
|
||||
var context = await _factory.GroupsPatchAsync(organizationId, groupId, inputModel);
|
||||
|
||||
Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode);
|
||||
|
||||
var databaseContext = _factory.GetDatabaseContext();
|
||||
Assert.Single(databaseContext.GroupUsers);
|
||||
|
||||
Assert.Equal(ScimApplicationFactory.InitialGroupUsersCount - 1, databaseContext.GroupUsers.Count());
|
||||
var groupUser = databaseContext.GroupUsers.FirstOrDefault();
|
||||
Assert.Equal(ScimApplicationFactory.TestOrganizationUserId2, groupUser.OrganizationUserId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Patch_AddSingleMember_Success()
|
||||
{
|
||||
var organizationId = ScimApplicationFactory.TestOrganizationId1;
|
||||
var groupId = ScimApplicationFactory.TestGroupId1;
|
||||
var inputModel = new ScimPatchModel
|
||||
{
|
||||
Operations = new List<ScimPatchModel.OperationModel>()
|
||||
{
|
||||
new ScimPatchModel.OperationModel
|
||||
{
|
||||
Op = "add",
|
||||
Path = $"members[value eq \"{ScimApplicationFactory.TestOrganizationUserId2}\"]",
|
||||
Value = JsonDocument.Parse("{}").RootElement
|
||||
}
|
||||
},
|
||||
Schemas = new List<string>() { ScimConstants.Scim2SchemaGroup }
|
||||
};
|
||||
|
||||
var context = await _factory.GroupsPatchAsync(organizationId, groupId, inputModel);
|
||||
|
||||
Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode);
|
||||
|
||||
var databaseContext = _factory.GetDatabaseContext();
|
||||
Assert.Equal(ScimApplicationFactory.InitialGroupUsersCount + 1, databaseContext.GroupUsers.Count());
|
||||
Assert.True(databaseContext.GroupUsers.Any(gu => gu.GroupId == groupId && gu.OrganizationUserId == ScimApplicationFactory.TestOrganizationUserId1));
|
||||
Assert.True(databaseContext.GroupUsers.Any(gu => gu.GroupId == groupId && gu.OrganizationUserId == ScimApplicationFactory.TestOrganizationUserId2));
|
||||
Assert.True(databaseContext.GroupUsers.Any(gu => gu.GroupId == groupId && gu.OrganizationUserId == ScimApplicationFactory.TestOrganizationUserId4));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Patch_AddListMembers_Success()
|
||||
{
|
||||
var organizationId = ScimApplicationFactory.TestOrganizationId1;
|
||||
var groupId = ScimApplicationFactory.TestGroupId2;
|
||||
var inputModel = new ScimPatchModel
|
||||
{
|
||||
Operations = new List<ScimPatchModel.OperationModel>()
|
||||
{
|
||||
new ScimPatchModel.OperationModel
|
||||
{
|
||||
Op = "add",
|
||||
Path = "members",
|
||||
Value = JsonDocument.Parse($"[{{\"value\":\"{ScimApplicationFactory.TestOrganizationUserId2}\"}},{{\"value\":\"{ScimApplicationFactory.TestOrganizationUserId3}\"}}]").RootElement
|
||||
}
|
||||
},
|
||||
Schemas = new List<string>() { ScimConstants.Scim2SchemaGroup }
|
||||
};
|
||||
|
||||
var context = await _factory.GroupsPatchAsync(organizationId, groupId, inputModel);
|
||||
|
||||
Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode);
|
||||
|
||||
var databaseContext = _factory.GetDatabaseContext();
|
||||
Assert.True(databaseContext.GroupUsers.Any(gu => gu.GroupId == groupId && gu.OrganizationUserId == ScimApplicationFactory.TestOrganizationUserId2));
|
||||
Assert.True(databaseContext.GroupUsers.Any(gu => gu.GroupId == groupId && gu.OrganizationUserId == ScimApplicationFactory.TestOrganizationUserId3));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Patch_RemoveSingleMember_ReplaceDisplayName_Success()
|
||||
{
|
||||
var organizationId = ScimApplicationFactory.TestOrganizationId1;
|
||||
var groupId = ScimApplicationFactory.TestGroupId1;
|
||||
var newDisplayName = "Patch Display Name";
|
||||
var inputModel = new ScimPatchModel
|
||||
{
|
||||
Operations = new List<ScimPatchModel.OperationModel>()
|
||||
{
|
||||
new ScimPatchModel.OperationModel
|
||||
{
|
||||
Op = "remove",
|
||||
Path = $"members[value eq \"{ScimApplicationFactory.TestOrganizationUserId1}\"]",
|
||||
Value = JsonDocument.Parse("{}").RootElement
|
||||
},
|
||||
new ScimPatchModel.OperationModel
|
||||
{
|
||||
Op = "replace",
|
||||
Value = JsonDocument.Parse($"{{\"displayName\":\"{newDisplayName}\"}}").RootElement
|
||||
}
|
||||
},
|
||||
Schemas = new List<string>() { ScimConstants.Scim2SchemaGroup }
|
||||
};
|
||||
|
||||
var context = await _factory.GroupsPatchAsync(organizationId, groupId, inputModel);
|
||||
|
||||
Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode);
|
||||
|
||||
var databaseContext = _factory.GetDatabaseContext();
|
||||
Assert.Equal(ScimApplicationFactory.InitialGroupUsersCount - 1, databaseContext.GroupUsers.Count());
|
||||
Assert.Equal(ScimApplicationFactory.InitialGroupCount, databaseContext.Groups.Count());
|
||||
|
||||
var group = databaseContext.Groups.FirstOrDefault(g => g.Id == groupId);
|
||||
Assert.Equal(newDisplayName, group.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Patch_RemoveListMembers_Success()
|
||||
{
|
||||
var organizationId = ScimApplicationFactory.TestOrganizationId1;
|
||||
var groupId = ScimApplicationFactory.TestGroupId1;
|
||||
var inputModel = new ScimPatchModel
|
||||
{
|
||||
Operations = new List<ScimPatchModel.OperationModel>()
|
||||
{
|
||||
new ScimPatchModel.OperationModel
|
||||
{
|
||||
Op = "remove",
|
||||
Path = "members",
|
||||
Value = JsonDocument.Parse($"[{{\"value\":\"{ScimApplicationFactory.TestOrganizationUserId1}\"}}, {{\"value\":\"{ScimApplicationFactory.TestOrganizationUserId4}\"}}]").RootElement
|
||||
}
|
||||
},
|
||||
Schemas = new List<string>() { ScimConstants.Scim2SchemaGroup }
|
||||
};
|
||||
|
||||
var context = await _factory.GroupsPatchAsync(organizationId, groupId, inputModel);
|
||||
|
||||
Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode);
|
||||
|
||||
var databaseContext = _factory.GetDatabaseContext();
|
||||
Assert.Empty(databaseContext.GroupUsers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Patch_NotFound()
|
||||
{
|
||||
var organizationId = ScimApplicationFactory.TestOrganizationId1;
|
||||
var groupId = Guid.NewGuid();
|
||||
var inputModel = new Models.ScimPatchModel
|
||||
{
|
||||
Operations = new List<ScimPatchModel.OperationModel>(),
|
||||
Schemas = new List<string>() { ScimConstants.Scim2SchemaGroup }
|
||||
};
|
||||
var expectedResponse = new ScimErrorResponseModel
|
||||
{
|
||||
Status = StatusCodes.Status404NotFound,
|
||||
Detail = "Group not found.",
|
||||
Schemas = new List<string> { ScimConstants.Scim2SchemaError }
|
||||
};
|
||||
|
||||
var context = await _factory.GroupsPatchAsync(organizationId, groupId, inputModel);
|
||||
|
||||
Assert.Equal(StatusCodes.Status404NotFound, context.Response.StatusCode);
|
||||
|
||||
var responseModel = JsonSerializer.Deserialize<ScimErrorResponseModel>(context.Response.Body, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
|
||||
AssertHelper.AssertPropertyEqual(expectedResponse, responseModel);
|
||||
}
|
||||
}
|
@ -1,9 +1,12 @@
|
||||
using System.Text.Json;
|
||||
using Bit.Core;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Scim.IntegrationTest.Factories;
|
||||
using Bit.Scim.Models;
|
||||
using Bit.Scim.Utilities;
|
||||
using Bit.Test.Common.Helpers;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Scim.IntegrationTest.Controllers.v2;
|
||||
@ -276,9 +279,18 @@ public class UsersControllerTests : IClassFixture<ScimApplicationFactory>, IAsyn
|
||||
AssertHelper.AssertPropertyEqual(expectedResponse, responseModel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Post_Success()
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task Post_Success(bool isScimInviteUserOptimizationEnabled)
|
||||
{
|
||||
var localFactory = new ScimApplicationFactory();
|
||||
localFactory.SubstituteService((IFeatureService featureService)
|
||||
=> featureService.IsEnabled(FeatureFlagKeys.ScimInviteUserOptimization)
|
||||
.Returns(isScimInviteUserOptimizationEnabled));
|
||||
|
||||
localFactory.ReinitializeDbForTests(localFactory.GetDatabaseContext());
|
||||
|
||||
var email = "user5@example.com";
|
||||
var displayName = "Test User 5";
|
||||
var externalId = "UE";
|
||||
@ -306,7 +318,7 @@ public class UsersControllerTests : IClassFixture<ScimApplicationFactory>, IAsyn
|
||||
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
|
||||
};
|
||||
|
||||
var context = await _factory.UsersPostAsync(ScimApplicationFactory.TestOrganizationId1, inputModel);
|
||||
var context = await localFactory.UsersPostAsync(ScimApplicationFactory.TestOrganizationId1, inputModel);
|
||||
|
||||
Assert.Equal(StatusCodes.Status201Created, context.Response.StatusCode);
|
||||
|
||||
@ -316,7 +328,7 @@ public class UsersControllerTests : IClassFixture<ScimApplicationFactory>, IAsyn
|
||||
var responseModel = JsonSerializer.Deserialize<ScimUserResponseModel>(context.Response.Body, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
|
||||
AssertHelper.AssertPropertyEqual(expectedResponse, responseModel, "Id");
|
||||
|
||||
var databaseContext = _factory.GetDatabaseContext();
|
||||
var databaseContext = localFactory.GetDatabaseContext();
|
||||
Assert.Equal(_initialUserCount + 1, databaseContext.OrganizationUsers.Count());
|
||||
}
|
||||
|
||||
|
@ -1,15 +1,18 @@
|
||||
using System.Text.Json;
|
||||
using AutoFixture;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Scim.Groups;
|
||||
using Bit.Scim.Models;
|
||||
using Bit.Scim.Utilities;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
@ -20,19 +23,16 @@ public class PatchGroupCommandTests
|
||||
{
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PatchGroup_ReplaceListMembers_Success(SutProvider<PatchGroupCommand> sutProvider, Organization organization, Group group, IEnumerable<Guid> userIds)
|
||||
public async Task PatchGroup_ReplaceListMembers_Success(SutProvider<PatchGroupCommand> sutProvider,
|
||||
Organization organization, Group group, IEnumerable<Guid> userIds)
|
||||
{
|
||||
group.OrganizationId = organization.Id;
|
||||
|
||||
sutProvider.GetDependency<IGroupRepository>()
|
||||
.GetByIdAsync(group.Id)
|
||||
.Returns(group);
|
||||
|
||||
var scimPatchModel = new Models.ScimPatchModel
|
||||
var scimPatchModel = new ScimPatchModel
|
||||
{
|
||||
Operations = new List<ScimPatchModel.OperationModel>
|
||||
{
|
||||
new ScimPatchModel.OperationModel
|
||||
new()
|
||||
{
|
||||
Op = "replace",
|
||||
Path = "members",
|
||||
@ -42,26 +42,31 @@ public class PatchGroupCommandTests
|
||||
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
|
||||
};
|
||||
|
||||
await sutProvider.Sut.PatchGroupAsync(organization, group.Id, scimPatchModel);
|
||||
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
|
||||
|
||||
await sutProvider.GetDependency<IGroupRepository>().Received(1).UpdateUsersAsync(group.Id, Arg.Is<IEnumerable<Guid>>(arg => arg.All(id => userIds.Contains(id))));
|
||||
await sutProvider.GetDependency<IGroupRepository>().Received(1).UpdateUsersAsync(
|
||||
group.Id,
|
||||
Arg.Is<IEnumerable<Guid>>(arg =>
|
||||
arg.Count() == userIds.Count() &&
|
||||
arg.ToHashSet().SetEquals(userIds)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PatchGroup_ReplaceDisplayNameFromPath_Success(SutProvider<PatchGroupCommand> sutProvider, Organization organization, Group group, string displayName)
|
||||
public async Task PatchGroup_ReplaceDisplayNameFromPath_Success(
|
||||
SutProvider<PatchGroupCommand> sutProvider, Organization organization, Group group, string displayName)
|
||||
{
|
||||
group.OrganizationId = organization.Id;
|
||||
|
||||
sutProvider.GetDependency<IGroupRepository>()
|
||||
.GetByIdAsync(group.Id)
|
||||
.Returns(group);
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetByIdAsync(organization.Id)
|
||||
.Returns(organization);
|
||||
|
||||
var scimPatchModel = new Models.ScimPatchModel
|
||||
var scimPatchModel = new ScimPatchModel
|
||||
{
|
||||
Operations = new List<ScimPatchModel.OperationModel>
|
||||
{
|
||||
new ScimPatchModel.OperationModel
|
||||
new()
|
||||
{
|
||||
Op = "replace",
|
||||
Path = "displayname",
|
||||
@ -71,27 +76,55 @@ public class PatchGroupCommandTests
|
||||
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
|
||||
};
|
||||
|
||||
await sutProvider.Sut.PatchGroupAsync(organization, group.Id, scimPatchModel);
|
||||
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
|
||||
|
||||
await sutProvider.GetDependency<IUpdateGroupCommand>().Received(1).UpdateGroupAsync(group, organization, EventSystemUser.SCIM);
|
||||
Assert.Equal(displayName, group.Name);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PatchGroup_ReplaceDisplayNameFromPath_MissingOrganization_Throws(
|
||||
SutProvider<PatchGroupCommand> sutProvider, Organization organization, Group group, string displayName)
|
||||
{
|
||||
group.OrganizationId = organization.Id;
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetByIdAsync(organization.Id)
|
||||
.Returns((Organization)null);
|
||||
|
||||
var scimPatchModel = new ScimPatchModel
|
||||
{
|
||||
Operations = new List<ScimPatchModel.OperationModel>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Op = "replace",
|
||||
Path = "displayname",
|
||||
Value = JsonDocument.Parse($"\"{displayName}\"").RootElement
|
||||
}
|
||||
},
|
||||
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
|
||||
};
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(() => sutProvider.Sut.PatchGroupAsync(group, scimPatchModel));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PatchGroup_ReplaceDisplayNameFromValueObject_Success(SutProvider<PatchGroupCommand> sutProvider, Organization organization, Group group, string displayName)
|
||||
{
|
||||
group.OrganizationId = organization.Id;
|
||||
|
||||
sutProvider.GetDependency<IGroupRepository>()
|
||||
.GetByIdAsync(group.Id)
|
||||
.Returns(group);
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetByIdAsync(organization.Id)
|
||||
.Returns(organization);
|
||||
|
||||
var scimPatchModel = new Models.ScimPatchModel
|
||||
var scimPatchModel = new ScimPatchModel
|
||||
{
|
||||
Operations = new List<ScimPatchModel.OperationModel>
|
||||
{
|
||||
new ScimPatchModel.OperationModel
|
||||
new()
|
||||
{
|
||||
Op = "replace",
|
||||
Value = JsonDocument.Parse($"{{\"displayName\":\"{displayName}\"}}").RootElement
|
||||
@ -100,12 +133,39 @@ public class PatchGroupCommandTests
|
||||
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
|
||||
};
|
||||
|
||||
await sutProvider.Sut.PatchGroupAsync(organization, group.Id, scimPatchModel);
|
||||
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
|
||||
|
||||
await sutProvider.GetDependency<IUpdateGroupCommand>().Received(1).UpdateGroupAsync(group, organization, EventSystemUser.SCIM);
|
||||
Assert.Equal(displayName, group.Name);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PatchGroup_ReplaceDisplayNameFromValueObject_MissingOrganization_Throws(
|
||||
SutProvider<PatchGroupCommand> sutProvider, Organization organization, Group group, string displayName)
|
||||
{
|
||||
group.OrganizationId = organization.Id;
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetByIdAsync(organization.Id)
|
||||
.Returns((Organization)null);
|
||||
|
||||
var scimPatchModel = new ScimPatchModel
|
||||
{
|
||||
Operations = new List<ScimPatchModel.OperationModel>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Op = "replace",
|
||||
Value = JsonDocument.Parse($"{{\"displayName\":\"{displayName}\"}}").RootElement
|
||||
}
|
||||
},
|
||||
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
|
||||
};
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(() => sutProvider.Sut.PatchGroupAsync(group, scimPatchModel));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PatchGroup_AddSingleMember_Success(SutProvider<PatchGroupCommand> sutProvider, Organization organization, Group group, ICollection<Guid> existingMembers, Guid userId)
|
||||
@ -113,18 +173,14 @@ public class PatchGroupCommandTests
|
||||
group.OrganizationId = organization.Id;
|
||||
|
||||
sutProvider.GetDependency<IGroupRepository>()
|
||||
.GetByIdAsync(group.Id)
|
||||
.Returns(group);
|
||||
|
||||
sutProvider.GetDependency<IGroupRepository>()
|
||||
.GetManyUserIdsByIdAsync(group.Id)
|
||||
.GetManyUserIdsByIdAsync(group.Id, true)
|
||||
.Returns(existingMembers);
|
||||
|
||||
var scimPatchModel = new Models.ScimPatchModel
|
||||
var scimPatchModel = new ScimPatchModel
|
||||
{
|
||||
Operations = new List<ScimPatchModel.OperationModel>
|
||||
{
|
||||
new ScimPatchModel.OperationModel
|
||||
new()
|
||||
{
|
||||
Op = "add",
|
||||
Path = $"members[value eq \"{userId}\"]",
|
||||
@ -133,9 +189,47 @@ public class PatchGroupCommandTests
|
||||
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
|
||||
};
|
||||
|
||||
await sutProvider.Sut.PatchGroupAsync(organization, group.Id, scimPatchModel);
|
||||
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
|
||||
|
||||
await sutProvider.GetDependency<IGroupRepository>().Received(1).UpdateUsersAsync(group.Id, Arg.Is<IEnumerable<Guid>>(arg => arg.All(id => existingMembers.Append(userId).Contains(id))));
|
||||
await sutProvider.GetDependency<IGroupRepository>().Received(1).AddGroupUsersByIdAsync(
|
||||
group.Id,
|
||||
Arg.Is<IEnumerable<Guid>>(arg => arg.Single() == userId));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PatchGroup_AddSingleMember_ReturnsEarlyIfAlreadyInGroup(
|
||||
SutProvider<PatchGroupCommand> sutProvider,
|
||||
Organization organization,
|
||||
Group group,
|
||||
ICollection<Guid> existingMembers)
|
||||
{
|
||||
// User being added is already in group
|
||||
var userId = existingMembers.First();
|
||||
group.OrganizationId = organization.Id;
|
||||
|
||||
sutProvider.GetDependency<IGroupRepository>()
|
||||
.GetManyUserIdsByIdAsync(group.Id, true)
|
||||
.Returns(existingMembers);
|
||||
|
||||
var scimPatchModel = new ScimPatchModel
|
||||
{
|
||||
Operations = new List<ScimPatchModel.OperationModel>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Op = "add",
|
||||
Path = $"members[value eq \"{userId}\"]",
|
||||
}
|
||||
},
|
||||
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
|
||||
};
|
||||
|
||||
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
|
||||
|
||||
await sutProvider.GetDependency<IGroupRepository>()
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
.AddGroupUsersByIdAsync(default, default);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@ -145,18 +239,14 @@ public class PatchGroupCommandTests
|
||||
group.OrganizationId = organization.Id;
|
||||
|
||||
sutProvider.GetDependency<IGroupRepository>()
|
||||
.GetByIdAsync(group.Id)
|
||||
.Returns(group);
|
||||
|
||||
sutProvider.GetDependency<IGroupRepository>()
|
||||
.GetManyUserIdsByIdAsync(group.Id)
|
||||
.GetManyUserIdsByIdAsync(group.Id, true)
|
||||
.Returns(existingMembers);
|
||||
|
||||
var scimPatchModel = new Models.ScimPatchModel
|
||||
var scimPatchModel = new ScimPatchModel
|
||||
{
|
||||
Operations = new List<ScimPatchModel.OperationModel>
|
||||
{
|
||||
new ScimPatchModel.OperationModel
|
||||
new()
|
||||
{
|
||||
Op = "add",
|
||||
Path = $"members",
|
||||
@ -166,9 +256,101 @@ public class PatchGroupCommandTests
|
||||
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
|
||||
};
|
||||
|
||||
await sutProvider.Sut.PatchGroupAsync(organization, group.Id, scimPatchModel);
|
||||
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
|
||||
|
||||
await sutProvider.GetDependency<IGroupRepository>().Received(1).UpdateUsersAsync(group.Id, Arg.Is<IEnumerable<Guid>>(arg => arg.All(id => existingMembers.Concat(userIds).Contains(id))));
|
||||
await sutProvider.GetDependency<IGroupRepository>().Received(1).AddGroupUsersByIdAsync(
|
||||
group.Id,
|
||||
Arg.Is<IEnumerable<Guid>>(arg =>
|
||||
arg.Count() == userIds.Count &&
|
||||
arg.ToHashSet().SetEquals(userIds)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PatchGroup_AddListMembers_IgnoresDuplicatesInRequest(
|
||||
SutProvider<PatchGroupCommand> sutProvider, Organization organization, Group group,
|
||||
ICollection<Guid> existingMembers)
|
||||
{
|
||||
// Create 3 userIds
|
||||
var fixture = new Fixture { RepeatCount = 3 };
|
||||
var userIds = fixture.CreateMany<Guid>().ToList();
|
||||
|
||||
// Copy the list and add a duplicate
|
||||
var userIdsWithDuplicate = userIds.Append(userIds.First()).ToList();
|
||||
Assert.Equal(4, userIdsWithDuplicate.Count);
|
||||
|
||||
group.OrganizationId = organization.Id;
|
||||
|
||||
sutProvider.GetDependency<IGroupRepository>()
|
||||
.GetManyUserIdsByIdAsync(group.Id, true)
|
||||
.Returns(existingMembers);
|
||||
|
||||
var scimPatchModel = new ScimPatchModel
|
||||
{
|
||||
Operations = new List<ScimPatchModel.OperationModel>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Op = "add",
|
||||
Path = $"members",
|
||||
Value = JsonDocument.Parse(JsonSerializer
|
||||
.Serialize(userIdsWithDuplicate
|
||||
.Select(uid => new { value = uid })
|
||||
.ToArray())).RootElement
|
||||
}
|
||||
},
|
||||
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
|
||||
};
|
||||
|
||||
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
|
||||
|
||||
await sutProvider.GetDependency<IGroupRepository>().Received(1).AddGroupUsersByIdAsync(
|
||||
group.Id,
|
||||
Arg.Is<IEnumerable<Guid>>(arg =>
|
||||
arg.Count() == 3 &&
|
||||
arg.ToHashSet().SetEquals(userIds)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PatchGroup_AddListMembers_SuccessIfOnlySomeUsersAreInGroup(
|
||||
SutProvider<PatchGroupCommand> sutProvider,
|
||||
Organization organization, Group group,
|
||||
ICollection<Guid> existingMembers,
|
||||
ICollection<Guid> userIds)
|
||||
{
|
||||
// A user is already in the group, but some still need to be added
|
||||
userIds.Add(existingMembers.First());
|
||||
|
||||
group.OrganizationId = organization.Id;
|
||||
|
||||
sutProvider.GetDependency<IGroupRepository>()
|
||||
.GetManyUserIdsByIdAsync(group.Id, true)
|
||||
.Returns(existingMembers);
|
||||
|
||||
var scimPatchModel = new ScimPatchModel
|
||||
{
|
||||
Operations = new List<ScimPatchModel.OperationModel>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Op = "add",
|
||||
Path = $"members",
|
||||
Value = JsonDocument.Parse(JsonSerializer.Serialize(userIds.Select(uid => new { value = uid }).ToArray())).RootElement
|
||||
}
|
||||
},
|
||||
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
|
||||
};
|
||||
|
||||
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
|
||||
|
||||
await sutProvider.GetDependency<IGroupRepository>()
|
||||
.Received(1)
|
||||
.AddGroupUsersByIdAsync(
|
||||
group.Id,
|
||||
Arg.Is<IEnumerable<Guid>>(arg =>
|
||||
arg.Count() == userIds.Count &&
|
||||
arg.ToHashSet().SetEquals(userIds)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@ -177,10 +359,6 @@ public class PatchGroupCommandTests
|
||||
{
|
||||
group.OrganizationId = organization.Id;
|
||||
|
||||
sutProvider.GetDependency<IGroupRepository>()
|
||||
.GetByIdAsync(group.Id)
|
||||
.Returns(group);
|
||||
|
||||
var scimPatchModel = new Models.ScimPatchModel
|
||||
{
|
||||
Operations = new List<ScimPatchModel.OperationModel>
|
||||
@ -194,21 +372,19 @@ public class PatchGroupCommandTests
|
||||
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
|
||||
};
|
||||
|
||||
await sutProvider.Sut.PatchGroupAsync(organization, group.Id, scimPatchModel);
|
||||
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
|
||||
|
||||
await sutProvider.GetDependency<IGroupService>().Received(1).DeleteUserAsync(group, userId, EventSystemUser.SCIM);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PatchGroup_RemoveListMembers_Success(SutProvider<PatchGroupCommand> sutProvider, Organization organization, Group group, ICollection<Guid> existingMembers)
|
||||
public async Task PatchGroup_RemoveListMembers_Success(SutProvider<PatchGroupCommand> sutProvider,
|
||||
Organization organization, Group group, ICollection<Guid> existingMembers)
|
||||
{
|
||||
List<Guid> usersToRemove = [existingMembers.First(), existingMembers.Skip(1).First()];
|
||||
group.OrganizationId = organization.Id;
|
||||
|
||||
sutProvider.GetDependency<IGroupRepository>()
|
||||
.GetByIdAsync(group.Id)
|
||||
.Returns(group);
|
||||
|
||||
sutProvider.GetDependency<IGroupRepository>()
|
||||
.GetManyUserIdsByIdAsync(group.Id)
|
||||
.Returns(existingMembers);
|
||||
@ -217,30 +393,58 @@ public class PatchGroupCommandTests
|
||||
{
|
||||
Operations = new List<ScimPatchModel.OperationModel>
|
||||
{
|
||||
new ScimPatchModel.OperationModel
|
||||
new()
|
||||
{
|
||||
Op = "remove",
|
||||
Path = $"members",
|
||||
Value = JsonDocument.Parse(JsonSerializer.Serialize(existingMembers.Select(uid => new { value = uid }).ToArray())).RootElement
|
||||
Value = JsonDocument.Parse(JsonSerializer.Serialize(usersToRemove.Select(uid => new { value = uid }).ToArray())).RootElement
|
||||
}
|
||||
},
|
||||
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
|
||||
};
|
||||
|
||||
await sutProvider.Sut.PatchGroupAsync(organization, group.Id, scimPatchModel);
|
||||
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
|
||||
|
||||
await sutProvider.GetDependency<IGroupRepository>().Received(1).UpdateUsersAsync(group.Id, Arg.Is<IEnumerable<Guid>>(arg => arg.All(id => existingMembers.Contains(id))));
|
||||
var expectedRemainingUsers = existingMembers.Skip(2).ToList();
|
||||
await sutProvider.GetDependency<IGroupRepository>()
|
||||
.Received(1)
|
||||
.UpdateUsersAsync(
|
||||
group.Id,
|
||||
Arg.Is<IEnumerable<Guid>>(arg =>
|
||||
arg.Count() == expectedRemainingUsers.Count &&
|
||||
arg.ToHashSet().SetEquals(expectedRemainingUsers)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PatchGroup_NoAction_Success(SutProvider<PatchGroupCommand> sutProvider, Organization organization, Group group)
|
||||
public async Task PatchGroup_InvalidOperation_Success(SutProvider<PatchGroupCommand> sutProvider, Organization organization, Group group)
|
||||
{
|
||||
group.OrganizationId = organization.Id;
|
||||
|
||||
sutProvider.GetDependency<IGroupRepository>()
|
||||
.GetByIdAsync(group.Id)
|
||||
.Returns(group);
|
||||
var scimPatchModel = new Models.ScimPatchModel
|
||||
{
|
||||
Operations = [new ScimPatchModel.OperationModel { Op = "invalid operation" }],
|
||||
Schemas = [ScimConstants.Scim2SchemaUser]
|
||||
};
|
||||
|
||||
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
|
||||
|
||||
// Assert: no operation performed
|
||||
await sutProvider.GetDependency<IGroupRepository>().DidNotReceiveWithAnyArgs().UpdateUsersAsync(default, default);
|
||||
await sutProvider.GetDependency<IGroupRepository>().DidNotReceiveWithAnyArgs().GetManyUserIdsByIdAsync(default);
|
||||
await sutProvider.GetDependency<IUpdateGroupCommand>().DidNotReceiveWithAnyArgs().UpdateGroupAsync(default, default);
|
||||
await sutProvider.GetDependency<IGroupService>().DidNotReceiveWithAnyArgs().DeleteUserAsync(default, default);
|
||||
|
||||
// Assert: logging
|
||||
sutProvider.GetDependency<ILogger<PatchGroupCommand>>().ReceivedWithAnyArgs().LogWarning(default);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PatchGroup_NoOperation_Success(
|
||||
SutProvider<PatchGroupCommand> sutProvider, Organization organization, Group group)
|
||||
{
|
||||
group.OrganizationId = organization.Id;
|
||||
|
||||
var scimPatchModel = new Models.ScimPatchModel
|
||||
{
|
||||
@ -248,45 +452,11 @@ public class PatchGroupCommandTests
|
||||
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
|
||||
};
|
||||
|
||||
await sutProvider.Sut.PatchGroupAsync(organization, group.Id, scimPatchModel);
|
||||
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
|
||||
|
||||
await sutProvider.GetDependency<IGroupRepository>().DidNotReceiveWithAnyArgs().UpdateUsersAsync(default, default);
|
||||
await sutProvider.GetDependency<IGroupRepository>().DidNotReceiveWithAnyArgs().GetManyUserIdsByIdAsync(default);
|
||||
await sutProvider.GetDependency<IUpdateGroupCommand>().DidNotReceiveWithAnyArgs().UpdateGroupAsync(default, default);
|
||||
await sutProvider.GetDependency<IGroupService>().DidNotReceiveWithAnyArgs().DeleteUserAsync(default, default);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PatchGroup_NotFound_Throws(SutProvider<PatchGroupCommand> sutProvider, Organization organization, Guid groupId)
|
||||
{
|
||||
var scimPatchModel = new Models.ScimPatchModel
|
||||
{
|
||||
Operations = new List<ScimPatchModel.OperationModel>(),
|
||||
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
|
||||
};
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.PatchGroupAsync(organization, groupId, scimPatchModel));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PatchGroup_MismatchingOrganizationId_Throws(SutProvider<PatchGroupCommand> sutProvider, Organization organization, Guid groupId)
|
||||
{
|
||||
var scimPatchModel = new Models.ScimPatchModel
|
||||
{
|
||||
Operations = new List<ScimPatchModel.OperationModel>(),
|
||||
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IGroupRepository>()
|
||||
.GetByIdAsync(groupId)
|
||||
.Returns(new Group
|
||||
{
|
||||
Id = groupId,
|
||||
OrganizationId = Guid.NewGuid()
|
||||
});
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.PatchGroupAsync(organization, groupId, scimPatchModel));
|
||||
}
|
||||
}
|
||||
|
@ -1,381 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using AutoFixture;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Scim.Groups;
|
||||
using Bit.Scim.Models;
|
||||
using Bit.Scim.Utilities;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Scim.Test.Groups;
|
||||
|
||||
[SutProviderCustomize]
|
||||
public class PatchGroupCommandvNextTests
|
||||
{
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PatchGroup_ReplaceListMembers_Success(SutProvider<PatchGroupCommandvNext> sutProvider,
|
||||
Organization organization, Group group, IEnumerable<Guid> userIds)
|
||||
{
|
||||
group.OrganizationId = organization.Id;
|
||||
|
||||
var scimPatchModel = new ScimPatchModel
|
||||
{
|
||||
Operations = new List<ScimPatchModel.OperationModel>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Op = "replace",
|
||||
Path = "members",
|
||||
Value = JsonDocument.Parse(JsonSerializer.Serialize(userIds.Select(uid => new { value = uid }).ToArray())).RootElement
|
||||
}
|
||||
},
|
||||
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
|
||||
};
|
||||
|
||||
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
|
||||
|
||||
await sutProvider.GetDependency<IGroupRepository>().Received(1).UpdateUsersAsync(
|
||||
group.Id,
|
||||
Arg.Is<IEnumerable<Guid>>(arg =>
|
||||
arg.Count() == userIds.Count() &&
|
||||
arg.ToHashSet().SetEquals(userIds)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PatchGroup_ReplaceDisplayNameFromPath_Success(
|
||||
SutProvider<PatchGroupCommandvNext> sutProvider, Organization organization, Group group, string displayName)
|
||||
{
|
||||
group.OrganizationId = organization.Id;
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetByIdAsync(organization.Id)
|
||||
.Returns(organization);
|
||||
|
||||
var scimPatchModel = new ScimPatchModel
|
||||
{
|
||||
Operations = new List<ScimPatchModel.OperationModel>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Op = "replace",
|
||||
Path = "displayname",
|
||||
Value = JsonDocument.Parse($"\"{displayName}\"").RootElement
|
||||
}
|
||||
},
|
||||
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
|
||||
};
|
||||
|
||||
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
|
||||
|
||||
await sutProvider.GetDependency<IUpdateGroupCommand>().Received(1).UpdateGroupAsync(group, organization, EventSystemUser.SCIM);
|
||||
Assert.Equal(displayName, group.Name);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PatchGroup_ReplaceDisplayNameFromValueObject_Success(SutProvider<PatchGroupCommandvNext> sutProvider, Organization organization, Group group, string displayName)
|
||||
{
|
||||
group.OrganizationId = organization.Id;
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetByIdAsync(organization.Id)
|
||||
.Returns(organization);
|
||||
|
||||
var scimPatchModel = new ScimPatchModel
|
||||
{
|
||||
Operations = new List<ScimPatchModel.OperationModel>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Op = "replace",
|
||||
Value = JsonDocument.Parse($"{{\"displayName\":\"{displayName}\"}}").RootElement
|
||||
}
|
||||
},
|
||||
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
|
||||
};
|
||||
|
||||
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
|
||||
|
||||
await sutProvider.GetDependency<IUpdateGroupCommand>().Received(1).UpdateGroupAsync(group, organization, EventSystemUser.SCIM);
|
||||
Assert.Equal(displayName, group.Name);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PatchGroup_AddSingleMember_Success(SutProvider<PatchGroupCommandvNext> sutProvider, Organization organization, Group group, ICollection<Guid> existingMembers, Guid userId)
|
||||
{
|
||||
group.OrganizationId = organization.Id;
|
||||
|
||||
sutProvider.GetDependency<IGroupRepository>()
|
||||
.GetManyUserIdsByIdAsync(group.Id, true)
|
||||
.Returns(existingMembers);
|
||||
|
||||
var scimPatchModel = new ScimPatchModel
|
||||
{
|
||||
Operations = new List<ScimPatchModel.OperationModel>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Op = "add",
|
||||
Path = $"members[value eq \"{userId}\"]",
|
||||
}
|
||||
},
|
||||
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
|
||||
};
|
||||
|
||||
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
|
||||
|
||||
await sutProvider.GetDependency<IGroupRepository>().Received(1).AddGroupUsersByIdAsync(
|
||||
group.Id,
|
||||
Arg.Is<IEnumerable<Guid>>(arg => arg.Single() == userId));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PatchGroup_AddSingleMember_ReturnsEarlyIfAlreadyInGroup(
|
||||
SutProvider<PatchGroupCommandvNext> sutProvider,
|
||||
Organization organization,
|
||||
Group group,
|
||||
ICollection<Guid> existingMembers)
|
||||
{
|
||||
// User being added is already in group
|
||||
var userId = existingMembers.First();
|
||||
group.OrganizationId = organization.Id;
|
||||
|
||||
sutProvider.GetDependency<IGroupRepository>()
|
||||
.GetManyUserIdsByIdAsync(group.Id, true)
|
||||
.Returns(existingMembers);
|
||||
|
||||
var scimPatchModel = new ScimPatchModel
|
||||
{
|
||||
Operations = new List<ScimPatchModel.OperationModel>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Op = "add",
|
||||
Path = $"members[value eq \"{userId}\"]",
|
||||
}
|
||||
},
|
||||
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
|
||||
};
|
||||
|
||||
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
|
||||
|
||||
await sutProvider.GetDependency<IGroupRepository>()
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
.AddGroupUsersByIdAsync(default, default);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PatchGroup_AddListMembers_Success(SutProvider<PatchGroupCommandvNext> sutProvider, Organization organization, Group group, ICollection<Guid> existingMembers, ICollection<Guid> userIds)
|
||||
{
|
||||
group.OrganizationId = organization.Id;
|
||||
|
||||
sutProvider.GetDependency<IGroupRepository>()
|
||||
.GetManyUserIdsByIdAsync(group.Id, true)
|
||||
.Returns(existingMembers);
|
||||
|
||||
var scimPatchModel = new ScimPatchModel
|
||||
{
|
||||
Operations = new List<ScimPatchModel.OperationModel>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Op = "add",
|
||||
Path = $"members",
|
||||
Value = JsonDocument.Parse(JsonSerializer.Serialize(userIds.Select(uid => new { value = uid }).ToArray())).RootElement
|
||||
}
|
||||
},
|
||||
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
|
||||
};
|
||||
|
||||
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
|
||||
|
||||
await sutProvider.GetDependency<IGroupRepository>().Received(1).AddGroupUsersByIdAsync(
|
||||
group.Id,
|
||||
Arg.Is<IEnumerable<Guid>>(arg =>
|
||||
arg.Count() == userIds.Count &&
|
||||
arg.ToHashSet().SetEquals(userIds)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PatchGroup_AddListMembers_IgnoresDuplicatesInRequest(
|
||||
SutProvider<PatchGroupCommandvNext> sutProvider, Organization organization, Group group,
|
||||
ICollection<Guid> existingMembers)
|
||||
{
|
||||
// Create 3 userIds
|
||||
var fixture = new Fixture { RepeatCount = 3 };
|
||||
var userIds = fixture.CreateMany<Guid>().ToList();
|
||||
|
||||
// Copy the list and add a duplicate
|
||||
var userIdsWithDuplicate = userIds.Append(userIds.First()).ToList();
|
||||
Assert.Equal(4, userIdsWithDuplicate.Count);
|
||||
|
||||
group.OrganizationId = organization.Id;
|
||||
|
||||
sutProvider.GetDependency<IGroupRepository>()
|
||||
.GetManyUserIdsByIdAsync(group.Id, true)
|
||||
.Returns(existingMembers);
|
||||
|
||||
var scimPatchModel = new ScimPatchModel
|
||||
{
|
||||
Operations = new List<ScimPatchModel.OperationModel>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Op = "add",
|
||||
Path = $"members",
|
||||
Value = JsonDocument.Parse(JsonSerializer
|
||||
.Serialize(userIdsWithDuplicate
|
||||
.Select(uid => new { value = uid })
|
||||
.ToArray())).RootElement
|
||||
}
|
||||
},
|
||||
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
|
||||
};
|
||||
|
||||
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
|
||||
|
||||
await sutProvider.GetDependency<IGroupRepository>().Received(1).AddGroupUsersByIdAsync(
|
||||
group.Id,
|
||||
Arg.Is<IEnumerable<Guid>>(arg =>
|
||||
arg.Count() == 3 &&
|
||||
arg.ToHashSet().SetEquals(userIds)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PatchGroup_AddListMembers_SuccessIfOnlySomeUsersAreInGroup(
|
||||
SutProvider<PatchGroupCommandvNext> sutProvider,
|
||||
Organization organization, Group group,
|
||||
ICollection<Guid> existingMembers,
|
||||
ICollection<Guid> userIds)
|
||||
{
|
||||
// A user is already in the group, but some still need to be added
|
||||
userIds.Add(existingMembers.First());
|
||||
|
||||
group.OrganizationId = organization.Id;
|
||||
|
||||
sutProvider.GetDependency<IGroupRepository>()
|
||||
.GetManyUserIdsByIdAsync(group.Id, true)
|
||||
.Returns(existingMembers);
|
||||
|
||||
var scimPatchModel = new ScimPatchModel
|
||||
{
|
||||
Operations = new List<ScimPatchModel.OperationModel>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Op = "add",
|
||||
Path = $"members",
|
||||
Value = JsonDocument.Parse(JsonSerializer.Serialize(userIds.Select(uid => new { value = uid }).ToArray())).RootElement
|
||||
}
|
||||
},
|
||||
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
|
||||
};
|
||||
|
||||
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
|
||||
|
||||
await sutProvider.GetDependency<IGroupRepository>()
|
||||
.Received(1)
|
||||
.AddGroupUsersByIdAsync(
|
||||
group.Id,
|
||||
Arg.Is<IEnumerable<Guid>>(arg =>
|
||||
arg.Count() == userIds.Count &&
|
||||
arg.ToHashSet().SetEquals(userIds)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PatchGroup_RemoveSingleMember_Success(SutProvider<PatchGroupCommandvNext> sutProvider, Organization organization, Group group, Guid userId)
|
||||
{
|
||||
group.OrganizationId = organization.Id;
|
||||
|
||||
var scimPatchModel = new Models.ScimPatchModel
|
||||
{
|
||||
Operations = new List<ScimPatchModel.OperationModel>
|
||||
{
|
||||
new ScimPatchModel.OperationModel
|
||||
{
|
||||
Op = "remove",
|
||||
Path = $"members[value eq \"{userId}\"]",
|
||||
}
|
||||
},
|
||||
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
|
||||
};
|
||||
|
||||
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
|
||||
|
||||
await sutProvider.GetDependency<IGroupService>().Received(1).DeleteUserAsync(group, userId, EventSystemUser.SCIM);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PatchGroup_RemoveListMembers_Success(SutProvider<PatchGroupCommandvNext> sutProvider,
|
||||
Organization organization, Group group, ICollection<Guid> existingMembers)
|
||||
{
|
||||
List<Guid> usersToRemove = [existingMembers.First(), existingMembers.Skip(1).First()];
|
||||
group.OrganizationId = organization.Id;
|
||||
|
||||
sutProvider.GetDependency<IGroupRepository>()
|
||||
.GetManyUserIdsByIdAsync(group.Id)
|
||||
.Returns(existingMembers);
|
||||
|
||||
var scimPatchModel = new Models.ScimPatchModel
|
||||
{
|
||||
Operations = new List<ScimPatchModel.OperationModel>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Op = "remove",
|
||||
Path = $"members",
|
||||
Value = JsonDocument.Parse(JsonSerializer.Serialize(usersToRemove.Select(uid => new { value = uid }).ToArray())).RootElement
|
||||
}
|
||||
},
|
||||
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
|
||||
};
|
||||
|
||||
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
|
||||
|
||||
var expectedRemainingUsers = existingMembers.Skip(2).ToList();
|
||||
await sutProvider.GetDependency<IGroupRepository>()
|
||||
.Received(1)
|
||||
.UpdateUsersAsync(
|
||||
group.Id,
|
||||
Arg.Is<IEnumerable<Guid>>(arg =>
|
||||
arg.Count() == expectedRemainingUsers.Count &&
|
||||
arg.ToHashSet().SetEquals(expectedRemainingUsers)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PatchGroup_NoAction_Success(
|
||||
SutProvider<PatchGroupCommandvNext> sutProvider, Organization organization, Group group)
|
||||
{
|
||||
group.OrganizationId = organization.Id;
|
||||
|
||||
var scimPatchModel = new Models.ScimPatchModel
|
||||
{
|
||||
Operations = new List<ScimPatchModel.OperationModel>(),
|
||||
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
|
||||
};
|
||||
|
||||
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
|
||||
|
||||
await sutProvider.GetDependency<IGroupRepository>().DidNotReceiveWithAnyArgs().UpdateUsersAsync(default, default);
|
||||
await sutProvider.GetDependency<IGroupRepository>().DidNotReceiveWithAnyArgs().GetManyUserIdsByIdAsync(default);
|
||||
await sutProvider.GetDependency<IUpdateGroupCommand>().DidNotReceiveWithAnyArgs().UpdateGroupAsync(default, default);
|
||||
await sutProvider.GetDependency<IGroupService>().DidNotReceiveWithAnyArgs().DeleteUserAsync(default, default);
|
||||
}
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
@ -43,7 +44,7 @@ public class PatchUserCommandTests
|
||||
|
||||
await sutProvider.Sut.PatchUserAsync(organizationUser.OrganizationId, organizationUser.Id, scimPatchModel);
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationService>().Received(1).RestoreUserAsync(organizationUser, EventSystemUser.SCIM);
|
||||
await sutProvider.GetDependency<IRestoreOrganizationUserCommand>().Received(1).RestoreUserAsync(organizationUser, EventSystemUser.SCIM);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@ -71,7 +72,7 @@ public class PatchUserCommandTests
|
||||
|
||||
await sutProvider.Sut.PatchUserAsync(organizationUser.OrganizationId, organizationUser.Id, scimPatchModel);
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationService>().Received(1).RestoreUserAsync(organizationUser, EventSystemUser.SCIM);
|
||||
await sutProvider.GetDependency<IRestoreOrganizationUserCommand>().Received(1).RestoreUserAsync(organizationUser, EventSystemUser.SCIM);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@ -147,7 +148,7 @@ public class PatchUserCommandTests
|
||||
|
||||
await sutProvider.Sut.PatchUserAsync(organizationUser.OrganizationId, organizationUser.Id, scimPatchModel);
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationService>().DidNotReceiveWithAnyArgs().RestoreUserAsync(default, EventSystemUser.SCIM);
|
||||
await sutProvider.GetDependency<IRestoreOrganizationUserCommand>().DidNotReceiveWithAnyArgs().RestoreUserAsync(default, EventSystemUser.SCIM);
|
||||
await sutProvider.GetDependency<IOrganizationService>().DidNotReceiveWithAnyArgs().RevokeUserAsync(default, EventSystemUser.SCIM);
|
||||
}
|
||||
|
||||
|
@ -27,7 +27,7 @@ public class PostUserCommandTests
|
||||
ExternalId = externalId,
|
||||
Emails = emails,
|
||||
Active = true,
|
||||
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
|
||||
Schemas = [ScimConstants.Scim2SchemaUser]
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
@ -39,13 +39,16 @@ public class PostUserCommandTests
|
||||
sutProvider.GetDependency<IPaymentService>().HasSecretsManagerStandalone(organization).Returns(true);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationService>()
|
||||
.InviteUserAsync(organizationId, invitingUserId: null, EventSystemUser.SCIM,
|
||||
.InviteUserAsync(organizationId,
|
||||
invitingUserId: null,
|
||||
EventSystemUser.SCIM,
|
||||
Arg.Is<OrganizationUserInvite>(i =>
|
||||
i.Emails.Single().Equals(scimUserRequestModel.PrimaryEmail.ToLowerInvariant()) &&
|
||||
i.Type == OrganizationUserType.User &&
|
||||
!i.Collections.Any() &&
|
||||
!i.Groups.Any() &&
|
||||
i.AccessSecretsManager), externalId)
|
||||
i.AccessSecretsManager),
|
||||
externalId)
|
||||
.Returns(newUser);
|
||||
|
||||
var user = await sutProvider.Sut.PostUserAsync(organizationId, scimUserRequestModel);
|
||||
|
@ -11,6 +11,7 @@ MAILCATCHER_PORT=1080
|
||||
# Alternative databases
|
||||
POSTGRES_PASSWORD=SET_A_PASSWORD_HERE_123
|
||||
MYSQL_ROOT_PASSWORD=SET_A_PASSWORD_HERE_123
|
||||
MARIADB_ROOT_PASSWORD=SET_A_PASSWORD_HERE_123
|
||||
|
||||
# IdP configuration
|
||||
# Complete using the values from the Manage SSO page in the web vault
|
||||
|
@ -70,6 +70,20 @@ services:
|
||||
profiles:
|
||||
- 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:
|
||||
image: kenchan0130/simplesamlphp:1.19.8
|
||||
container_name: idp
|
||||
@ -124,8 +138,20 @@ services:
|
||||
profiles:
|
||||
- servicebus
|
||||
|
||||
redis:
|
||||
image: redis:alpine
|
||||
container_name: bw-redis
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
command: redis-server --appendonly yes
|
||||
profiles:
|
||||
- redis
|
||||
|
||||
volumes:
|
||||
mssql_dev_data:
|
||||
postgres_dev_data:
|
||||
mysql_dev_data:
|
||||
rabbitmq_data:
|
||||
redis_data:
|
||||
|
0
dev/ef_migrate.ps1
Normal file → Executable file
0
dev/ef_migrate.ps1
Normal file → Executable file
@ -5,6 +5,7 @@ param(
|
||||
[switch]$all,
|
||||
[switch]$postgres,
|
||||
[switch]$mysql,
|
||||
[switch]$mariadb,
|
||||
[switch]$mssql,
|
||||
[switch]$sqlite,
|
||||
[switch]$selfhost,
|
||||
@ -15,11 +16,15 @@ param(
|
||||
$ErrorActionPreference = "Stop"
|
||||
$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;
|
||||
}
|
||||
|
||||
if ($all -or $postgres -or $mysql -or $sqlite) {
|
||||
if ($all -or $(Get-IsEFDatabase)) {
|
||||
dotnet ef *> $null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
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 @(
|
||||
@($mysql, "MySQL", "MySqlMigrations", "mySql", 2),
|
||||
@($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) {
|
||||
continue
|
||||
|
@ -25,6 +25,45 @@
|
||||
"Subscriptions": [
|
||||
{
|
||||
"Name": "events-write-subscription"
|
||||
},
|
||||
{
|
||||
"Name": "events-slack-subscription"
|
||||
},
|
||||
{
|
||||
"Name": "events-webhook-subscription"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "event-integrations",
|
||||
"Subscriptions": [
|
||||
{
|
||||
"Name": "integration-slack-subscription",
|
||||
"Rules": [
|
||||
{
|
||||
"Name": "slack-integration-filter",
|
||||
"Properties": {
|
||||
"FilterType": "Correlation",
|
||||
"CorrelationFilter": {
|
||||
"Label": "slack"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "integration-webhook-subscription",
|
||||
"Rules": [
|
||||
{
|
||||
"Name": "webhook-integration-filter",
|
||||
"Properties": {
|
||||
"FilterType": "Correlation",
|
||||
"CorrelationFilter": {
|
||||
"Label": "webhook"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
@ -4,6 +4,7 @@
|
||||
"rollForward": "latestFeature"
|
||||
},
|
||||
"msbuild-sdks": {
|
||||
"Microsoft.Build.Traversal": "4.1.0"
|
||||
"Microsoft.Build.Traversal": "4.1.0",
|
||||
"Microsoft.Build.Sql": "0.1.9-preview"
|
||||
}
|
||||
}
|
||||
|
@ -40,8 +40,6 @@ export function authenticate(
|
||||
payload["deviceName"] = "chrome";
|
||||
payload["username"] = username;
|
||||
payload["password"] = password;
|
||||
|
||||
params.headers["Auth-Email"] = encoding.b64encode(username);
|
||||
} else {
|
||||
payload["scope"] = "api.organization";
|
||||
payload["grant_type"] = "client_credentials";
|
||||
|
90
perf/load/sync.js
Normal file
90
perf/load/sync.js
Normal file
@ -0,0 +1,90 @@
|
||||
import http from "k6/http";
|
||||
import { check, fail } from "k6";
|
||||
import { authenticate } from "./helpers/auth.js";
|
||||
|
||||
const IDENTITY_URL = __ENV.IDENTITY_URL;
|
||||
const API_URL = __ENV.API_URL;
|
||||
const CLIENT_ID = __ENV.CLIENT_ID;
|
||||
const AUTH_USERNAME = __ENV.AUTH_USER_EMAIL;
|
||||
const AUTH_PASSWORD = __ENV.AUTH_USER_PASSWORD_HASH;
|
||||
|
||||
export const options = {
|
||||
ext: {
|
||||
loadimpact: {
|
||||
projectID: 3639465,
|
||||
name: "Sync",
|
||||
},
|
||||
},
|
||||
scenarios: {
|
||||
constant_load: {
|
||||
executor: "constant-arrival-rate",
|
||||
rate: 30,
|
||||
timeUnit: "1m", // 0.5 requests / second
|
||||
duration: "10m",
|
||||
preAllocatedVUs: 5,
|
||||
},
|
||||
ramping_load: {
|
||||
executor: "ramping-arrival-rate",
|
||||
startRate: 30,
|
||||
timeUnit: "1m", // 0.5 requests / second to start
|
||||
stages: [
|
||||
{ duration: "30s", target: 30 },
|
||||
{ duration: "2m", target: 75 },
|
||||
{ duration: "1m", target: 60 },
|
||||
{ duration: "2m", target: 100 },
|
||||
{ duration: "2m", target: 90 },
|
||||
{ duration: "1m", target: 120 },
|
||||
{ duration: "30s", target: 150 },
|
||||
{ duration: "30s", target: 60 },
|
||||
{ duration: "30s", target: 0 },
|
||||
],
|
||||
preAllocatedVUs: 20,
|
||||
},
|
||||
},
|
||||
thresholds: {
|
||||
http_req_failed: ["rate<0.01"],
|
||||
http_req_duration: ["p(95)<1200"],
|
||||
},
|
||||
};
|
||||
|
||||
export function setup() {
|
||||
return authenticate(IDENTITY_URL, CLIENT_ID, AUTH_USERNAME, AUTH_PASSWORD);
|
||||
}
|
||||
|
||||
export default function (data) {
|
||||
const params = {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${data.access_token}`,
|
||||
"X-ClientId": CLIENT_ID,
|
||||
},
|
||||
tags: { name: "Sync" },
|
||||
};
|
||||
|
||||
const excludeDomains = Math.random() > 0.5;
|
||||
|
||||
const syncRes = http.get(`${API_URL}/sync?excludeDomains=${excludeDomains}`, params);
|
||||
if (
|
||||
!check(syncRes, {
|
||||
"sync status is 200": (r) => r.status === 200,
|
||||
})
|
||||
) {
|
||||
console.error(`Sync failed with status ${syncRes.status}: ${syncRes.body}`);
|
||||
fail("sync status code was *not* 200");
|
||||
}
|
||||
|
||||
if (syncRes.status === 200) {
|
||||
const syncJson = syncRes.json();
|
||||
|
||||
check(syncJson, {
|
||||
"sync response has profile": (j) => j.profile !== undefined,
|
||||
"sync response has folders": (j) => Array.isArray(j.folders),
|
||||
"sync response has collections": (j) => Array.isArray(j.collections),
|
||||
"sync response has ciphers": (j) => Array.isArray(j.ciphers),
|
||||
"sync response has policies": (j) => Array.isArray(j.policies),
|
||||
"sync response has sends": (j) => Array.isArray(j.sends),
|
||||
"sync response has correct object type": (j) => j.object === "sync"
|
||||
});
|
||||
}
|
||||
}
|
@ -1,4 +0,0 @@
|
||||
*
|
||||
!obj/build-output/publish/*
|
||||
!obj/Docker/empty/
|
||||
!entrypoint.sh
|
@ -14,9 +14,6 @@
|
||||
<ProjectReference Include="..\Core\Core.csproj" />
|
||||
<ProjectReference Include="..\..\util\SqliteMigrations\SqliteMigrations.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Folder Include="Billing\Controllers\" />
|
||||
</ItemGroup>
|
||||
|
||||
<Choose>
|
||||
<When Condition="!$(DefineConstants.Contains('OSS'))">
|
||||
|
@ -11,7 +11,7 @@ using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Billing.Providers.Services;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.OrganizationConnectionConfigs;
|
||||
@ -462,6 +462,8 @@ public class OrganizationsController : Controller
|
||||
organization.UsersGetPremium = model.UsersGetPremium;
|
||||
organization.UseSecretsManager = model.UseSecretsManager;
|
||||
organization.UseRiskInsights = model.UseRiskInsights;
|
||||
organization.UseOrganizationDomains = model.UseOrganizationDomains;
|
||||
organization.UseAdminSponsoredFamilies = model.UseAdminSponsoredFamilies;
|
||||
|
||||
//secrets
|
||||
organization.SmSeats = model.SmSeats;
|
||||
|
@ -3,18 +3,20 @@ using System.Net;
|
||||
using Bit.Admin.AdminConsole.Models;
|
||||
using Bit.Admin.Enums;
|
||||
using Bit.Admin.Utilities;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.Providers.Interfaces;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Billing.Entities;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Repositories;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Billing.Services.Contracts;
|
||||
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.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
@ -23,6 +25,7 @@ using Bit.Core.Settings;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Admin.AdminConsole.Controllers;
|
||||
|
||||
@ -44,6 +47,7 @@ public class ProvidersController : Controller
|
||||
private readonly IProviderPlanRepository _providerPlanRepository;
|
||||
private readonly IProviderBillingService _providerBillingService;
|
||||
private readonly IPricingClient _pricingClient;
|
||||
private readonly IStripeAdapter _stripeAdapter;
|
||||
private readonly string _stripeUrl;
|
||||
private readonly string _braintreeMerchantUrl;
|
||||
private readonly string _braintreeMerchantId;
|
||||
@ -63,7 +67,8 @@ public class ProvidersController : Controller
|
||||
IProviderPlanRepository providerPlanRepository,
|
||||
IProviderBillingService providerBillingService,
|
||||
IWebHostEnvironment webHostEnvironment,
|
||||
IPricingClient pricingClient)
|
||||
IPricingClient pricingClient,
|
||||
IStripeAdapter stripeAdapter)
|
||||
{
|
||||
_organizationRepository = organizationRepository;
|
||||
_organizationService = organizationService;
|
||||
@ -79,6 +84,7 @@ public class ProvidersController : Controller
|
||||
_providerPlanRepository = providerPlanRepository;
|
||||
_providerBillingService = providerBillingService;
|
||||
_pricingClient = pricingClient;
|
||||
_stripeAdapter = stripeAdapter;
|
||||
_stripeUrl = webHostEnvironment.GetStripeUrl();
|
||||
_braintreeMerchantUrl = webHostEnvironment.GetBraintreeMerchantUrl();
|
||||
_braintreeMerchantId = globalSettings.Braintree.MerchantId;
|
||||
@ -133,10 +139,10 @@ public class ProvidersController : Controller
|
||||
return View(new CreateResellerProviderModel());
|
||||
}
|
||||
|
||||
[HttpGet("providers/create/multi-organization-enterprise")]
|
||||
public IActionResult CreateMultiOrganizationEnterprise(int enterpriseMinimumSeats, string ownerEmail = null)
|
||||
[HttpGet("providers/create/business-unit")]
|
||||
public IActionResult CreateBusinessUnit(int enterpriseMinimumSeats, string ownerEmail = null)
|
||||
{
|
||||
return View(new CreateMultiOrganizationEnterpriseProviderModel
|
||||
return View(new CreateBusinessUnitProviderModel
|
||||
{
|
||||
OwnerEmail = ownerEmail,
|
||||
EnterpriseSeatMinimum = enterpriseMinimumSeats
|
||||
@ -157,7 +163,7 @@ public class ProvidersController : Controller
|
||||
{
|
||||
ProviderType.Msp => RedirectToAction("CreateMsp"),
|
||||
ProviderType.Reseller => RedirectToAction("CreateReseller"),
|
||||
ProviderType.MultiOrganizationEnterprise => RedirectToAction("CreateMultiOrganizationEnterprise"),
|
||||
ProviderType.BusinessUnit => RedirectToAction("CreateBusinessUnit"),
|
||||
_ => View(model)
|
||||
};
|
||||
}
|
||||
@ -198,10 +204,10 @@ public class ProvidersController : Controller
|
||||
return RedirectToAction("Edit", new { id = provider.Id });
|
||||
}
|
||||
|
||||
[HttpPost("providers/create/multi-organization-enterprise")]
|
||||
[HttpPost("providers/create/business-unit")]
|
||||
[ValidateAntiForgeryToken]
|
||||
[RequirePermission(Permission.Provider_Create)]
|
||||
public async Task<IActionResult> CreateMultiOrganizationEnterprise(CreateMultiOrganizationEnterpriseProviderModel model)
|
||||
public async Task<IActionResult> CreateBusinessUnit(CreateBusinessUnitProviderModel model)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
@ -209,7 +215,7 @@ public class ProvidersController : Controller
|
||||
}
|
||||
var provider = model.ToProvider();
|
||||
|
||||
await _createProviderCommand.CreateMultiOrganizationEnterpriseAsync(
|
||||
await _createProviderCommand.CreateBusinessUnitAsync(
|
||||
provider,
|
||||
model.OwnerEmail,
|
||||
model.Plan.Value,
|
||||
@ -300,29 +306,44 @@ public class ProvidersController : Controller
|
||||
{
|
||||
case ProviderType.Msp:
|
||||
var updateMspSeatMinimumsCommand = new UpdateProviderSeatMinimumsCommand(
|
||||
provider.Id,
|
||||
provider.GatewaySubscriptionId,
|
||||
provider,
|
||||
[
|
||||
(Plan: PlanType.TeamsMonthly, SeatsMinimum: model.TeamsMonthlySeatMinimum),
|
||||
(Plan: PlanType.EnterpriseMonthly, SeatsMinimum: model.EnterpriseMonthlySeatMinimum)
|
||||
]);
|
||||
await _providerBillingService.UpdateSeatMinimums(updateMspSeatMinimumsCommand);
|
||||
|
||||
if (_featureService.IsEnabled(FeatureFlagKeys.PM199566_UpdateMSPToChargeAutomatically))
|
||||
{
|
||||
var customer = await _stripeAdapter.CustomerGetAsync(provider.GatewayCustomerId);
|
||||
|
||||
if (model.PayByInvoice != customer.ApprovedToPayByInvoice())
|
||||
{
|
||||
var approvedToPayByInvoice = model.PayByInvoice ? "1" : "0";
|
||||
await _stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions
|
||||
{
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
[StripeConstants.MetadataKeys.InvoiceApproved] = approvedToPayByInvoice
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
case ProviderType.MultiOrganizationEnterprise:
|
||||
case ProviderType.BusinessUnit:
|
||||
{
|
||||
var existingMoePlan = providerPlans.Single();
|
||||
|
||||
// 1. Change the plan and take over any old values.
|
||||
var changeMoePlanCommand = new ChangeProviderPlanCommand(
|
||||
provider,
|
||||
existingMoePlan.Id,
|
||||
model.Plan!.Value,
|
||||
provider.GatewaySubscriptionId);
|
||||
model.Plan!.Value);
|
||||
await _providerBillingService.ChangePlan(changeMoePlanCommand);
|
||||
|
||||
// 2. Update the seat minimums.
|
||||
var updateMoeSeatMinimumsCommand = new UpdateProviderSeatMinimumsCommand(
|
||||
provider.Id,
|
||||
provider.GatewaySubscriptionId,
|
||||
provider,
|
||||
[
|
||||
(Plan: model.Plan!.Value, SeatsMinimum: model.EnterpriseMinimumSeats!.Value)
|
||||
]);
|
||||
@ -347,14 +368,18 @@ public class ProvidersController : Controller
|
||||
|
||||
if (!provider.IsBillable())
|
||||
{
|
||||
return new ProviderEditModel(provider, users, providerOrganizations, new List<ProviderPlan>());
|
||||
return new ProviderEditModel(provider, users, providerOrganizations, new List<ProviderPlan>(), false);
|
||||
}
|
||||
|
||||
var providerPlans = await _providerPlanRepository.GetByProviderId(id);
|
||||
|
||||
var payByInvoice =
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM199566_UpdateMSPToChargeAutomatically) &&
|
||||
(await _stripeAdapter.CustomerGetAsync(provider.GatewayCustomerId)).ApprovedToPayByInvoice();
|
||||
|
||||
return new ProviderEditModel(
|
||||
provider, users, providerOrganizations,
|
||||
providerPlans.ToList(), GetGatewayCustomerUrl(provider), GetGatewaySubscriptionUrl(provider));
|
||||
providerPlans.ToList(), payByInvoice, GetGatewayCustomerUrl(provider), GetGatewaySubscriptionUrl(provider));
|
||||
}
|
||||
|
||||
[RequirePermission(Permission.Provider_ResendEmailInvite)]
|
||||
@ -445,6 +470,19 @@ public class ProvidersController : Controller
|
||||
[RequirePermission(Permission.Provider_Edit)]
|
||||
public async Task<IActionResult> Delete(Guid id, string providerName)
|
||||
{
|
||||
var provider = await _providerRepository.GetByIdAsync(id);
|
||||
|
||||
if (provider is null)
|
||||
{
|
||||
return BadRequest("Provider does not exist");
|
||||
}
|
||||
|
||||
if (provider.Status == ProviderStatusType.Pending)
|
||||
{
|
||||
await _providerService.DeleteAsync(provider);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(providerName))
|
||||
{
|
||||
return BadRequest("Invalid provider name");
|
||||
@ -457,13 +495,6 @@ public class ProvidersController : Controller
|
||||
return BadRequest("You must unlink all clients before you can delete a provider");
|
||||
}
|
||||
|
||||
var provider = await _providerRepository.GetByIdAsync(id);
|
||||
|
||||
if (provider is null)
|
||||
{
|
||||
return BadRequest("Provider does not exist");
|
||||
}
|
||||
|
||||
if (!string.Equals(providerName.Trim(), provider.DisplayName(), StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return BadRequest("Invalid provider name");
|
||||
|
@ -6,7 +6,7 @@ using Bit.SharedWeb.Utilities;
|
||||
|
||||
namespace Bit.Admin.AdminConsole.Models;
|
||||
|
||||
public class CreateMultiOrganizationEnterpriseProviderModel : IValidatableObject
|
||||
public class CreateBusinessUnitProviderModel : IValidatableObject
|
||||
{
|
||||
[Display(Name = "Owner Email")]
|
||||
public string OwnerEmail { get; set; }
|
||||
@ -22,7 +22,7 @@ public class CreateMultiOrganizationEnterpriseProviderModel : IValidatableObject
|
||||
{
|
||||
return new Provider
|
||||
{
|
||||
Type = ProviderType.MultiOrganizationEnterprise
|
||||
Type = ProviderType.BusinessUnit
|
||||
};
|
||||
}
|
||||
|
||||
@ -30,17 +30,17 @@ public class CreateMultiOrganizationEnterpriseProviderModel : IValidatableObject
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(OwnerEmail))
|
||||
{
|
||||
var ownerEmailDisplayName = nameof(OwnerEmail).GetDisplayAttribute<CreateMultiOrganizationEnterpriseProviderModel>()?.GetName() ?? nameof(OwnerEmail);
|
||||
var ownerEmailDisplayName = nameof(OwnerEmail).GetDisplayAttribute<CreateBusinessUnitProviderModel>()?.GetName() ?? nameof(OwnerEmail);
|
||||
yield return new ValidationResult($"The {ownerEmailDisplayName} field is required.");
|
||||
}
|
||||
if (EnterpriseSeatMinimum < 0)
|
||||
{
|
||||
var enterpriseSeatMinimumDisplayName = nameof(EnterpriseSeatMinimum).GetDisplayAttribute<CreateMultiOrganizationEnterpriseProviderModel>()?.GetName() ?? nameof(EnterpriseSeatMinimum);
|
||||
var enterpriseSeatMinimumDisplayName = nameof(EnterpriseSeatMinimum).GetDisplayAttribute<CreateBusinessUnitProviderModel>()?.GetName() ?? nameof(EnterpriseSeatMinimum);
|
||||
yield return new ValidationResult($"The {enterpriseSeatMinimumDisplayName} field can not be negative.");
|
||||
}
|
||||
if (Plan != PlanType.EnterpriseAnnually && Plan != PlanType.EnterpriseMonthly)
|
||||
{
|
||||
var planDisplayName = nameof(Plan).GetDisplayAttribute<CreateMultiOrganizationEnterpriseProviderModel>()?.GetName() ?? nameof(Plan);
|
||||
var planDisplayName = nameof(Plan).GetDisplayAttribute<CreateBusinessUnitProviderModel>()?.GetName() ?? nameof(Plan);
|
||||
yield return new ValidationResult($"The {planDisplayName} field must be set to Enterprise Annually or Enterprise Monthly.");
|
||||
}
|
||||
}
|
@ -86,6 +86,7 @@ public class OrganizationEditModel : OrganizationViewModel
|
||||
UseApi = org.UseApi;
|
||||
UseSecretsManager = org.UseSecretsManager;
|
||||
UseRiskInsights = org.UseRiskInsights;
|
||||
UseAdminSponsoredFamilies = org.UseAdminSponsoredFamilies;
|
||||
UseResetPassword = org.UseResetPassword;
|
||||
SelfHost = org.SelfHost;
|
||||
UsersGetPremium = org.UsersGetPremium;
|
||||
@ -101,7 +102,7 @@ public class OrganizationEditModel : OrganizationViewModel
|
||||
MaxAutoscaleSmSeats = org.MaxAutoscaleSmSeats;
|
||||
SmServiceAccounts = org.SmServiceAccounts;
|
||||
MaxAutoscaleSmServiceAccounts = org.MaxAutoscaleSmServiceAccounts;
|
||||
|
||||
UseOrganizationDomains = org.UseOrganizationDomains;
|
||||
_plans = plans;
|
||||
}
|
||||
|
||||
@ -154,6 +155,8 @@ public class OrganizationEditModel : OrganizationViewModel
|
||||
public new bool UseSecretsManager { get; set; }
|
||||
[Display(Name = "Risk Insights")]
|
||||
public new bool UseRiskInsights { get; set; }
|
||||
[Display(Name = "Admin Sponsored Families")]
|
||||
public bool UseAdminSponsoredFamilies { get; set; }
|
||||
[Display(Name = "Self Host")]
|
||||
public bool SelfHost { get; set; }
|
||||
[Display(Name = "Users Get Premium")]
|
||||
@ -183,6 +186,8 @@ public class OrganizationEditModel : OrganizationViewModel
|
||||
public int? SmServiceAccounts { get; set; }
|
||||
[Display(Name = "Max Autoscale Machine Accounts")]
|
||||
public int? MaxAutoscaleSmServiceAccounts { get; set; }
|
||||
[Display(Name = "Use Organization Domains")]
|
||||
public bool UseOrganizationDomains { get; set; }
|
||||
|
||||
/**
|
||||
* Creates a Plan[] object for use in Javascript
|
||||
@ -212,6 +217,7 @@ public class OrganizationEditModel : OrganizationViewModel
|
||||
Has2fa = p.Has2fa,
|
||||
HasApi = p.HasApi,
|
||||
HasSso = p.HasSso,
|
||||
HasOrganizationDomains = p.HasOrganizationDomains,
|
||||
HasKeyConnector = p.HasKeyConnector,
|
||||
HasScim = p.HasScim,
|
||||
HasResetPassword = p.HasResetPassword,
|
||||
@ -295,6 +301,7 @@ public class OrganizationEditModel : OrganizationViewModel
|
||||
existingOrganization.UseApi = UseApi;
|
||||
existingOrganization.UseSecretsManager = UseSecretsManager;
|
||||
existingOrganization.UseRiskInsights = UseRiskInsights;
|
||||
existingOrganization.UseAdminSponsoredFamilies = UseAdminSponsoredFamilies;
|
||||
existingOrganization.UseResetPassword = UseResetPassword;
|
||||
existingOrganization.SelfHost = SelfHost;
|
||||
existingOrganization.UsersGetPremium = UsersGetPremium;
|
||||
@ -311,6 +318,7 @@ public class OrganizationEditModel : OrganizationViewModel
|
||||
existingOrganization.MaxAutoscaleSmSeats = MaxAutoscaleSmSeats;
|
||||
existingOrganization.SmServiceAccounts = SmServiceAccounts;
|
||||
existingOrganization.MaxAutoscaleSmServiceAccounts = MaxAutoscaleSmServiceAccounts;
|
||||
existingOrganization.UseOrganizationDomains = UseOrganizationDomains;
|
||||
return existingOrganization;
|
||||
}
|
||||
}
|
||||
|
@ -44,6 +44,8 @@ public class OrganizationViewModel
|
||||
orgUsers
|
||||
.Where(u => u.Type == OrganizationUserType.Admin && u.Status == organizationUserStatus)
|
||||
.Select(u => u.Email));
|
||||
OwnersDetails = orgUsers.Where(u => u.Type == OrganizationUserType.Owner && u.Status == organizationUserStatus);
|
||||
AdminsDetails = orgUsers.Where(u => u.Type == OrganizationUserType.Admin && u.Status == organizationUserStatus);
|
||||
SecretsCount = secretsCount;
|
||||
ProjectsCount = projectCount;
|
||||
ServiceAccountsCount = serviceAccountsCount;
|
||||
@ -70,4 +72,6 @@ public class OrganizationViewModel
|
||||
public int OccupiedSmSeatsCount { get; set; }
|
||||
public bool UseSecretsManager => Organization.UseSecretsManager;
|
||||
public bool UseRiskInsights => Organization.UseRiskInsights;
|
||||
public IEnumerable<OrganizationUserUserDetails> OwnersDetails { get; set; }
|
||||
public IEnumerable<OrganizationUserUserDetails> AdminsDetails { get; set; }
|
||||
}
|
||||
|
@ -2,8 +2,8 @@
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.Models.Data.Provider;
|
||||
using Bit.Core.Billing.Entities;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Providers.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.SharedWeb.Utilities;
|
||||
|
||||
@ -18,6 +18,7 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject
|
||||
IEnumerable<ProviderUserUserDetails> providerUsers,
|
||||
IEnumerable<ProviderOrganizationOrganizationDetails> organizations,
|
||||
IReadOnlyCollection<ProviderPlan> providerPlans,
|
||||
bool payByInvoice,
|
||||
string gatewayCustomerUrl = null,
|
||||
string gatewaySubscriptionUrl = null) : base(provider, providerUsers, organizations, providerPlans)
|
||||
{
|
||||
@ -33,8 +34,9 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject
|
||||
GatewayCustomerUrl = gatewayCustomerUrl;
|
||||
GatewaySubscriptionUrl = gatewaySubscriptionUrl;
|
||||
Type = provider.Type;
|
||||
PayByInvoice = payByInvoice;
|
||||
|
||||
if (Type == ProviderType.MultiOrganizationEnterprise)
|
||||
if (Type == ProviderType.BusinessUnit)
|
||||
{
|
||||
var plan = providerPlans.SingleOrDefault();
|
||||
EnterpriseMinimumSeats = plan?.SeatMinimum ?? 0;
|
||||
@ -62,6 +64,8 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject
|
||||
public string GatewaySubscriptionId { get; set; }
|
||||
public string GatewayCustomerUrl { get; }
|
||||
public string GatewaySubscriptionUrl { get; }
|
||||
[Display(Name = "Pay By Invoice")]
|
||||
public bool PayByInvoice { get; set; }
|
||||
[Display(Name = "Provider Type")]
|
||||
public ProviderType Type { get; set; }
|
||||
|
||||
@ -100,7 +104,7 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject
|
||||
yield return new ValidationResult($"The {billingEmailDisplayName} field is required.");
|
||||
}
|
||||
break;
|
||||
case ProviderType.MultiOrganizationEnterprise:
|
||||
case ProviderType.BusinessUnit:
|
||||
if (Plan == null)
|
||||
{
|
||||
var displayName = nameof(Plan).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(Plan);
|
||||
|
@ -2,8 +2,8 @@
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.Models.Data.Provider;
|
||||
using Bit.Core.Billing.Entities;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Providers.Entities;
|
||||
|
||||
namespace Bit.Admin.AdminConsole.Models;
|
||||
|
||||
@ -19,7 +19,7 @@ public class ProviderViewModel
|
||||
{
|
||||
Provider = provider;
|
||||
UserCount = providerUsers.Count();
|
||||
ProviderAdmins = providerUsers.Where(u => u.Type == ProviderUserType.ProviderAdmin);
|
||||
ProviderUsers = providerUsers;
|
||||
ProviderOrganizations = organizations.Where(o => o.ProviderId == provider.Id);
|
||||
|
||||
if (Provider.Type == ProviderType.Msp)
|
||||
@ -40,7 +40,7 @@ public class ProviderViewModel
|
||||
ProviderPlanViewModels.Add(new ProviderPlanViewModel("Enterprise (Monthly) Subscription", enterpriseProviderPlan, usedEnterpriseSeats));
|
||||
}
|
||||
}
|
||||
else if (Provider.Type == ProviderType.MultiOrganizationEnterprise)
|
||||
else if (Provider.Type == ProviderType.BusinessUnit)
|
||||
{
|
||||
var usedEnterpriseSeats = ProviderOrganizations.Where(po => po.PlanType == PlanType.EnterpriseMonthly)
|
||||
.Sum(po => po.OccupiedSeats).GetValueOrDefault(0);
|
||||
@ -61,7 +61,7 @@ public class ProviderViewModel
|
||||
|
||||
public int UserCount { get; set; }
|
||||
public Provider Provider { get; set; }
|
||||
public IEnumerable<ProviderUserUserDetails> ProviderAdmins { get; set; }
|
||||
public IEnumerable<ProviderUserUserDetails> ProviderUsers { get; set; }
|
||||
public IEnumerable<ProviderOrganizationOrganizationDetails> ProviderOrganizations { get; set; }
|
||||
public List<ProviderPlanViewModel> ProviderPlanViewModels { get; set; } = [];
|
||||
}
|
||||
|
@ -1,7 +1,8 @@
|
||||
@using Bit.Admin.Enums;
|
||||
@using Bit.Admin.Models
|
||||
@using Bit.Core.AdminConsole.Enums.Provider
|
||||
@using Bit.Core.Billing.Enums
|
||||
@using Bit.Core.Enums
|
||||
@using Bit.Core.Billing.Extensions
|
||||
@inject Bit.Admin.Services.IAccessControlService AccessControlService
|
||||
@model OrganizationEditModel
|
||||
@{
|
||||
@ -13,6 +14,11 @@
|
||||
var canRequestDelete = AccessControlService.UserHasPermission(Permission.Org_RequestDelete);
|
||||
var canDelete = AccessControlService.UserHasPermission(Permission.Org_Delete);
|
||||
var canUnlinkFromProvider = AccessControlService.UserHasPermission(Permission.Provider_Edit);
|
||||
|
||||
var canConvertToBusinessUnit = AccessControlService.UserHasPermission(Permission.Org_Billing_ConvertToBusinessUnit) &&
|
||||
Model.Organization.PlanType.GetProductTier() == ProductTierType.Enterprise &&
|
||||
!string.IsNullOrEmpty(Model.Organization.GatewaySubscriptionId) &&
|
||||
Model.Provider is null or { Type: ProviderType.BusinessUnit, Status: ProviderStatusType.Pending };
|
||||
}
|
||||
|
||||
@section Scripts {
|
||||
@ -114,6 +120,15 @@
|
||||
Enterprise Trial
|
||||
</button>
|
||||
}
|
||||
@if (canConvertToBusinessUnit)
|
||||
{
|
||||
<a asp-controller="BusinessUnitConversion"
|
||||
asp-action="Index"
|
||||
asp-route-organizationId="@Model.Organization.Id"
|
||||
class="btn btn-secondary me-2">
|
||||
Convert to Business Unit
|
||||
</a>
|
||||
}
|
||||
@if (canUnlinkFromProvider && Model.Provider is not null)
|
||||
{
|
||||
<button class="btn btn-outline-danger me-2"
|
||||
|
@ -19,12 +19,6 @@
|
||||
<span id="org-confirmed-users" title="Confirmed">@Model.UserConfirmedCount</span>)
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-4 col-lg-3">Owners</dt>
|
||||
<dd id="org-owner" class="col-sm-8 col-lg-9">@(string.IsNullOrWhiteSpace(Model.Owners) ? "None" : Model.Owners)</dd>
|
||||
|
||||
<dt class="col-sm-4 col-lg-3">Admins</dt>
|
||||
<dd id="org-admins" class="col-sm-8 col-lg-9">@(string.IsNullOrWhiteSpace(Model.Admins) ? "None" : Model.Admins)</dd>
|
||||
|
||||
<dt class="col-sm-4 col-lg-3">Using 2FA</dt>
|
||||
<dd id="org-2fa" class="col-sm-8 col-lg-9">@(Model.Organization.TwoFactorIsEnabled() ? "Yes" : "No")</dd>
|
||||
|
||||
@ -76,3 +70,49 @@
|
||||
<dt class="col-sm-4 col-lg-3">Secrets Manager Seats</dt>
|
||||
<dd id="sm-seat-count" class="col-sm-8 col-lg-9">@(Model.UseSecretsManager ? Model.OccupiedSmSeatsCount: "N/A" )</dd>
|
||||
</dl>
|
||||
|
||||
<h2>Administrators</h2>
|
||||
<dl class="row">
|
||||
<div class="table-responsive">
|
||||
<div class="col-8">
|
||||
<table class="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 190px;">Email</th>
|
||||
<th style="width: 60px;">Role</th>
|
||||
<th style="width: 40px;">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@if(!Model.Admins.Any() && !Model.Owners.Any())
|
||||
{
|
||||
<tr>
|
||||
<td colspan="6">No results to list.</td>
|
||||
</tr>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach(var owner in Model.OwnersDetails)
|
||||
{
|
||||
<tr>
|
||||
<td class="align-middle">@owner.Email</td>
|
||||
<td class="align-middle">Owner</td>
|
||||
<td class="align-middle">@owner.Status</td>
|
||||
</tr>
|
||||
}
|
||||
|
||||
@foreach(var admin in Model.AdminsDetails)
|
||||
{
|
||||
<tr>
|
||||
<td class="align-middle">@admin.Email</td>
|
||||
<td class="align-middle">Admin</td>
|
||||
<td class="align-middle">@admin.Status</td>
|
||||
</tr>
|
||||
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</dl>
|
||||
|
@ -7,7 +7,7 @@
|
||||
var canResendEmailInvite = AccessControlService.UserHasPermission(Permission.Provider_ResendEmailInvite);
|
||||
}
|
||||
|
||||
<h2>Provider Admins</h2>
|
||||
<h2>Administrators</h2>
|
||||
<div class="row">
|
||||
<div class="col-8">
|
||||
<div class="table-responsive">
|
||||
@ -15,12 +15,13 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 190px;">Email</th>
|
||||
<th style="width: 160px;">Role</th>
|
||||
<th style="width: 40px;">Status</th>
|
||||
<th style="width: 30px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@if(!Model.ProviderAdmins.Any())
|
||||
@if(!Model.ProviderUsers.Any())
|
||||
{
|
||||
<tr>
|
||||
<td colspan="6">No results to list.</td>
|
||||
@ -28,29 +29,39 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach(var admin in Model.ProviderAdmins)
|
||||
@foreach(var user in Model.ProviderUsers)
|
||||
{
|
||||
<tr>
|
||||
<td class="align-middle">
|
||||
@admin.Email
|
||||
@user.Email
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
@admin.Status
|
||||
@if(@user.Type == 0)
|
||||
{
|
||||
<span>Provider Admin</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Service User</span>
|
||||
}
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
@user.Status
|
||||
</td>
|
||||
<td>
|
||||
@if(admin.Status.Equals(ProviderUserStatusType.Confirmed)
|
||||
@if(user.Status.Equals(ProviderUserStatusType.Confirmed)
|
||||
&& @Model.Provider.Status.Equals(ProviderStatusType.Pending)
|
||||
&& canResendEmailInvite)
|
||||
{
|
||||
@if(@TempData["InviteResentTo"] != null && @TempData["InviteResentTo"].ToString() == @admin.UserId.Value.ToString())
|
||||
@if(@TempData["InviteResentTo"] != null && @TempData["InviteResentTo"].ToString() == @user.UserId.Value.ToString())
|
||||
{
|
||||
<button class="btn btn-outline-success btn-sm disabled" disabled>Invite Resent!</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a class="btn btn-outline-secondary btn-sm"
|
||||
data-id="@admin.Id" asp-controller="Providers"
|
||||
asp-action="ResendInvite" asp-route-ownerId="@admin.UserId"
|
||||
data-id="@user.Id" asp-controller="Providers"
|
||||
asp-action="ResendInvite" asp-route-ownerId="@user.UserId"
|
||||
asp-route-providerId="@Model.Provider.Id">
|
||||
Resend Setup Invite
|
||||
</a>
|
||||
|
@ -1,15 +1,15 @@
|
||||
@using Bit.Core.Billing.Enums
|
||||
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
|
||||
@model CreateMultiOrganizationEnterpriseProviderModel
|
||||
@model CreateBusinessUnitProviderModel
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Create Multi-organization Enterprise Provider";
|
||||
ViewData["Title"] = "Create Business Unit Provider";
|
||||
}
|
||||
|
||||
<h1 class="mb-4">Create Multi-organization Enterprise Provider</h1>
|
||||
<h1 class="mb-4">Create Business Unit Provider</h1>
|
||||
<div>
|
||||
<form method="post" asp-action="CreateMultiOrganizationEnterprise">
|
||||
<form method="post" asp-action="CreateBusinessUnit">
|
||||
<div asp-validation-summary="All" class="alert alert-danger"></div>
|
||||
<div class="mb-3">
|
||||
<label asp-for="OwnerEmail" class="form-label"></label>
|
||||
@ -19,14 +19,14 @@
|
||||
<div class="col-sm">
|
||||
<div class="mb-3">
|
||||
@{
|
||||
var multiOrgPlans = new List<PlanType>
|
||||
var businessUnitPlanTypes = new List<PlanType>
|
||||
{
|
||||
PlanType.EnterpriseAnnually,
|
||||
PlanType.EnterpriseMonthly
|
||||
};
|
||||
}
|
||||
<label asp-for="Plan" class="form-label"></label>
|
||||
<select class="form-select" asp-for="Plan" asp-items="Html.GetEnumSelectList(multiOrgPlans)">
|
||||
<select class="form-select" asp-for="Plan" asp-items="Html.GetEnumSelectList(businessUnitPlanTypes)">
|
||||
<option value="">--</option>
|
||||
</select>
|
||||
</div>
|
@ -74,20 +74,20 @@
|
||||
</div>
|
||||
break;
|
||||
}
|
||||
case ProviderType.MultiOrganizationEnterprise:
|
||||
case ProviderType.BusinessUnit:
|
||||
{
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<div class="mb-3">
|
||||
@{
|
||||
var multiOrgPlans = new List<PlanType>
|
||||
var businessUnitPlanTypes = new List<PlanType>
|
||||
{
|
||||
PlanType.EnterpriseAnnually,
|
||||
PlanType.EnterpriseMonthly
|
||||
};
|
||||
}
|
||||
<label asp-for="Plan" class="form-label"></label>
|
||||
<select class="form-control" asp-for="Plan" asp-items="Html.GetEnumSelectList(multiOrgPlans)">
|
||||
<select class="form-control" asp-for="Plan" asp-items="Html.GetEnumSelectList(businessUnitPlanTypes)">
|
||||
<option value="">--</option>
|
||||
</select>
|
||||
</div>
|
||||
@ -136,6 +136,17 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@if (FeatureService.IsEnabled(FeatureFlagKeys.PM199566_UpdateMSPToChargeAutomatically) && Model.Provider.Type == ProviderType.Msp && Model.Provider.IsBillable())
|
||||
{
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<div class="form-check mb-3">
|
||||
<input type="checkbox" class="form-check-input" asp-for="PayByInvoice">
|
||||
<label class="form-check-label" asp-for="PayByInvoice"></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</form>
|
||||
@await Html.PartialAsync("Organizations", Model)
|
||||
@ -172,17 +183,29 @@
|
||||
<div class="p-3">
|
||||
<h4 class="fw-bolder" id="exampleModalLabel">Delete provider</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<span class="fw-light">
|
||||
This action is permanent and irreversible. Enter the provider name to complete deletion of the provider and associated data.
|
||||
</span>
|
||||
<form>
|
||||
<div class="mb-3">
|
||||
<label for="provider-name" class="col-form-label">Provider name</label>
|
||||
<input type="text" class="form-control" id="provider-name">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@if (Model.Provider.Status == ProviderStatusType.Pending)
|
||||
{
|
||||
<div class="modal-body">
|
||||
<span class="fw-light">
|
||||
This action is permanent and irreversible.
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="modal-body">
|
||||
<span class="fw-light">
|
||||
This action is permanent and irreversible. Enter the provider name to complete deletion of the provider and associated data.
|
||||
</span>
|
||||
<form>
|
||||
<div class="mb-3">
|
||||
<label for="provider-name" class="col-form-label">Provider name</label>
|
||||
<input type="text" class="form-control" id="provider-name">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-primary btn-pill" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-danger btn-pill" onclick="deleteProvider('@Model.Provider.Id');">Delete provider</button>
|
||||
|
@ -1,9 +1,11 @@
|
||||
@using Bit.Admin.Enums;
|
||||
@using Bit.Core
|
||||
@using Bit.Core.Enums
|
||||
@using Bit.Core.AdminConsole.Enums.Provider
|
||||
@using Bit.Core.Billing.Enums
|
||||
@using Bit.SharedWeb.Utilities
|
||||
@inject Bit.Admin.Services.IAccessControlService AccessControlService;
|
||||
@inject Bit.Core.Services.IFeatureService FeatureService
|
||||
|
||||
@model OrganizationEditModel
|
||||
|
||||
@ -122,6 +124,10 @@
|
||||
<input type="checkbox" class="form-check-input" asp-for="UseSso" disabled='@(canEditPlan ? null : "disabled")'>
|
||||
<label class="form-check-label" asp-for="UseSso"></label>
|
||||
</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">
|
||||
<input type="checkbox" class="form-check-input" asp-for="UseKeyConnector" disabled='@(canEditPlan ? null : "disabled")'>
|
||||
<label class="form-check-label" asp-for="UseKeyConnector"></label>
|
||||
@ -146,6 +152,13 @@
|
||||
<input type="checkbox" class="form-check-input" asp-for="UseCustomPermissions" disabled='@(canEditPlan ? null : "disabled")'>
|
||||
<label class="form-check-label" asp-for="UseCustomPermissions"></label>
|
||||
</div>
|
||||
@if(FeatureService.IsEnabled(FeatureFlagKeys.PM17772_AdminInitiatedSponsorships))
|
||||
{
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" asp-for="UseAdminSponsoredFamilies" disabled='@(canEditPlan ? null : "disabled")'>
|
||||
<label class="form-check-label" asp-for="UseAdminSponsoredFamilies"></label>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<h3>Password Manager</h3>
|
||||
|
@ -69,6 +69,7 @@
|
||||
document.getElementById('@(nameof(Model.UseGroups))').checked = plan.hasGroups;
|
||||
document.getElementById('@(nameof(Model.UsePolicies))').checked = plan.hasPolicies;
|
||||
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.UseDirectory))').checked = plan.hasDirectory;
|
||||
document.getElementById('@(nameof(Model.UseEvents))').checked = plan.hasEvents;
|
||||
|
@ -0,0 +1,183 @@
|
||||
#nullable enable
|
||||
using Bit.Admin.Billing.Models;
|
||||
using Bit.Admin.Enums;
|
||||
using Bit.Admin.Utilities;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Providers.Services;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Bit.Admin.Billing.Controllers;
|
||||
|
||||
[Authorize]
|
||||
[Route("organizations/billing/{organizationId:guid}/business-unit")]
|
||||
public class BusinessUnitConversionController(
|
||||
IBusinessUnitConverter businessUnitConverter,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IProviderRepository providerRepository,
|
||||
IProviderUserRepository providerUserRepository) : Controller
|
||||
{
|
||||
[HttpGet]
|
||||
[RequirePermission(Permission.Org_Billing_ConvertToBusinessUnit)]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task<IActionResult> IndexAsync([FromRoute] Guid organizationId)
|
||||
{
|
||||
var organization = await organizationRepository.GetByIdAsync(organizationId);
|
||||
|
||||
if (organization == null)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var model = new BusinessUnitConversionModel { Organization = organization };
|
||||
|
||||
var invitedProviderAdmin = await GetInvitedProviderAdminAsync(organization);
|
||||
|
||||
if (invitedProviderAdmin != null)
|
||||
{
|
||||
model.ProviderAdminEmail = invitedProviderAdmin.Email;
|
||||
model.ProviderId = invitedProviderAdmin.ProviderId;
|
||||
}
|
||||
|
||||
var success = ReadSuccessMessage();
|
||||
|
||||
if (!string.IsNullOrEmpty(success))
|
||||
{
|
||||
model.Success = success;
|
||||
}
|
||||
|
||||
var errors = ReadErrorMessages();
|
||||
|
||||
if (errors is { Count: > 0 })
|
||||
{
|
||||
model.Errors = errors;
|
||||
}
|
||||
|
||||
return View(model);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[RequirePermission(Permission.Org_Billing_ConvertToBusinessUnit)]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task<IActionResult> InitiateAsync(
|
||||
[FromRoute] Guid organizationId,
|
||||
BusinessUnitConversionModel model)
|
||||
{
|
||||
var organization = await organizationRepository.GetByIdAsync(organizationId);
|
||||
|
||||
if (organization == null)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var result = await businessUnitConverter.InitiateConversion(
|
||||
organization,
|
||||
model.ProviderAdminEmail!);
|
||||
|
||||
return result.Match(
|
||||
providerId => RedirectToAction("Edit", "Providers", new { id = providerId }),
|
||||
errors =>
|
||||
{
|
||||
PersistErrorMessages(errors);
|
||||
return RedirectToAction("Index", new { organizationId });
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("reset")]
|
||||
[RequirePermission(Permission.Org_Billing_ConvertToBusinessUnit)]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task<IActionResult> ResetAsync(
|
||||
[FromRoute] Guid organizationId,
|
||||
BusinessUnitConversionModel model)
|
||||
{
|
||||
var organization = await organizationRepository.GetByIdAsync(organizationId);
|
||||
|
||||
if (organization == null)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
await businessUnitConverter.ResetConversion(organization, model.ProviderAdminEmail!);
|
||||
|
||||
PersistSuccessMessage("Business unit conversion was successfully reset.");
|
||||
|
||||
return RedirectToAction("Index", new { organizationId });
|
||||
}
|
||||
|
||||
[HttpPost("resend-invite")]
|
||||
[RequirePermission(Permission.Org_Billing_ConvertToBusinessUnit)]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task<IActionResult> ResendInviteAsync(
|
||||
[FromRoute] Guid organizationId,
|
||||
BusinessUnitConversionModel model)
|
||||
{
|
||||
var organization = await organizationRepository.GetByIdAsync(organizationId);
|
||||
|
||||
if (organization == null)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
await businessUnitConverter.ResendConversionInvite(organization, model.ProviderAdminEmail!);
|
||||
|
||||
PersistSuccessMessage($"Invite was successfully resent to {model.ProviderAdminEmail}.");
|
||||
|
||||
return RedirectToAction("Index", new { organizationId });
|
||||
}
|
||||
|
||||
private async Task<ProviderUser?> GetInvitedProviderAdminAsync(
|
||||
Organization organization)
|
||||
{
|
||||
var provider = await providerRepository.GetByOrganizationIdAsync(organization.Id);
|
||||
|
||||
if (provider is not
|
||||
{
|
||||
Type: ProviderType.BusinessUnit,
|
||||
Status: ProviderStatusType.Pending
|
||||
})
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var providerUsers =
|
||||
await providerUserRepository.GetManyByProviderAsync(provider.Id, ProviderUserType.ProviderAdmin);
|
||||
|
||||
if (providerUsers.Count != 1)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var providerUser = providerUsers.First();
|
||||
|
||||
return providerUser is
|
||||
{
|
||||
Type: ProviderUserType.ProviderAdmin,
|
||||
Status: ProviderUserStatusType.Invited,
|
||||
UserId: not null
|
||||
} ? providerUser : null;
|
||||
}
|
||||
|
||||
private const string _errors = "errors";
|
||||
private const string _success = "Success";
|
||||
|
||||
private void PersistSuccessMessage(string message) => TempData[_success] = message;
|
||||
private void PersistErrorMessages(List<string> errors)
|
||||
{
|
||||
var input = string.Join("|", errors);
|
||||
TempData[_errors] = input;
|
||||
}
|
||||
private string? ReadSuccessMessage() => ReadTempData<string>(_success);
|
||||
private List<string>? ReadErrorMessages()
|
||||
{
|
||||
var output = ReadTempData<string>(_errors);
|
||||
return string.IsNullOrEmpty(output) ? null : output.Split('|').ToList();
|
||||
}
|
||||
|
||||
private T? ReadTempData<T>(string key) => TempData.TryGetValue(key, out var obj) && obj is T value ? value : default;
|
||||
}
|
@ -1,8 +1,8 @@
|
||||
using Bit.Admin.Billing.Models;
|
||||
using Bit.Admin.Enums;
|
||||
using Bit.Admin.Utilities;
|
||||
using Bit.Core.Billing.Migration.Models;
|
||||
using Bit.Core.Billing.Migration.Services;
|
||||
using Bit.Core.Billing.Providers.Migration.Models;
|
||||
using Bit.Core.Billing.Providers.Migration.Services;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
25
src/Admin/Billing/Models/BusinessUnitConversionModel.cs
Normal file
25
src/Admin/Billing/Models/BusinessUnitConversionModel.cs
Normal file
@ -0,0 +1,25 @@
|
||||
#nullable enable
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||
|
||||
namespace Bit.Admin.Billing.Models;
|
||||
|
||||
public class BusinessUnitConversionModel
|
||||
{
|
||||
[Required]
|
||||
[EmailAddress]
|
||||
[Display(Name = "Provider Admin Email")]
|
||||
public string? ProviderAdminEmail { get; set; }
|
||||
|
||||
[BindNever]
|
||||
public required Organization Organization { get; set; }
|
||||
|
||||
[BindNever]
|
||||
public Guid? ProviderId { get; set; }
|
||||
|
||||
[BindNever]
|
||||
public string? Success { get; set; }
|
||||
|
||||
[BindNever] public List<string>? Errors { get; set; } = [];
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
using Bit.Core.Billing.Entities;
|
||||
using Bit.Core.Billing.Providers.Entities;
|
||||
|
||||
namespace Bit.Admin.Billing.Models;
|
||||
|
||||
|
75
src/Admin/Billing/Views/BusinessUnitConversion/Index.cshtml
Normal file
75
src/Admin/Billing/Views/BusinessUnitConversion/Index.cshtml
Normal file
@ -0,0 +1,75 @@
|
||||
@model Bit.Admin.Billing.Models.BusinessUnitConversionModel
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Convert Organization to Business Unit";
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(Model.ProviderAdminEmail))
|
||||
{
|
||||
<h1>Convert @Model.Organization.Name to Business Unit</h1>
|
||||
@if (!string.IsNullOrEmpty(Model.Success))
|
||||
{
|
||||
<div class="alert alert-success alert-dismissible fade show mb-3" role="alert">
|
||||
@Model.Success
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
}
|
||||
@if (Model.Errors?.Any() ?? false)
|
||||
{
|
||||
@foreach (var error in Model.Errors)
|
||||
{
|
||||
<div class="alert alert-danger alert-dismissible fade show mb-3" role="alert">
|
||||
@error
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
<p>This organization has a business unit conversion in progress.</p>
|
||||
|
||||
<div class="mb-3">
|
||||
<label asp-for="ProviderAdminEmail" class="form-label"></label>
|
||||
<input type="email" class="form-control" asp-for="ProviderAdminEmail" disabled></input>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<form method="post" asp-controller="BusinessUnitConversion" asp-action="ResendInvite" asp-route-organizationId="@Model.Organization.Id">
|
||||
<input type="hidden" asp-for="ProviderAdminEmail" />
|
||||
<button type="submit" class="btn btn-primary mb-2">Resend Invite</button>
|
||||
</form>
|
||||
<form method="post" asp-controller="BusinessUnitConversion" asp-action="Reset" asp-route-organizationId="@Model.Organization.Id">
|
||||
<input type="hidden" asp-for="ProviderAdminEmail" />
|
||||
<button type="submit" class="btn btn-danger mb-2">Reset Conversion</button>
|
||||
</form>
|
||||
@if (Model.ProviderId.HasValue)
|
||||
{
|
||||
<a asp-controller="Providers"
|
||||
asp-action="Edit"
|
||||
asp-route-id="@Model.ProviderId"
|
||||
class="btn btn-secondary mb-2">
|
||||
Go to Provider
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<h1>Convert @Model.Organization.Name to Business Unit</h1>
|
||||
@if (Model.Errors?.Any() ?? false)
|
||||
{
|
||||
@foreach (var error in Model.Errors)
|
||||
{
|
||||
<div class="alert alert-danger alert-dismissible fade show mb-3" role="alert">
|
||||
@error
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
<form method="post" asp-controller="BusinessUnitConversion" asp-action="Initiate" asp-route-organizationId="@Model.Organization.Id">
|
||||
<div asp-validation-summary="All" class="alert alert-danger"></div>
|
||||
<div class="mb-3">
|
||||
<label asp-for="ProviderAdminEmail" class="form-label"></label>
|
||||
<input type="email" class="form-control" asp-for="ProviderAdminEmail" />
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary mb-2">Convert</button>
|
||||
</form>
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
@using System.Text.Json
|
||||
@model Bit.Core.Billing.Migration.Models.ProviderMigrationResult
|
||||
@model Bit.Core.Billing.Providers.Migration.Models.ProviderMigrationResult
|
||||
@{
|
||||
ViewData["Title"] = "Results";
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
@model Bit.Core.Billing.Migration.Models.ProviderMigrationResult[]
|
||||
@model Bit.Core.Billing.Providers.Migration.Models.ProviderMigrationResult[]
|
||||
@{
|
||||
ViewData["Title"] = "Results";
|
||||
}
|
||||
|
@ -4,7 +4,6 @@ using Bit.Admin.Enums;
|
||||
using Bit.Admin.Models;
|
||||
using Bit.Admin.Services;
|
||||
using Bit.Admin.Utilities;
|
||||
using Bit.Core;
|
||||
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
@ -89,7 +88,7 @@ public class UsersController : Controller
|
||||
var ciphers = await _cipherRepository.GetManyByUserIdAsync(id);
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
@ -106,7 +105,7 @@ public class UsersController : Controller
|
||||
var billingInfo = await _paymentService.GetBillingAsync(user);
|
||||
var billingHistoryInfo = await _paymentService.GetBillingHistoryAsync(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);
|
||||
|
||||
return View(new UserEditModel(user, isTwoFactorEnabled, ciphers, billingInfo, billingHistoryInfo, _globalSettings, verifiedDomain, deviceVerificationRequired));
|
||||
@ -167,7 +166,6 @@ public class UsersController : Controller
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
[RequirePermission(Permission.User_NewDeviceException_Edit)]
|
||||
[RequireFeature(FeatureFlagKeys.NewDeviceVerification)]
|
||||
public async Task<IActionResult> ToggleNewDeviceVerification(Guid id)
|
||||
{
|
||||
var user = await _userRepository.GetByIdAsync(id);
|
||||
@ -179,12 +177,4 @@ public class UsersController : Controller
|
||||
await _userService.ToggleNewDeviceVerificationException(user.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.IsManagedByAnyOrganizationAsync(userId)
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
@ -1,21 +1,71 @@
|
||||
###############################################
|
||||
# Build stage #
|
||||
###############################################
|
||||
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
||||
|
||||
# Docker buildx supplies the value for this arg
|
||||
ARG TARGETPLATFORM
|
||||
|
||||
# Determine proper runtime value for .NET
|
||||
RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \
|
||||
RID=linux-x64 ; \
|
||||
elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \
|
||||
RID=linux-arm64 ; \
|
||||
elif [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then \
|
||||
RID=linux-arm ; \
|
||||
fi \
|
||||
&& echo "RID=$RID" > /tmp/rid.txt
|
||||
|
||||
# Set up Node
|
||||
ARG NODE_VERSION=20
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash - \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y nodejs \
|
||||
&& npm install -g npm@latest && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy required project files
|
||||
WORKDIR /source
|
||||
COPY . ./
|
||||
|
||||
# Restore project dependencies and tools
|
||||
WORKDIR /source/src/Admin
|
||||
RUN npm ci
|
||||
RUN . /tmp/rid.txt && dotnet restore -r $RID
|
||||
|
||||
# Build project
|
||||
RUN npm run build
|
||||
RUN . /tmp/rid.txt && dotnet publish \
|
||||
-c release \
|
||||
--no-restore \
|
||||
--self-contained \
|
||||
/p:PublishSingleFile=true \
|
||||
-r $RID \
|
||||
-o out
|
||||
|
||||
###############################################
|
||||
# App stage #
|
||||
###############################################
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:8.0
|
||||
|
||||
ARG TARGETPLATFORM
|
||||
LABEL com.bitwarden.product="bitwarden"
|
||||
ENV ASPNETCORE_ENVIRONMENT=Production
|
||||
ENV ASPNETCORE_URLS=http://+:5000
|
||||
ENV SSL_CERT_DIR=/etc/bitwarden/ca-certificates
|
||||
EXPOSE 5000
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
gosu \
|
||||
curl \
|
||||
krb5-user \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV ASPNETCORE_URLS http://+:5000
|
||||
# Copy app from the build stage
|
||||
WORKDIR /app
|
||||
EXPOSE 5000
|
||||
COPY obj/build-output/publish .
|
||||
COPY entrypoint.sh /
|
||||
COPY --from=build /source/src/Admin/out /app
|
||||
COPY ./src/Admin/entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
HEALTHCHECK CMD curl -f http://localhost:5000 || exit 1
|
||||
HEALTHCHECK CMD curl -f http://localhost:5000/alive || exit 1
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
|
@ -38,6 +38,7 @@ public enum Permission
|
||||
Org_Billing_View,
|
||||
Org_Billing_Edit,
|
||||
Org_Billing_LaunchGateway,
|
||||
Org_Billing_ConvertToBusinessUnit,
|
||||
|
||||
Provider_List_View,
|
||||
Provider_Create,
|
||||
|
@ -17,7 +17,7 @@ public class ChargeBraintreeModel : IValidatableObject
|
||||
{
|
||||
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))
|
||||
{
|
||||
yield return new ValidationResult("Customer Id is not a valid format.");
|
||||
|
@ -10,7 +10,7 @@ using Microsoft.AspNetCore.Mvc.Razor;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Bit.Admin.Services;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Migration;
|
||||
using Bit.Core.Billing.Providers.Migration;
|
||||
|
||||
#if !OSS
|
||||
using Bit.Commercial.Core.Utilities;
|
||||
|
@ -2,7 +2,7 @@
|
||||
using Bit.Core;
|
||||
using Bit.Core.Jobs;
|
||||
using Bit.Core.Tools.Repositories;
|
||||
using Bit.Core.Tools.Services;
|
||||
using Bit.Core.Tools.SendFeatures.Commands.Interfaces;
|
||||
using Quartz;
|
||||
|
||||
namespace Bit.Admin.Tools.Jobs;
|
||||
@ -32,10 +32,10 @@ public class DeleteSendsJob : BaseJob
|
||||
}
|
||||
using (var scope = _serviceProvider.CreateScope())
|
||||
{
|
||||
var sendService = scope.ServiceProvider.GetRequiredService<ISendService>();
|
||||
var nonAnonymousSendCommand = scope.ServiceProvider.GetRequiredService<INonAnonymousSendCommand>();
|
||||
foreach (var send in sends)
|
||||
{
|
||||
await sendService.DeleteSendAsync(send);
|
||||
await nonAnonymousSendCommand.DeleteSendAsync(send);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -42,6 +42,7 @@ public static class RolePermissionMapping
|
||||
Permission.Org_Billing_View,
|
||||
Permission.Org_Billing_Edit,
|
||||
Permission.Org_Billing_LaunchGateway,
|
||||
Permission.Org_Billing_ConvertToBusinessUnit,
|
||||
Permission.Provider_List_View,
|
||||
Permission.Provider_Create,
|
||||
Permission.Provider_View,
|
||||
@ -90,6 +91,7 @@ public static class RolePermissionMapping
|
||||
Permission.Org_Billing_View,
|
||||
Permission.Org_Billing_Edit,
|
||||
Permission.Org_Billing_LaunchGateway,
|
||||
Permission.Org_Billing_ConvertToBusinessUnit,
|
||||
Permission.Org_InitiateTrial,
|
||||
Permission.Provider_List_View,
|
||||
Permission.Provider_Create,
|
||||
@ -166,6 +168,7 @@ public static class RolePermissionMapping
|
||||
Permission.Org_Billing_View,
|
||||
Permission.Org_Billing_Edit,
|
||||
Permission.Org_Billing_LaunchGateway,
|
||||
Permission.Org_Billing_ConvertToBusinessUnit,
|
||||
Permission.Org_RequestDelete,
|
||||
Permission.Provider_Edit,
|
||||
Permission.Provider_View,
|
||||
|
@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Setup
|
||||
|
||||
@ -19,31 +19,36 @@ then
|
||||
LGID=65534
|
||||
fi
|
||||
|
||||
# Create user and group
|
||||
if [ "$(id -u)" = "0" ]
|
||||
then
|
||||
# Create user and group
|
||||
|
||||
groupadd -o -g $LGID $GROUPNAME >/dev/null 2>&1 ||
|
||||
groupmod -o -g $LGID $GROUPNAME >/dev/null 2>&1
|
||||
useradd -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 ||
|
||||
usermod -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1
|
||||
mkhomedir_helper $USERNAME
|
||||
groupadd -o -g $LGID $GROUPNAME >/dev/null 2>&1 ||
|
||||
groupmod -o -g $LGID $GROUPNAME >/dev/null 2>&1
|
||||
useradd -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 ||
|
||||
usermod -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1
|
||||
mkhomedir_helper $USERNAME
|
||||
|
||||
# The rest...
|
||||
# The rest...
|
||||
|
||||
chown -R $USERNAME:$GROUPNAME /app
|
||||
mkdir -p /etc/bitwarden/core
|
||||
mkdir -p /etc/bitwarden/logs
|
||||
mkdir -p /etc/bitwarden/ca-certificates
|
||||
chown -R $USERNAME:$GROUPNAME /etc/bitwarden
|
||||
chown -R $USERNAME:$GROUPNAME /app
|
||||
mkdir -p /etc/bitwarden/core
|
||||
mkdir -p /etc/bitwarden/logs
|
||||
mkdir -p /etc/bitwarden/ca-certificates
|
||||
chown -R $USERNAME:$GROUPNAME /etc/bitwarden
|
||||
|
||||
if [[ $globalSettings__selfHosted == "true" ]]; then
|
||||
cp /etc/bitwarden/ca-certificates/*.crt /usr/local/share/ca-certificates/ >/dev/null 2>&1 \
|
||||
&& update-ca-certificates
|
||||
if [[ -f "/etc/bitwarden/kerberos/bitwarden.keytab" && -f "/etc/bitwarden/kerberos/krb5.conf" ]]; then
|
||||
chown -R $USERNAME:$GROUPNAME /etc/bitwarden/kerberos
|
||||
fi
|
||||
|
||||
gosu_cmd="gosu $USERNAME:$GROUPNAME"
|
||||
else
|
||||
gosu_cmd=""
|
||||
fi
|
||||
|
||||
if [[ -f "/etc/bitwarden/kerberos/bitwarden.keytab" && -f "/etc/bitwarden/kerberos/krb5.conf" ]]; then
|
||||
chown -R $USERNAME:$GROUPNAME /etc/bitwarden/kerberos
|
||||
cp -f /etc/bitwarden/kerberos/krb5.conf /etc/krb5.conf
|
||||
gosu $USERNAME:$GROUPNAME kinit $globalSettings__kerberosUser -k -t /etc/bitwarden/kerberos/bitwarden.keytab
|
||||
cp -f /etc/bitwarden/kerberos/krb5.conf /etc/krb5.conf
|
||||
$gosu_cmd kinit $globalSettings__kerberosUser -k -t /etc/bitwarden/kerberos/bitwarden.keytab
|
||||
fi
|
||||
|
||||
exec gosu $USERNAME:$GROUPNAME dotnet /app/Admin.dll
|
||||
exec $gosu_cmd /app/Admin
|
||||
|
237
src/Admin/package-lock.json
generated
237
src/Admin/package-lock.json
generated
@ -9,18 +9,18 @@
|
||||
"version": "0.0.0",
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
"bootstrap": "5.3.3",
|
||||
"bootstrap": "5.3.6",
|
||||
"font-awesome": "4.7.0",
|
||||
"jquery": "3.7.1",
|
||||
"toastr": "2.1.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"css-loader": "7.1.2",
|
||||
"expose-loader": "5.0.0",
|
||||
"expose-loader": "5.0.1",
|
||||
"mini-css-extract-plugin": "2.9.2",
|
||||
"sass": "1.85.0",
|
||||
"sass-loader": "16.0.4",
|
||||
"webpack": "5.97.1",
|
||||
"sass": "1.88.0",
|
||||
"sass-loader": "16.0.5",
|
||||
"webpack": "5.99.8",
|
||||
"webpack-cli": "5.1.4"
|
||||
}
|
||||
},
|
||||
@ -442,9 +442,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
|
||||
"integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==",
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
|
||||
"integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@ -456,13 +456,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.13.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.5.tgz",
|
||||
"integrity": "sha512-+lTU0PxZXn0Dr1NBtC7Y8cR21AJr87dLLU953CWA6pMxxv/UDc7jYAY90upcrie1nRcD6XNG5HOYEDtgW5TxAg==",
|
||||
"version": "22.15.21",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.21.tgz",
|
||||
"integrity": "sha512-EV/37Td6c+MgKAbkcLG6vqZ2zEYHD7bvSrzqqs2RIhbA6w3x+Dqz8MZM3sP6kGTeLrdoOgKZe+Xja7tUB2DNkQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.20.0"
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@webassemblyjs/ast": {
|
||||
@ -688,9 +688,9 @@
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.14.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz",
|
||||
"integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==",
|
||||
"version": "8.14.1",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
|
||||
"integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
@ -749,9 +749,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/bootstrap": {
|
||||
"version": "5.3.3",
|
||||
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.3.tgz",
|
||||
"integrity": "sha512-8HLCdWgyoMguSO9o+aH+iuZ+aht+mzW0u3HIMzVu7Srrpv7EBBxTnrFlSCskwdY1+EOFQSm7uMJhNQHkdPcmjg==",
|
||||
"version": "5.3.6",
|
||||
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.6.tgz",
|
||||
"integrity": "sha512-jX0GAcRzvdwISuvArXn3m7KZscWWFAf1MKBcnzaN02qWMb3jpMoUX4/qgeiGzqyIb4ojulRzs89UCUmGcFSzTA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@ -782,9 +782,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.24.4",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz",
|
||||
"integrity": "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==",
|
||||
"version": "4.24.5",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.5.tgz",
|
||||
"integrity": "sha512-FDToo4Wo82hIdgc1CQ+NQD0hEhmpPjrZ3hiUgwgOG6IuTdlpr8jdjyG24P6cNP1yJpTLzS5OcGgSw0xmDU1/Tw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@ -802,10 +802,10 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"caniuse-lite": "^1.0.30001688",
|
||||
"electron-to-chromium": "^1.5.73",
|
||||
"caniuse-lite": "^1.0.30001716",
|
||||
"electron-to-chromium": "^1.5.149",
|
||||
"node-releases": "^2.0.19",
|
||||
"update-browserslist-db": "^1.1.1"
|
||||
"update-browserslist-db": "^1.1.3"
|
||||
},
|
||||
"bin": {
|
||||
"browserslist": "cli.js"
|
||||
@ -822,9 +822,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001700",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001700.tgz",
|
||||
"integrity": "sha512-2S6XIXwaE7K7erT8dY+kLQcpa5ms63XlRkMkReXjle+kf6c5g38vyMl+Z5y8dSxOFDhcFe+nxnn261PLxBSQsQ==",
|
||||
"version": "1.0.30001718",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001718.tgz",
|
||||
"integrity": "sha512-AflseV1ahcSunK53NfEs9gFWgOEmzr0f+kaMFA4xiLZlr9Hzt7HxcSpIFcnNCUkz6R6dWKa54rUz3HUmI3nVcw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@ -976,9 +976,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.103",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.103.tgz",
|
||||
"integrity": "sha512-P6+XzIkfndgsrjROJWfSvVEgNHtPgbhVyTkwLjUM2HU/h7pZRORgaTlHqfAikqxKmdJMLW8fftrdGWbd/Ds0FA==",
|
||||
"version": "1.5.155",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.155.tgz",
|
||||
"integrity": "sha512-ps5KcGGmwL8VaeJlvlDlu4fORQpv3+GIcF5I3f9tUKUlJ/wsysh6HU8P5L1XWRYeXfA0oJd4PyM8ds8zTFf6Ng==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
@ -1010,9 +1010,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.6.0.tgz",
|
||||
"integrity": "sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==",
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
|
||||
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@ -1084,9 +1084,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/expose-loader": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/expose-loader/-/expose-loader-5.0.0.tgz",
|
||||
"integrity": "sha512-BtUqYRmvx1bEY5HN6eK2I9URUZgNmN0x5UANuocaNjXSgfoDlkXt+wyEMe7i5DzDNh2BKJHPc5F4rBwEdSQX6w==",
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/expose-loader/-/expose-loader-5.0.1.tgz",
|
||||
"integrity": "sha512-5YPZuszN/eWND/B+xuq5nIpb/l5TV1HYmdO6SubYtHv+HenVw9/6bn33Mm5reY8DNid7AVtbARvyUD34edfCtg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@ -1107,13 +1107,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-json-stable-stringify": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
||||
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz",
|
||||
@ -1249,9 +1242,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/immutable": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.0.3.tgz",
|
||||
"integrity": "sha512-P8IdPQHq3lA1xVeBRi5VPqUm5HDgKnx0Ru51wZz5mjxHr5n3RWhjIpOFU7ybkUxfB+5IToy+OLaHYDBIWsv+uw==",
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.2.tgz",
|
||||
"integrity": "sha512-qHKXW1q6liAk1Oys6umoaZbDRqjcjgSrbnrifHsfsttza7zcvRAsL7mMV6xWcyhwQy7Xj5v4hhbr6b+iDYwlmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@ -1502,9 +1495,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.8",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz",
|
||||
"integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==",
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@ -1755,16 +1748,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/randombytes": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
|
||||
@ -1878,9 +1861,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sass": {
|
||||
"version": "1.85.0",
|
||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.85.0.tgz",
|
||||
"integrity": "sha512-3ToiC1xZ1Y8aU7+CkgCI/tqyuPXEmYGJXO7H4uqp0xkLXUqp88rQQ4j1HmP37xSJLbCJPaIiv+cT1y+grssrww==",
|
||||
"version": "1.88.0",
|
||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.88.0.tgz",
|
||||
"integrity": "sha512-sF6TWQqjFvr4JILXzG4ucGOLELkESHL+I5QJhh7CNaE+Yge0SI+ehCatsXhJ7ymU1hAFcIS3/PBpjdIbXoyVbg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@ -1899,9 +1882,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/sass-loader": {
|
||||
"version": "16.0.4",
|
||||
"resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-16.0.4.tgz",
|
||||
"integrity": "sha512-LavLbgbBGUt3wCiYzhuLLu65+fWXaXLmq7YxivLhEqmiupCFZ5sKUAipK3do6V80YSU0jvSxNhEdT13IXNr3rg==",
|
||||
"version": "16.0.5",
|
||||
"resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-16.0.5.tgz",
|
||||
"integrity": "sha512-oL+CMBXrj6BZ/zOq4os+UECPL+bWqt6OAC6DWS8Ln8GZRcMDjlJ4JC3FBDuHJdYaFWIdKNIBYmtZtK2MaMkNIw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@ -1940,9 +1923,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/schema-utils": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.0.tgz",
|
||||
"integrity": "sha512-Gf9qqc58SpCA/xdziiHz35F4GNIWYWZrEshUc/G/r5BnLph6xpKuLeoJoQuj5WfBIx/eQLf+hmVPYHaxJu7V2g==",
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz",
|
||||
"integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@ -1960,9 +1943,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
|
||||
"integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
|
||||
"version": "7.7.2",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
|
||||
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
@ -2079,9 +2062,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz",
|
||||
"integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==",
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.2.tgz",
|
||||
"integrity": "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@ -2089,14 +2072,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/terser": {
|
||||
"version": "5.39.0",
|
||||
"resolved": "https://registry.npmjs.org/terser/-/terser-5.39.0.tgz",
|
||||
"integrity": "sha512-LBAhFyLho16harJoWMg/nZsQYgTrg5jXOn2nCYjRUcZZEdE3qa2zb8QEDRUGVZBW4rlazf2fxkg8tztybTaqWw==",
|
||||
"version": "5.39.2",
|
||||
"resolved": "https://registry.npmjs.org/terser/-/terser-5.39.2.tgz",
|
||||
"integrity": "sha512-yEPUmWve+VA78bI71BW70Dh0TuV4HHd+I5SHOAfS1+QBOmvmCiiffgjR8ryyEd3KIfvPGFqoADt8LdQ6XpXIvg==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"@jridgewell/source-map": "^0.3.3",
|
||||
"acorn": "^8.8.2",
|
||||
"acorn": "^8.14.0",
|
||||
"commander": "^2.20.0",
|
||||
"source-map-support": "~0.5.20"
|
||||
},
|
||||
@ -2108,9 +2091,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/terser-webpack-plugin": {
|
||||
"version": "5.3.11",
|
||||
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.11.tgz",
|
||||
"integrity": "sha512-RVCsMfuD0+cTt3EwX8hSl2Ks56EbFHWmhluwcqoPKtBnfjiT6olaq7PRIRfhyU8nnC2MrnDrBLfrD/RGE+cVXQ==",
|
||||
"version": "5.3.14",
|
||||
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz",
|
||||
"integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@ -2165,16 +2148,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.20.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
|
||||
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.2.tgz",
|
||||
"integrity": "sha512-PPypAm5qvlD7XMZC3BujecnaOxwhrtoFR+Dqkk5Aa/6DssiH0ibKoketaj9w8LP7Bont1rYeoV5plxD7RTEPRg==",
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
|
||||
"integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@ -2202,16 +2185,6 @@
|
||||
"browserslist": ">= 4.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uri-js": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
||||
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
@ -2220,9 +2193,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/watchpack": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz",
|
||||
"integrity": "sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw==",
|
||||
"version": "2.4.4",
|
||||
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz",
|
||||
"integrity": "sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@ -2234,14 +2207,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/webpack": {
|
||||
"version": "5.97.1",
|
||||
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.97.1.tgz",
|
||||
"integrity": "sha512-EksG6gFY3L1eFMROS/7Wzgrii5mBAFe4rIr3r2BTfo7bcc+DWwFZ4OJ/miOuHJO/A85HwyI4eQ0F6IKXesO7Fg==",
|
||||
"version": "5.99.8",
|
||||
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.99.8.tgz",
|
||||
"integrity": "sha512-lQ3CPiSTpfOnrEGeXDwoq5hIGzSjmwD72GdfVzF7CQAI7t47rJG9eDWvcEkEn3CUQymAElVvDg3YNTlCYj+qUQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/eslint-scope": "^3.7.7",
|
||||
"@types/estree": "^1.0.6",
|
||||
"@types/json-schema": "^7.0.15",
|
||||
"@webassemblyjs/ast": "^1.14.1",
|
||||
"@webassemblyjs/wasm-edit": "^1.14.1",
|
||||
"@webassemblyjs/wasm-parser": "^1.14.1",
|
||||
@ -2258,9 +2232,9 @@
|
||||
"loader-runner": "^4.2.0",
|
||||
"mime-types": "^2.1.27",
|
||||
"neo-async": "^2.6.2",
|
||||
"schema-utils": "^3.2.0",
|
||||
"schema-utils": "^4.3.2",
|
||||
"tapable": "^2.1.1",
|
||||
"terser-webpack-plugin": "^5.3.10",
|
||||
"terser-webpack-plugin": "^5.3.11",
|
||||
"watchpack": "^2.4.1",
|
||||
"webpack-sources": "^3.2.3"
|
||||
},
|
||||
@ -2361,59 +2335,6 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/webpack/node_modules/ajv": {
|
||||
"version": "6.12.6",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
"json-schema-traverse": "^0.4.1",
|
||||
"uri-js": "^4.2.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/webpack/node_modules/ajv-keywords": {
|
||||
"version": "3.5.2",
|
||||
"resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz",
|
||||
"integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"ajv": "^6.9.1"
|
||||
}
|
||||
},
|
||||
"node_modules/webpack/node_modules/json-schema-traverse": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/webpack/node_modules/schema-utils": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz",
|
||||
"integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/json-schema": "^7.0.8",
|
||||
"ajv": "^6.12.5",
|
||||
"ajv-keywords": "^3.5.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.13.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
@ -8,18 +8,18 @@
|
||||
"build": "webpack"
|
||||
},
|
||||
"dependencies": {
|
||||
"bootstrap": "5.3.3",
|
||||
"bootstrap": "5.3.6",
|
||||
"font-awesome": "4.7.0",
|
||||
"jquery": "3.7.1",
|
||||
"toastr": "2.1.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"css-loader": "7.1.2",
|
||||
"expose-loader": "5.0.0",
|
||||
"expose-loader": "5.0.1",
|
||||
"mini-css-extract-plugin": "2.9.2",
|
||||
"sass": "1.85.0",
|
||||
"sass-loader": "16.0.4",
|
||||
"webpack": "5.97.1",
|
||||
"sass": "1.88.0",
|
||||
"sass-loader": "16.0.5",
|
||||
"webpack": "5.99.8",
|
||||
"webpack-cli": "5.1.4"
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +0,0 @@
|
||||
*
|
||||
!obj/build-output/publish/*
|
||||
!obj/Docker/empty/
|
||||
!entrypoint.sh
|
21
src/Api/AdminConsole/Authorization/AuthorizeAttribute.cs
Normal file
21
src/Api/AdminConsole/Authorization/AuthorizeAttribute.cs
Normal file
@ -0,0 +1,21 @@
|
||||
#nullable enable
|
||||
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace Bit.Api.AdminConsole.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// An attribute which requires authorization using the specified requirement.
|
||||
/// This uses the standard ASP.NET authorization middleware.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The IAuthorizationRequirement that will be used to authorize the user.</typeparam>
|
||||
public class AuthorizeAttribute<T>
|
||||
: AuthorizeAttribute, IAuthorizationRequirementData
|
||||
where T : IAuthorizationRequirement, new()
|
||||
{
|
||||
public IEnumerable<IAuthorizationRequirement> GetRequirements()
|
||||
{
|
||||
var requirement = new T();
|
||||
return [requirement];
|
||||
}
|
||||
}
|
87
src/Api/AdminConsole/Authorization/HttpContextExtensions.cs
Normal file
87
src/Api/AdminConsole/Authorization/HttpContextExtensions.cs
Normal file
@ -0,0 +1,87 @@
|
||||
#nullable enable
|
||||
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.Models.Data.Provider;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
|
||||
namespace Bit.Api.AdminConsole.Authorization;
|
||||
|
||||
public static class HttpContextExtensions
|
||||
{
|
||||
public const string NoOrgIdError =
|
||||
"A route decorated with with '[Authorize<Requirement>]' must include a route value named 'orgId' or 'organizationId' either through the [Controller] attribute or through a '[Http*]' attribute.";
|
||||
|
||||
/// <summary>
|
||||
/// Returns the result of the callback, caching it in HttpContext.Features for the lifetime of the request.
|
||||
/// Subsequent calls will retrieve the cached value.
|
||||
/// Results are stored by type and therefore must be of a unique type.
|
||||
/// </summary>
|
||||
public static async Task<T> WithFeaturesCacheAsync<T>(this HttpContext httpContext, Func<Task<T>> callback)
|
||||
{
|
||||
var cachedResult = httpContext.Features.Get<T>();
|
||||
if (cachedResult != null)
|
||||
{
|
||||
return cachedResult;
|
||||
}
|
||||
|
||||
var result = await callback();
|
||||
httpContext.Features.Set(result);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the user is a ProviderUser for a Provider which manages the specified organization, otherwise false.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This data is fetched from the database and cached as a HttpContext Feature for the lifetime of the request.
|
||||
/// </remarks>
|
||||
public static async Task<bool> IsProviderUserForOrgAsync(
|
||||
this HttpContext httpContext,
|
||||
IProviderUserRepository providerUserRepository,
|
||||
Guid userId,
|
||||
Guid organizationId)
|
||||
{
|
||||
var organizations = await httpContext.GetProviderUserOrganizationsAsync(providerUserRepository, userId);
|
||||
return organizations.Any(o => o.OrganizationId == organizationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the ProviderUserOrganizations for a user. These are the organizations the ProviderUser manages via their Provider, if any.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This data is fetched from the database and cached as a HttpContext Feature for the lifetime of the request.
|
||||
/// </remarks>
|
||||
private static async Task<IEnumerable<ProviderUserOrganizationDetails>> GetProviderUserOrganizationsAsync(
|
||||
this HttpContext httpContext,
|
||||
IProviderUserRepository providerUserRepository,
|
||||
Guid userId)
|
||||
=> await httpContext.WithFeaturesCacheAsync(() =>
|
||||
providerUserRepository.GetManyOrganizationDetailsByUserAsync(userId, ProviderUserStatusType.Confirmed));
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Parses the {orgId} or {organizationId} route parameter into a Guid, or throws if neither are present or are not valid guids.
|
||||
/// </summary>
|
||||
/// <param name="httpContext"></param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="InvalidOperationException"></exception>
|
||||
public static Guid GetOrganizationId(this HttpContext httpContext)
|
||||
{
|
||||
var routeValues = httpContext.GetRouteData().Values;
|
||||
|
||||
routeValues.TryGetValue("orgId", out var orgIdParam);
|
||||
if (orgIdParam != null && Guid.TryParse(orgIdParam.ToString(), out var orgId))
|
||||
{
|
||||
return orgId;
|
||||
}
|
||||
|
||||
routeValues.TryGetValue("organizationId", out var organizationIdParam);
|
||||
if (organizationIdParam != null && Guid.TryParse(organizationIdParam.ToString(), out var organizationId))
|
||||
{
|
||||
return organizationId;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(NoOrgIdError);
|
||||
}
|
||||
}
|
@ -0,0 +1,31 @@
|
||||
#nullable enable
|
||||
|
||||
using Bit.Core.Context;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace Bit.Api.AdminConsole.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// A requirement that implements this interface will be handled by <see cref="OrganizationRequirementHandler"/>,
|
||||
/// which calls AuthorizeAsync with the organization details from the route.
|
||||
/// This is used for simple role-based checks.
|
||||
/// This may only be used on endpoints with {orgId} in their path.
|
||||
/// </summary>
|
||||
public interface IOrganizationRequirement : IAuthorizationRequirement
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether to authorize a request that has this requirement.
|
||||
/// </summary>
|
||||
/// <param name="organizationClaims">
|
||||
/// The CurrentContextOrganization for the user if they are a member of the organization.
|
||||
/// This is null if they are not a member.
|
||||
/// </param>
|
||||
/// <param name="isProviderUserForOrg">
|
||||
/// A callback that returns true if the user is a ProviderUser that manages the organization, otherwise false.
|
||||
/// This requires a database query, call it last.
|
||||
/// </param>
|
||||
/// <returns>True if the requirement has been satisfied, otherwise false.</returns>
|
||||
public Task<bool> AuthorizeAsync(
|
||||
CurrentContextOrganization? organizationClaims,
|
||||
Func<Task<bool>> isProviderUserForOrg);
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user