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

Merge branch 'main' into blazor

This commit is contained in:
Jonas Hendrickx 2025-04-06 11:54:51 +02:00 committed by GitHub
commit c720e8c154
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
1293 changed files with 196589 additions and 17205 deletions

View File

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

View File

@ -1,5 +1,3 @@
version: '3'
services: services:
bitwarden_server: bitwarden_server:
image: mcr.microsoft.com/devcontainers/dotnet:8.0 image: mcr.microsoft.com/devcontainers/dotnet:8.0
@ -13,7 +11,8 @@ services:
platform: linux/amd64 platform: linux/amd64
restart: unless-stopped restart: unless-stopped
env_file: env_file:
../../dev/.env - path: ../../dev/.env
required: false
environment: environment:
ACCEPT_EULA: "Y" ACCEPT_EULA: "Y"
MSSQL_PID: Developer MSSQL_PID: Developer

View File

@ -3,6 +3,11 @@
"dockerComposeFile": "../../.devcontainer/bitwarden_common/docker-compose.yml", "dockerComposeFile": "../../.devcontainer/bitwarden_common/docker-compose.yml",
"service": "bitwarden_server", "service": "bitwarden_server",
"workspaceFolder": "/workspace", "workspaceFolder": "/workspace",
"features": {
"ghcr.io/devcontainers/features/node:1": {
"version": "16"
}
},
"mounts": [ "mounts": [
{ {
"source": "../../dev/.data/keys", "source": "../../dev/.data/keys",
@ -13,7 +18,6 @@
"customizations": { "customizations": {
"vscode": { "vscode": {
"settings": {}, "settings": {},
"features": {},
"extensions": ["ms-dotnettools.csdevkit"] "extensions": ["ms-dotnettools.csdevkit"]
} }
}, },

View File

@ -51,4 +51,10 @@ Proceed? [y/N] " response
} }
# main # main
one_time_setup if [[ -z "${CODESPACES}" ]]; then
one_time_setup
else
# Ignore interactive elements when running in codespaces since they are not supported there
# TODO Write codespaces specific instructions and link here
echo "Running in codespaces, follow instructions here: https://contributing.bitwarden.com/getting-started/server/guide/ to continue the setup"
fi

View File

@ -6,6 +6,11 @@
], ],
"service": "bitwarden_server", "service": "bitwarden_server",
"workspaceFolder": "/workspace", "workspaceFolder": "/workspace",
"features": {
"ghcr.io/devcontainers/features/node:1": {
"version": "16"
}
},
"mounts": [ "mounts": [
{ {
"source": "../../dev/.data/keys", "source": "../../dev/.data/keys",
@ -16,15 +21,39 @@
"customizations": { "customizations": {
"vscode": { "vscode": {
"settings": {}, "settings": {},
"features": {},
"extensions": ["ms-dotnettools.csdevkit"] "extensions": ["ms-dotnettools.csdevkit"]
} }
}, },
"postCreateCommand": "bash .devcontainer/internal_dev/postCreateCommand.sh", "postCreateCommand": "bash .devcontainer/internal_dev/postCreateCommand.sh",
"forwardPorts": [1080, 1433, 3306, 5432, 10000, 10001, 10002],
"portsAttributes": { "portsAttributes": {
"1080": { "1080": {
"label": "Mail Catcher", "label": "Mail Catcher",
"onAutoForward": "notify" "onAutoForward": "notify"
},
"1433": {
"label": "SQL Server",
"onAutoForward": "notify"
},
"3306": {
"label": "MySQL",
"onAutoForward": "notify"
},
"5432": {
"label": "PostgreSQL",
"onAutoForward": "notify"
},
"10000": {
"label": "Azurite Storage Blob",
"onAutoForward": "notify"
},
"10001": {
"label": "Azurite Storage Queue ",
"onAutoForward": "notify"
},
"10002": {
"label": "Azurite Storage Table",
"onAutoForward": "notify"
} }
} }
} }

View File

@ -1,5 +1,3 @@
version: '3'
services: services:
bitwarden_storage: bitwarden_storage:
image: mcr.microsoft.com/azure-storage/azurite:latest image: mcr.microsoft.com/azure-storage/azurite:latest

View File

@ -70,7 +70,29 @@ Press <Enter> to continue."
sleep 5 # wait for DB container to start sleep 5 # wait for DB container to start
dotnet run --project ./util/MsSqlMigratorUtility "$SQL_CONNECTION_STRING" dotnet run --project ./util/MsSqlMigratorUtility "$SQL_CONNECTION_STRING"
fi fi
read -r -p "Would you like to install the Stripe CLI? [y/N] " stripe_response
if [[ "$stripe_response" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
install_stripe_cli
fi
}
# Install Stripe CLI
install_stripe_cli() {
echo "Installing Stripe CLI..."
# Add Stripe CLI GPG key so that apt can verify the packages authenticity.
# If Stripe ever changes the key, we'll need to update this. Visit https://docs.stripe.com/stripe-cli?install-method=apt if so
curl -s https://packages.stripe.dev/api/security/keypair/stripe-cli-gpg/public | gpg --dearmor | sudo tee /usr/share/keyrings/stripe.gpg >/dev/null
# Add Stripe CLI repository to apt sources
echo "deb [signed-by=/usr/share/keyrings/stripe.gpg] https://packages.stripe.dev/stripe-cli-debian-local stable main" | sudo tee -a /etc/apt/sources.list.d/stripe.list >/dev/null
sudo apt update
sudo apt install -y stripe
} }
# main # main
one_time_setup if [[ -z "${CODESPACES}" ]]; then
one_time_setup
else
# Ignore interactive elements when running in codespaces since they are not supported there
# TODO Write codespaces specific instructions and link here
echo "Running in codespaces, follow instructions here: https://contributing.bitwarden.com/getting-started/server/guide/ to continue the setup"
fi

39
.github/CODEOWNERS vendored
View File

@ -4,23 +4,35 @@
# #
# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners # https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
# DevOps for Actions and other workflow changes ## Docker files have shared ownership ##
.github/workflows @bitwarden/dept-devops **/Dockerfile
**/*.Dockerfile
**/.dockerignore
**/entrypoint.sh
# DevOps for Docker changes ## BRE team owns these workflows ##
**/Dockerfile @bitwarden/dept-devops .github/workflows/publish.yml @bitwarden/dept-bre
**/*.Dockerfile @bitwarden/dept-devops
**/.dockerignore @bitwarden/dept-devops ## These are shared workflows ##
.github/workflows/_move_finalization_db_scripts.yml
.github/workflows/release.yml
# Database Operations for database changes # Database Operations for database changes
src/Sql/** @bitwarden/dept-dbops src/Sql/** @bitwarden/dept-dbops
util/EfShared/** @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/MySqlMigrations/** @bitwarden/dept-dbops
util/PostgresMigrations/** @bitwarden/dept-dbops util/PostgresMigrations/** @bitwarden/dept-dbops
util/SqlServerEFScaffold/** @bitwarden/dept-dbops util/SqlServerEFScaffold/** @bitwarden/dept-dbops
util/SqliteMigrations/** @bitwarden/dept-dbops util/SqliteMigrations/** @bitwarden/dept-dbops
# Shared util projects
util/Setup/** @bitwarden/dept-bre @bitwarden/team-platform-dev
# Auth team # Auth team
**/Auth @bitwarden/team-auth-dev **/Auth @bitwarden/team-auth-dev
bitwarden_license/src/Sso @bitwarden/team-auth-dev bitwarden_license/src/Sso @bitwarden/team-auth-dev
@ -29,7 +41,6 @@ src/Identity @bitwarden/team-auth-dev
# Key Management team # Key Management team
**/KeyManagement @bitwarden/team-key-management-dev **/KeyManagement @bitwarden/team-key-management-dev
**/SecretsManager @bitwarden/team-secrets-manager-dev
**/Tools @bitwarden/team-tools-dev **/Tools @bitwarden/team-tools-dev
# Vault team # Vault team
@ -60,6 +71,16 @@ src/EventsProcessor @bitwarden/team-admin-console-dev
src/Admin/Controllers/ToolsController.cs @bitwarden/team-billing-dev src/Admin/Controllers/ToolsController.cs @bitwarden/team-billing-dev
src/Admin/Views/Tools @bitwarden/team-billing-dev src/Admin/Views/Tools @bitwarden/team-billing-dev
# Multiple owners - DO NOT REMOVE (DevOps) # 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
# Multiple owners - DO NOT REMOVE (BRE)
**/packages.lock.json **/packages.lock.json
Directory.Build.props Directory.Build.props

197
.github/renovate.json vendored
View File

@ -1,197 +0,0 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["github>bitwarden/renovate-config"],
"enabledManagers": [
"dockerfile",
"docker-compose",
"github-actions",
"npm",
"nuget"
],
"packageRules": [
{
"groupName": "dockerfile minor",
"matchManagers": ["dockerfile"],
"matchUpdateTypes": ["minor", "patch"]
},
{
"groupName": "docker-compose minor",
"matchManagers": ["docker-compose"],
"matchUpdateTypes": ["minor", "patch"]
},
{
"groupName": "gh minor",
"matchManagers": ["github-actions"],
"matchUpdateTypes": ["minor", "patch"]
},
{
"matchManagers": ["github-actions", "dockerfile", "docker-compose"],
"commitMessagePrefix": "[deps] DevOps:"
},
{
"matchPackageNames": ["DnsClient", "Quartz"],
"description": "Admin Console owned dependencies",
"commitMessagePrefix": "[deps] AC:",
"reviewers": ["team:team-admin-console-dev"]
},
{
"matchFileNames": ["src/Admin/package.json", "src/Sso/package.json"],
"description": "Admin & SSO npm packages",
"commitMessagePrefix": "[deps] Auth:",
"reviewers": ["team:team-auth-dev"]
},
{
"matchPackageNames": [
"AspNetCoreRateLimit",
"AspNetCoreRateLimit.Redis",
"Azure.Data.Tables",
"Azure.Extensions.AspNetCore.DataProtection.Blobs",
"Azure.Messaging.EventGrid",
"Azure.Messaging.ServiceBus",
"Azure.Storage.Blobs",
"Azure.Storage.Queues",
"DuoUniversal",
"Fido2.AspNet",
"Duende.IdentityServer",
"Microsoft.Extensions.Identity.Stores",
"Otp.NET",
"Sustainsys.Saml2.AspNetCore2",
"YubicoDotNetClient"
],
"description": "Auth owned dependencies",
"commitMessagePrefix": "[deps] Auth:",
"reviewers": ["team:team-auth-dev"]
},
{
"matchPackageNames": [
"AutoFixture.AutoNSubstitute",
"AutoFixture.Xunit2",
"BenchmarkDotNet",
"BitPay.Light",
"Braintree",
"coverlet.collector",
"FluentAssertions",
"Kralizek.AutoFixture.Extensions.MockHttp",
"Microsoft.AspNetCore.Mvc.Testing",
"Microsoft.Extensions.Logging",
"Microsoft.Extensions.Logging.Console",
"Newtonsoft.Json",
"NSubstitute",
"Sentry.Serilog",
"Serilog.AspNetCore",
"Serilog.Extensions.Logging",
"Serilog.Extensions.Logging.File",
"Serilog.Sinks.AzureCosmosDB",
"Serilog.Sinks.SyslogMessages",
"Stripe.net",
"Swashbuckle.AspNetCore",
"Swashbuckle.AspNetCore.SwaggerGen",
"xunit",
"xunit.runner.visualstudio"
],
"description": "Billing owned dependencies",
"commitMessagePrefix": "[deps] Billing:",
"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": [
"Dapper",
"dbup-sqlserver",
"dotnet-ef",
"linq2db.EntityFrameworkCore",
"Microsoft.Azure.Cosmos",
"Microsoft.Data.SqlClient",
"Microsoft.EntityFrameworkCore.Design",
"Microsoft.EntityFrameworkCore.InMemory",
"Microsoft.EntityFrameworkCore.Relational",
"Microsoft.EntityFrameworkCore.Sqlite",
"Microsoft.EntityFrameworkCore.SqlServer",
"Microsoft.Extensions.Caching.SqlServer",
"Microsoft.Extensions.Caching.StackExchangeRedis",
"Npgsql.EntityFrameworkCore.PostgreSQL",
"Pomelo.EntityFrameworkCore.MySql"
],
"description": "DbOps owned dependencies",
"commitMessagePrefix": "[deps] DbOps:",
"reviewers": ["team:dept-dbops"]
},
{
"matchPackageNames": ["CommandDotNet", "YamlDotNet"],
"description": "DevOps owned dependencies",
"commitMessagePrefix": "[deps] DevOps:",
"reviewers": ["team:dept-devops"]
},
{
"matchPackageNames": [
"Microsoft.AspNetCore.Authentication.JwtBearer",
"Microsoft.AspNetCore.Http"
],
"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",
"AspNetCore.HealthChecks.AzureServiceBus",
"AspNetCore.HealthChecks.AzureStorage",
"AspNetCore.HealthChecks.Network",
"AspNetCore.HealthChecks.Redis",
"AspNetCore.HealthChecks.SendGrid",
"AspNetCore.HealthChecks.SqlServer",
"AspNetCore.HealthChecks.Uris"
],
"description": "Vault owned dependencies",
"commitMessagePrefix": "[deps] Vault:",
"reviewers": ["team:team-vault-dev"]
}
],
"ignoreDeps": ["dotnet-sdk"]
}

199
.github/renovate.json5 vendored Normal file
View File

@ -0,0 +1,199 @@
{
$schema: "https://docs.renovatebot.com/renovate-schema.json",
extends: ["github>bitwarden/renovate-config"], // Extends our default configuration for pinned dependencies
enabledManagers: [
"dockerfile",
"docker-compose",
"github-actions",
"npm",
"nuget",
],
packageRules: [
{
groupName: "dockerfile minor",
matchManagers: ["dockerfile"],
matchUpdateTypes: ["minor"],
},
{
groupName: "docker-compose minor",
matchManagers: ["docker-compose"],
matchUpdateTypes: ["minor"],
},
{
groupName: "github-action minor",
matchManagers: ["github-actions"],
matchUpdateTypes: ["minor"],
},
{
matchManagers: ["dockerfile", "docker-compose"],
commitMessagePrefix: "[deps] BRE:",
},
{
matchPackageNames: ["DnsClient"],
description: "Admin Console owned dependencies",
commitMessagePrefix: "[deps] AC:",
reviewers: ["team:team-admin-console-dev"],
},
{
matchFileNames: ["src/Admin/package.json", "src/Sso/package.json"],
description: "Admin & SSO npm packages",
commitMessagePrefix: "[deps] Auth:",
reviewers: ["team:team-auth-dev"],
},
{
matchPackageNames: [
"Azure.Extensions.AspNetCore.DataProtection.Blobs",
"DuoUniversal",
"Fido2.AspNet",
"Duende.IdentityServer",
"Microsoft.Extensions.Identity.Stores",
"Otp.NET",
"Sustainsys.Saml2.AspNetCore2",
"YubicoDotNetClient",
],
description: "Auth owned dependencies",
commitMessagePrefix: "[deps] Auth:",
reviewers: ["team:team-auth-dev"],
},
{
matchPackageNames: [
"AutoFixture.AutoNSubstitute",
"AutoFixture.Xunit2",
"BenchmarkDotNet",
"BitPay.Light",
"Braintree",
"coverlet.collector",
"CsvHelper",
"Kralizek.AutoFixture.Extensions.MockHttp",
"Microsoft.AspNetCore.Mvc.Testing",
"Microsoft.Extensions.Logging",
"Microsoft.Extensions.Logging.Console",
"Newtonsoft.Json",
"NSubstitute",
"Sentry.Serilog",
"Serilog.AspNetCore",
"Serilog.Extensions.Logging",
"Serilog.Extensions.Logging.File",
"Serilog.Sinks.AzureCosmosDB",
"Serilog.Sinks.SyslogMessages",
"Stripe.net",
"Swashbuckle.AspNetCore",
"Swashbuckle.AspNetCore.SwaggerGen",
"xunit",
"xunit.runner.visualstudio",
],
description: "Billing owned dependencies",
commitMessagePrefix: "[deps] Billing:",
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: [
"Dapper",
"dbup-sqlserver",
"dotnet-ef",
"linq2db.EntityFrameworkCore",
"Microsoft.Azure.Cosmos",
"Microsoft.Data.SqlClient",
"Microsoft.EntityFrameworkCore.Design",
"Microsoft.EntityFrameworkCore.InMemory",
"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",
],
description: "DbOps owned dependencies",
commitMessagePrefix: "[deps] DbOps:",
reviewers: ["team:dept-dbops"],
},
{
matchPackageNames: ["CommandDotNet", "YamlDotNet"],
description: "DevOps owned dependencies",
commitMessagePrefix: "[deps] BRE:",
reviewers: ["team:dept-bre"],
},
{
matchPackageNames: [
"AspNetCoreRateLimit",
"AspNetCoreRateLimit.Redis",
"Azure.Data.Tables",
"Azure.Messaging.EventGrid",
"Azure.Messaging.ServiceBus",
"Azure.Storage.Blobs",
"Azure.Storage.Queues",
"Microsoft.AspNetCore.Authentication.JwtBearer",
"Microsoft.AspNetCore.Http",
"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",
"AspNetCore.HealthChecks.AzureServiceBus",
"AspNetCore.HealthChecks.AzureStorage",
"AspNetCore.HealthChecks.Network",
"AspNetCore.HealthChecks.Redis",
"AspNetCore.HealthChecks.SendGrid",
"AspNetCore.HealthChecks.SqlServer",
"AspNetCore.HealthChecks.Uris",
],
description: "Vault owned dependencies",
commitMessagePrefix: "[deps] Vault:",
reviewers: ["team:team-vault-dev"],
},
],
ignoreDeps: ["dotnet-sdk"],
}

View File

@ -1,4 +1,3 @@
---
name: _move_finalization_db_scripts name: _move_finalization_db_scripts
run-name: Move finalization database scripts run-name: Move finalization database scripts
@ -30,7 +29,7 @@ jobs:
secrets: "github-pat-bitwarden-devops-bot-repo-scope" secrets: "github-pat-bitwarden-devops-bot-repo-scope"
- name: Check out branch - name: Check out branch
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
token: ${{ steps.retrieve-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }} token: ${{ steps.retrieve-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
@ -54,7 +53,7 @@ jobs:
if: ${{ needs.setup.outputs.copy_finalization_scripts == 'true' }} if: ${{ needs.setup.outputs.copy_finalization_scripts == 'true' }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
fetch-depth: 0 fetch-depth: 0
@ -108,7 +107,7 @@ jobs:
devops-alerts-slack-webhook-url" devops-alerts-slack-webhook-url"
- name: Import GPG keys - name: Import GPG keys
uses: crazy-max/ghaction-import-gpg@01dd5d3ca463c7f10f7f4f7b4f177225ac661ee4 # v6.1.0 uses: crazy-max/ghaction-import-gpg@cb9bde2e2525e640591a934b1fd28eef1dcaf5e5 # v6.2.0
with: with:
gpg_private_key: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key }} gpg_private_key: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key }}
passphrase: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key-passphrase }} passphrase: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key-passphrase }}

View File

@ -1,4 +1,3 @@
---
name: Automatic responses name: Automatic responses
on: on:
issues: issues:

View File

@ -1,4 +1,3 @@
---
name: Build name: Build
on: on:
@ -9,6 +8,9 @@ on:
- "rc" - "rc"
- "hotfix-rc" - "hotfix-rc"
pull_request: pull_request:
types: [opened, synchronize]
workflow_call:
inputs: {}
env: env:
_AZ_REGISTRY: "bitwardenprod.azurecr.io" _AZ_REGISTRY: "bitwardenprod.azurecr.io"
@ -19,10 +21,12 @@ jobs:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1 uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0
- name: Verify format - name: Verify format
run: dotnet format --verify-no-changes run: dotnet format --verify-no-changes
@ -32,6 +36,8 @@ jobs:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
needs: needs:
- lint - lint
outputs:
has_secrets: ${{ steps.check-secrets.outputs.has_secrets }}
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@ -67,14 +73,24 @@ jobs:
base_path: ./bitwarden_license/src base_path: ./bitwarden_license/src
node: true node: true
steps: 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 - name: Check out repo
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1 uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0
- name: Set up Node - name: Set up Node
uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4.0.4 uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
with: with:
cache: "npm" cache: "npm"
cache-dependency-path: "**/package-lock.json" cache-dependency-path: "**/package-lock.json"
@ -110,7 +126,7 @@ jobs:
ls -atlh ../../../ ls -atlh ../../../
- name: Upload project artifact - name: Upload project artifact
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with: with:
name: ${{ matrix.project_name }}.zip name: ${{ matrix.project_name }}.zip
path: ${{ matrix.base_path }}/${{ matrix.project_name }}/${{ matrix.project_name }}.zip path: ${{ matrix.base_path }}/${{ matrix.project_name }}/${{ matrix.project_name }}.zip
@ -121,7 +137,10 @@ jobs:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
permissions: permissions:
security-events: write security-events: write
needs: build-artifacts id-token: write
needs:
- build-artifacts
if: ${{ needs.build-artifacts.outputs.has_secrets == 'true' }}
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@ -173,7 +192,9 @@ jobs:
dotnet: true dotnet: true
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Check branch to publish - name: Check branch to publish
env: env:
@ -213,7 +234,7 @@ jobs:
- name: Generate Docker image tag - name: Generate Docker image tag
id: tag id: tag
run: | run: |
if [[ $(grep "pull" <<< "${GITHUB_REF}") ]]; then if [[ "${GITHUB_EVENT_NAME}" == "pull_request" ]]; then
IMAGE_TAG=$(echo "${GITHUB_HEAD_REF}" | sed "s#/#-#g") IMAGE_TAG=$(echo "${GITHUB_HEAD_REF}" | sed "s#/#-#g")
else else
IMAGE_TAG=$(echo "${GITHUB_REF:11}" | sed "s#/#-#g") IMAGE_TAG=$(echo "${GITHUB_REF:11}" | sed "s#/#-#g")
@ -263,7 +284,8 @@ jobs:
-d ${{ matrix.base_path }}/${{ matrix.project_name }}/obj/build-output/publish -d ${{ matrix.base_path }}/${{ matrix.project_name }}/obj/build-output/publish
- name: Build Docker image - name: Build Docker image
uses: docker/build-push-action@4f58ea79222b3b9dc2c8bbdd6debcef730109a75 # v6.9.0 id: build-docker
uses: docker/build-push-action@67a2d409c0a876cbe6b11854e3e25193efe4e62d # v6.12.0
with: with:
context: ${{ matrix.base_path }}/${{ matrix.project_name }} context: ${{ matrix.base_path }}/${{ matrix.project_name }}
file: ${{ matrix.base_path }}/${{ matrix.project_name }}/Dockerfile file: ${{ matrix.base_path }}/${{ matrix.project_name }}/Dockerfile
@ -273,18 +295,37 @@ jobs:
secrets: | secrets: |
"GH_PAT=${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}" "GH_PAT=${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}"
- name: Install Cosign
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
uses: sigstore/cosign-installer@dc72c7d5c4d10cd6bcb8cf6e3fd625a9e5e537da # v3.7.0
- name: Sign image with Cosign
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
env:
DIGEST: ${{ steps.build-docker.outputs.digest }}
TAGS: ${{ steps.image-tags.outputs.tags }}
run: |
IFS="," read -a tags <<< "${TAGS}"
images=""
for tag in "${tags[@]}"; do
images+="${tag}@${DIGEST} "
done
cosign sign --yes ${images}
- name: Scan Docker image - name: Scan Docker image
id: container-scan id: container-scan
uses: anchore/scan-action@49e50b215b647c5ec97abb66f69af73c46a4ca08 # v5.0.1 uses: anchore/scan-action@abae793926ec39a78ab18002bc7fc45bbbd94342 # v6.0.0
with: with:
image: ${{ steps.image-tags.outputs.primary_tag }} image: ${{ steps.image-tags.outputs.primary_tag }}
fail-build: false fail-build: false
output-format: sarif output-format: sarif
- name: Upload Grype results to GitHub - name: Upload Grype results to GitHub
uses: github/codeql-action/upload-sarif@f779452ac5af1c261dce0346a8f964149f49322b # v3.26.13 uses: github/codeql-action/upload-sarif@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3.28.8
with: with:
sarif_file: ${{ steps.container-scan.outputs.sarif }} sarif_file: ${{ steps.container-scan.outputs.sarif }}
sha: ${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.sha || github.sha }}
ref: ${{ contains(github.event_name, 'pull_request') && format('refs/pull/{0}/head', github.event.pull_request.number) || github.ref }}
upload: upload:
name: Upload name: Upload
@ -292,10 +333,12 @@ jobs:
needs: build-docker needs: build-docker
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1 uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0
- name: Log in to Azure - production subscription - name: Log in to Azure - production subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
@ -306,9 +349,9 @@ jobs:
run: az acr login -n $_AZ_REGISTRY --only-show-errors run: az acr login -n $_AZ_REGISTRY --only-show-errors
- name: Make Docker stubs - name: Make Docker stubs
if: github.ref == 'refs/heads/main' || if: |
github.ref == 'refs/heads/rc' || github.event_name != 'pull_request'
github.ref == 'refs/heads/hotfix-rc' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
run: | run: |
# Set proper setup image based on branch # Set proper setup image based on branch
case "$GITHUB_REF" in case "$GITHUB_REF" in
@ -348,38 +391,48 @@ jobs:
cd docker-stub/EU; zip -r ../../docker-stub-EU.zip *; cd ../.. cd docker-stub/EU; zip -r ../../docker-stub-EU.zip *; cd ../..
- name: Make Docker stub checksums - name: Make Docker stub checksums
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc' if: |
github.event_name != 'pull_request'
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
run: | run: |
sha256sum docker-stub-US.zip > docker-stub-US-sha256.txt sha256sum docker-stub-US.zip > docker-stub-US-sha256.txt
sha256sum docker-stub-EU.zip > docker-stub-EU-sha256.txt sha256sum docker-stub-EU.zip > docker-stub-EU-sha256.txt
- name: Upload Docker stub US artifact - name: Upload Docker stub US artifact
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc' if: |
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 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: with:
name: docker-stub-US.zip name: docker-stub-US.zip
path: docker-stub-US.zip path: docker-stub-US.zip
if-no-files-found: error if-no-files-found: error
- name: Upload Docker stub EU artifact - name: Upload Docker stub EU artifact
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc' if: |
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 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: with:
name: docker-stub-EU.zip name: docker-stub-EU.zip
path: docker-stub-EU.zip path: docker-stub-EU.zip
if-no-files-found: error if-no-files-found: error
- name: Upload Docker stub US checksum artifact - name: Upload Docker stub US checksum artifact
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc' if: |
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 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: with:
name: docker-stub-US-sha256.txt name: docker-stub-US-sha256.txt
path: docker-stub-US-sha256.txt path: docker-stub-US-sha256.txt
if-no-files-found: error if-no-files-found: error
- name: Upload Docker stub EU checksum artifact - name: Upload Docker stub EU checksum artifact
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc' if: |
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 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: with:
name: docker-stub-EU-sha256.txt name: docker-stub-EU-sha256.txt
path: docker-stub-EU-sha256.txt path: docker-stub-EU-sha256.txt
@ -403,7 +456,7 @@ jobs:
GLOBALSETTINGS__SQLSERVER__CONNECTIONSTRING: "placeholder" GLOBALSETTINGS__SQLSERVER__CONNECTIONSTRING: "placeholder"
- name: Upload Public API Swagger artifact - name: Upload Public API Swagger artifact
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with: with:
name: swagger.json name: swagger.json
path: swagger.json path: swagger.json
@ -437,14 +490,14 @@ jobs:
GLOBALSETTINGS__SQLSERVER__CONNECTIONSTRING: "placeholder" GLOBALSETTINGS__SQLSERVER__CONNECTIONSTRING: "placeholder"
- name: Upload Internal API Swagger artifact - name: Upload Internal API Swagger artifact
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with: with:
name: internal.json name: internal.json
path: internal.json path: internal.json
if-no-files-found: error if-no-files-found: error
- name: Upload Identity Swagger artifact - name: Upload Identity Swagger artifact
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with: with:
name: identity.json name: identity.json
path: identity.json path: identity.json
@ -453,7 +506,8 @@ jobs:
build-mssqlmigratorutility: build-mssqlmigratorutility:
name: Build MSSQL migrator utility name: Build MSSQL migrator utility
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
needs: lint needs:
- lint
defaults: defaults:
run: run:
shell: bash shell: bash
@ -467,10 +521,12 @@ jobs:
- win-x64 - win-x64
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1 uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0
- name: Print environment - name: Print environment
run: | run: |
@ -486,7 +542,7 @@ jobs:
- name: Upload project artifact for Windows - name: Upload project artifact for Windows
if: ${{ contains(matrix.target, 'win') == true }} if: ${{ contains(matrix.target, 'win') == true }}
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with: with:
name: MsSqlMigratorUtility-${{ matrix.target }} name: MsSqlMigratorUtility-${{ matrix.target }}
path: util/MsSqlMigratorUtility/obj/build-output/publish/MsSqlMigratorUtility.exe path: util/MsSqlMigratorUtility/obj/build-output/publish/MsSqlMigratorUtility.exe
@ -494,7 +550,7 @@ jobs:
- name: Upload project artifact - name: Upload project artifact
if: ${{ contains(matrix.target, 'win') == false }} if: ${{ contains(matrix.target, 'win') == false }}
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with: with:
name: MsSqlMigratorUtility-${{ matrix.target }} name: MsSqlMigratorUtility-${{ matrix.target }}
path: util/MsSqlMigratorUtility/obj/build-output/publish/MsSqlMigratorUtility path: util/MsSqlMigratorUtility/obj/build-output/publish/MsSqlMigratorUtility
@ -502,8 +558,12 @@ jobs:
self-host-build: self-host-build:
name: Trigger self-host build name: Trigger self-host build
if: |
github.event_name != 'pull_request'
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
needs: build-docker needs:
- build-docker
steps: steps:
- name: Log in to Azure - CI subscription - name: Log in to Azure - CI subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
@ -534,9 +594,10 @@ jobs:
trigger-k8s-deploy: trigger-k8s-deploy:
name: Trigger k8s deploy name: Trigger k8s deploy
if: github.ref == 'refs/heads/main' if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
needs: build-docker needs:
- build-docker
steps: steps:
- name: Log in to Azure - CI subscription - name: Log in to Azure - CI subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
@ -568,9 +629,13 @@ jobs:
trigger-ee-updates: trigger-ee-updates:
name: Trigger Ephemeral Environment updates name: Trigger Ephemeral Environment updates
if: github.ref != 'refs/heads/main' && contains(github.event.pull_request.labels.*.name, 'ephemeral-environment') if: |
needs.build-artifacts.outputs.has_secrets == 'true'
&& github.event_name == 'pull_request'
&& contains(github.event.pull_request.labels.*.name, 'ephemeral-environment')
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
needs: build-docker needs:
- build-docker
steps: steps:
- name: Log in to Azure - CI subscription - name: Log in to Azure - CI subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
@ -595,10 +660,25 @@ jobs:
workflow_id: '_update_ephemeral_tags.yml', workflow_id: '_update_ephemeral_tags.yml',
ref: 'main', ref: 'main',
inputs: { inputs: {
ephemeral_env_branch: '${{ github.head_ref }}' ephemeral_env_branch: process.env.GITHUB_HEAD_REF
} }
}) })
trigger-ephemeral-environment-sync:
name: Trigger Ephemeral Environment Sync
needs: trigger-ee-updates
if: |
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: check-failures:
name: Check for failures name: Check for failures
if: always() if: always()
@ -614,9 +694,8 @@ jobs:
steps: steps:
- name: Check if any job failed - name: Check if any job failed
if: | if: |
(github.ref == 'refs/heads/main' github.event_name != 'pull_request'
|| github.ref == 'refs/heads/rc' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
|| github.ref == 'refs/heads/hotfix-rc')
&& contains(needs.*.result, 'failure') && contains(needs.*.result, 'failure')
run: exit 1 run: exit 1

21
.github/workflows/build_target.yml vendored Normal file
View File

@ -0,0 +1,21 @@
name: Build on PR Target
on:
pull_request_target:
types: [opened, synchronize]
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

View File

@ -1,4 +1,3 @@
---
name: Container registry cleanup name: Container registry cleanup
on: on:

View File

@ -1,4 +1,3 @@
---
name: Cleanup RC Branch name: Cleanup RC Branch
on: on:
@ -24,7 +23,7 @@ jobs:
secrets: "github-pat-bitwarden-devops-bot-repo-scope" secrets: "github-pat-bitwarden-devops-bot-repo-scope"
- name: Checkout main - name: Checkout main
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
ref: main ref: main
token: ${{ steps.retrieve-bot-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }} token: ${{ steps.retrieve-bot-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }}

View File

@ -33,11 +33,11 @@ jobs:
steps: steps:
- name: Check out repository - name: Check out repository
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Collect - name: Collect
id: collect id: collect
uses: launchdarkly/find-code-references-in-pull-request@d008aa4f321d8cd35314d9cb095388dcfde84439 # v2.0.0 uses: launchdarkly/find-code-references-in-pull-request@30f4c4ab2949bbf258b797ced2fbf6dea34df9ce # v2.1.0
with: with:
project-key: default project-key: default
environment-key: dev environment-key: dev

View File

@ -1,4 +1,3 @@
---
name: Enforce PR labels name: Enforce PR labels
on: on:
@ -7,13 +6,13 @@ on:
types: [labeled, unlabeled, opened, reopened, synchronize] types: [labeled, unlabeled, opened, reopened, synchronize]
jobs: jobs:
enforce-label: enforce-label:
if: ${{ contains(github.event.*.labels.*.name, 'hold') || contains(github.event.*.labels.*.name, 'needs-qa') || contains(github.event.*.labels.*.name, 'DB-migrations-changed') }} if: ${{ contains(github.event.*.labels.*.name, 'hold') || contains(github.event.*.labels.*.name, 'needs-qa') || contains(github.event.*.labels.*.name, 'DB-migrations-changed') || contains(github.event.*.labels.*.name, 'ephemeral-environment') }}
name: Enforce label name: Enforce label
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- name: Check for label - name: Check for label
run: | run: |
echo "PRs with the hold or needs-qa labels cannot be merged" echo "PRs with the hold, needs-qa or ephemeral-environment labels cannot be merged"
echo "### :x: PRs with the hold or needs-qa labels cannot be merged" >> $GITHUB_STEP_SUMMARY echo "### :x: PRs with the hold, needs-qa or ephemeral-environment labels cannot be merged" >> $GITHUB_STEP_SUMMARY
exit 1 exit 1

View File

@ -0,0 +1,38 @@
name: Ephemeral Environment
on:
pull_request:
types: [labeled]
jobs:
trigger-ee-updates:
name: Trigger Ephemeral Environment updates
runs-on: ubuntu-24.04
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
}
})

View File

@ -1,7 +1,6 @@
# Runs if there are changes to the paths: list. # Runs if there are changes to the paths: list.
# Starts a matrix job to check for modified files, then sets output based on the results. # Starts a matrix job to check for modified files, then sets output based on the results.
# The input decides if the label job is ran, adding a label to the PR. # The input decides if the label job is ran, adding a label to the PR.
---
name: Protect files name: Protect files
on: on:
@ -29,7 +28,7 @@ jobs:
label: "DB-migrations-changed" label: "DB-migrations-changed"
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
fetch-depth: 2 fetch-depth: 2

View File

@ -1,4 +1,3 @@
---
name: Publish name: Publish
run-name: Publish ${{ inputs.publish_type }} run-name: Publish ${{ inputs.publish_type }}
@ -99,7 +98,7 @@ jobs:
echo "Github Release Option: $RELEASE_OPTION" echo "Github Release Option: $RELEASE_OPTION"
- name: Check out repo - name: Check out repo
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up project name - name: Set up project name
id: setup id: setup

View File

@ -1,4 +1,3 @@
---
name: Release name: Release
run-name: Release ${{ inputs.release_type }} run-name: Release ${{ inputs.release_type }}
@ -37,7 +36,7 @@ jobs:
fi fi
- name: Check out repo - name: Check out repo
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Check release version - name: Check release version
id: version id: version
@ -86,7 +85,7 @@ jobs:
- name: Create release - name: Create release
if: ${{ inputs.release_type != 'Dry Run' }} if: ${{ inputs.release_type != 'Dry Run' }}
uses: ncipollo/release-action@2c591bcc8ecdcd2db72b97d6147f871fcd833ba5 # v1.14.0 uses: ncipollo/release-action@cdcc88a9acf3ca41c16c37bb7d21b9ad48560d87 # v1.15.0
with: with:
artifacts: "docker-stub-US.zip, artifacts: "docker-stub-US.zip,
docker-stub-US-sha256.txt, docker-stub-US-sha256.txt,

View File

@ -3,12 +3,13 @@ name: Repository management
on: on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
branch_to_cut: task:
default: "rc" default: "Version Bump"
description: "Branch to cut" description: "Task to execute"
options: options:
- "rc" - "Version Bump"
- "hotfix-rc" - "Version Bump and Cut rc"
- "Version Bump and Cut hotfix-rc"
required: true required: true
type: choice type: choice
target_ref: target_ref:
@ -22,18 +23,50 @@ on:
type: string type: string
jobs: jobs:
setup:
name: Setup
runs-on: ubuntu-24.04
outputs:
branch: ${{ steps.set-branch.outputs.branch }}
steps:
- name: Set branch
id: set-branch
env:
TASK: ${{ inputs.task }}
run: |
if [[ "$TASK" == "Version Bump" ]]; then
BRANCH="none"
elif [[ "$TASK" == "Version Bump and Cut rc" ]]; then
BRANCH="rc"
elif [[ "$TASK" == "Version Bump and Cut hotfix-rc" ]]; then
BRANCH="hotfix-rc"
fi
echo "branch=$BRANCH" >> $GITHUB_OUTPUT
cut_branch: cut_branch:
name: Cut branch name: Cut branch
runs-on: ubuntu-22.04 if: ${{ needs.setup.outputs.branch != 'none' }}
needs: setup
runs-on: ubuntu-24.04
steps: steps:
- name: Generate GH App token
uses: actions/create-github-app-token@c1a285145b9d317df6ced56c09f525b5c2b6f755 # v1.11.1
id: app-token
with:
app-id: ${{ secrets.BW_GHAPP_ID }}
private-key: ${{ secrets.BW_GHAPP_KEY }}
- name: Check out target ref - name: Check out target ref
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
ref: ${{ inputs.target_ref }} ref: ${{ inputs.target_ref }}
token: ${{ steps.app-token.outputs.token }}
- name: Check if ${{ inputs.branch_to_cut }} branch exists - name: Check if ${{ needs.setup.outputs.branch }} branch exists
env: env:
BRANCH_NAME: ${{ inputs.branch_to_cut }} BRANCH_NAME: ${{ needs.setup.outputs.branch }}
run: | run: |
if [[ $(git ls-remote --heads origin $BRANCH_NAME) ]]; then if [[ $(git ls-remote --heads origin $BRANCH_NAME) ]]; then
echo "$BRANCH_NAME already exists! Please delete $BRANCH_NAME before running again." >> $GITHUB_STEP_SUMMARY echo "$BRANCH_NAME already exists! Please delete $BRANCH_NAME before running again." >> $GITHUB_STEP_SUMMARY
@ -42,7 +75,7 @@ jobs:
- name: Cut branch - name: Cut branch
env: env:
BRANCH_NAME: ${{ inputs.branch_to_cut }} BRANCH_NAME: ${{ needs.setup.outputs.branch }}
run: | run: |
git switch --quiet --create $BRANCH_NAME git switch --quiet --create $BRANCH_NAME
git push --quiet --set-upstream origin $BRANCH_NAME git push --quiet --set-upstream origin $BRANCH_NAME
@ -50,8 +83,11 @@ jobs:
bump_version: bump_version:
name: Bump Version name: Bump Version
runs-on: ubuntu-22.04 if: ${{ always() }}
needs: cut_branch runs-on: ubuntu-24.04
needs:
- cut_branch
- setup
outputs: outputs:
version: ${{ steps.set-final-version-output.outputs.version }} version: ${{ steps.set-final-version-output.outputs.version }}
steps: steps:
@ -61,10 +97,23 @@ jobs:
with: with:
version: ${{ inputs.version_number_override }} version: ${{ inputs.version_number_override }}
- name: Generate GH App token
uses: actions/create-github-app-token@c1a285145b9d317df6ced56c09f525b5c2b6f755 # v1.11.1
id: app-token
with:
app-id: ${{ secrets.BW_GHAPP_ID }}
private-key: ${{ secrets.BW_GHAPP_KEY }}
- name: Check out branch - name: Check out branch
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
ref: main ref: main
token: ${{ steps.app-token.outputs.token }}
- name: Configure Git
run: |
git config --local user.email "actions@github.com"
git config --local user.name "Github Actions"
- name: Install xmllint - name: Install xmllint
run: | run: |
@ -123,85 +172,78 @@ jobs:
- name: Set final version output - name: Set final version output
id: set-final-version-output id: set-final-version-output
env:
VERSION: ${{ inputs.version_number_override }}
run: | run: |
if [[ "${{ steps.bump-version-override.outcome }}" = "success" ]]; then if [[ "${{ steps.bump-version-override.outcome }}" = "success" ]]; then
echo "version=${{ inputs.version_number_override }}" >> $GITHUB_OUTPUT echo "version=$VERSION" >> $GITHUB_OUTPUT
elif [[ "${{ steps.bump-version-automatic.outcome }}" = "success" ]]; then elif [[ "${{ steps.bump-version-automatic.outcome }}" = "success" ]]; then
echo "version=${{ steps.calculate-next-version.outputs.version }}" >> $GITHUB_OUTPUT echo "version=${{ steps.calculate-next-version.outputs.version }}" >> $GITHUB_OUTPUT
fi fi
- name: Configure Git
run: |
git config --local user.email "actions@github.com"
git config --local user.name "Github Actions"
- name: Commit files - name: Commit files
run: git commit -m "Bumped version to ${{ steps.set-final-version-output.outputs.version }}" -a run: git commit -m "Bumped version to ${{ steps.set-final-version-output.outputs.version }}" -a
- name: Push changes - name: Push changes
run: | run: git push
git pull -pt
git push
cherry_pick: cherry_pick:
name: Cherry-Pick Commit(s) name: Cherry-Pick Commit(s)
runs-on: ubuntu-22.04 if: ${{ needs.setup.outputs.branch != 'none' }}
needs: bump_version runs-on: ubuntu-24.04
needs:
- bump_version
- setup
steps: steps:
- name: Check out main branch - name: Generate GH App token
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/create-github-app-token@c1a285145b9d317df6ced56c09f525b5c2b6f755 # v1.11.1
id: app-token
with: with:
app-id: ${{ secrets.BW_GHAPP_ID }}
private-key: ${{ secrets.BW_GHAPP_KEY }}
- name: Check out main branch
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
ref: main ref: main
token: ${{ steps.app-token.outputs.token }}
- name: Configure Git
run: |
git config --local user.email "actions@github.com"
git config --local user.name "Github Actions"
- name: Install xmllint - name: Install xmllint
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -y libxml2-utils sudo apt-get install -y libxml2-utils
- name: Verify version has been updated
env:
NEW_VERSION: ${{ needs.bump_version.outputs.version }}
run: |
# Wait for version to change.
while : ; do
echo "Waiting for version to be updated..."
git pull --force
CURRENT_VERSION=$(xmllint -xpath "/Project/PropertyGroup/Version/text()" Directory.Build.props)
# If the versions don't match we continue the loop, otherwise we break out of the loop.
[[ "$NEW_VERSION" != "$CURRENT_VERSION" ]] || break
sleep 10
done
- name: Get last version commit(s)
id: get-commits
run: |
git switch main
MAIN_COMMIT=$(git log --reverse --pretty=format:"%H" --max-count=1 Directory.Build.props)
echo "main_commit=$MAIN_COMMIT" >> $GITHUB_OUTPUT
if [[ $(git ls-remote --heads origin rc) ]]; then
git switch rc
RC_COMMIT=$(git log --reverse --pretty=format:"%H" --max-count=1 Directory.Build.props)
echo "rc_commit=$RC_COMMIT" >> $GITHUB_OUTPUT
RC_VERSION=$(xmllint -xpath "/Project/PropertyGroup/Version/text()" Directory.Build.props)
echo "rc_version=$RC_VERSION" >> $GITHUB_OUTPUT
fi
- name: Configure Git
run: |
git config --local user.email "actions@github.com"
git config --local user.name "Github Actions"
- name: Perform cherry-pick(s) - name: Perform cherry-pick(s)
env: env:
CUT_BRANCH: ${{ inputs.branch_to_cut }} CUT_BRANCH: ${{ needs.setup.outputs.branch }}
MAIN_COMMIT: ${{ steps.get-commits.outputs.main_commit }}
RC_COMMIT: ${{ steps.get-commits.outputs.rc_commit }}
RC_VERSION: ${{ steps.get-commits.outputs.rc_version }}
run: | run: |
# Function for cherry-picking
cherry_pick () {
local source_branch=$1
local destination_branch=$2
# Get project commit/version from source branch
git switch $source_branch
SOURCE_COMMIT=$(git log --reverse --pretty=format:"%H" --max-count=1 Directory.Build.props)
SOURCE_VERSION=$(xmllint -xpath "/Project/PropertyGroup/Version/text()" Directory.Build.props)
# Get project commit/version from destination branch
git switch $destination_branch
DESTINATION_VERSION=$(xmllint -xpath "/Project/PropertyGroup/Version/text()" Directory.Build.props)
if [[ "$DESTINATION_VERSION" != "$SOURCE_VERSION" ]]; then
git cherry-pick --strategy-option=theirs -x $SOURCE_COMMIT
git push -u origin $destination_branch
fi
}
# If we are cutting 'hotfix-rc': # If we are cutting 'hotfix-rc':
if [[ "$CUT_BRANCH" == "hotfix-rc" ]]; then if [[ "$CUT_BRANCH" == "hotfix-rc" ]]; then
@ -209,25 +251,16 @@ jobs:
if [[ $(git ls-remote --heads origin rc) ]]; then if [[ $(git ls-remote --heads origin rc) ]]; then
# Chery-pick from 'rc' into 'hotfix-rc' # Chery-pick from 'rc' into 'hotfix-rc'
git switch hotfix-rc cherry_pick rc hotfix-rc
HOTFIX_RC_VERSION=$(xmllint -xpath "/Project/PropertyGroup/Version/text()" Directory.Build.props)
if [[ "$HOTFIX_RC_VERSION" != "$RC_VERSION" ]]; then
git cherry-pick --strategy-option=theirs -x $RC_COMMIT
git push -u origin hotfix-rc
fi
# Cherry-pick from 'main' into 'rc' # Cherry-pick from 'main' into 'rc'
git switch rc cherry_pick main rc
git cherry-pick --strategy-option=theirs -x $MAIN_COMMIT
git push -u origin rc
# If the 'rc' branch does not exist: # If the 'rc' branch does not exist:
else else
# Cherry-pick from 'main' into 'hotfix-rc' # Cherry-pick from 'main' into 'hotfix-rc'
git switch hotfix-rc cherry_pick main hotfix-rc
git cherry-pick --strategy-option=theirs -x $MAIN_COMMIT
git push -u origin hotfix-rc
fi fi
@ -235,9 +268,7 @@ jobs:
elif [[ "$CUT_BRANCH" == "rc" ]]; then elif [[ "$CUT_BRANCH" == "rc" ]]; then
# Cherry-pick from 'main' into 'rc' # Cherry-pick from 'main' into 'rc'
git switch rc cherry_pick main rc
git cherry-pick --strategy-option=theirs -x $MAIN_COMMIT
git push -u origin rc
fi fi

View File

@ -26,12 +26,12 @@ jobs:
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}
- name: Scan with Checkmarx - name: Scan with Checkmarx
uses: checkmarx/ast-github-action@f0869bd1a37fddc06499a096101e6c900e815d81 # 2.0.36 uses: checkmarx/ast-github-action@184bf2f64f55d1c93fd6636d539edf274703e434 # 2.0.41
env: env:
INCREMENTAL: "${{ contains(github.event_name, 'pull_request') && '--sast-incremental' || '' }}" INCREMENTAL: "${{ contains(github.event_name, 'pull_request') && '--sast-incremental' || '' }}"
with: with:
@ -46,9 +46,11 @@ jobs:
--output-path . ${{ env.INCREMENTAL }} --output-path . ${{ env.INCREMENTAL }}
- name: Upload Checkmarx results to GitHub - name: Upload Checkmarx results to GitHub
uses: github/codeql-action/upload-sarif@f779452ac5af1c261dce0346a8f964149f49322b # v3.26.13 uses: github/codeql-action/upload-sarif@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3.28.8
with: with:
sarif_file: cx_result.sarif 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: quality:
name: Quality scan name: Quality scan
@ -60,19 +62,19 @@ jobs:
steps: steps:
- name: Set up JDK 17 - name: Set up JDK 17
uses: actions/setup-java@b36c23c0d998641eff861008f374ee103c25ac73 # v4.4.0 uses: actions/setup-java@7a6d8a8234af8eb26422e24e3006232cccaa061b # v4.6.0
with: with:
java-version: 17 java-version: 17
distribution: "zulu" distribution: "zulu"
- name: Check out repo - name: Check out repo
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
fetch-depth: 0 fetch-depth: 0
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1 uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0
- name: Install SonarCloud scanner - name: Install SonarCloud scanner
run: dotnet tool install dotnet-sonarscanner -g run: dotnet tool install dotnet-sonarscanner -g
@ -80,12 +82,11 @@ jobs:
- name: Scan with SonarCloud - name: Scan with SonarCloud
env: env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
dotnet-sonarscanner begin /k:"${{ github.repository_owner }}_${{ github.event.repository.name }}" \ dotnet-sonarscanner begin /k:"${{ github.repository_owner }}_${{ github.event.repository.name }}" \
/d:sonar.test.inclusions=test/,bitwarden_license/test/ \ /d:sonar.test.inclusions=test/,bitwarden_license/test/ \
/d:sonar.exclusions=test/,bitwarden_license/test/ \ /d:sonar.exclusions=test/,bitwarden_license/test/ \
/o:"${{ github.repository_owner }}" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" \ /o:"${{ github.repository_owner }}" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" \
/d:sonar.host.url="https://sonarcloud.io" /d:sonar.host.url="https://sonarcloud.io" ${{ contains(github.event_name, 'pull_request') && format('/d:sonar.pullrequest.key={0}', github.event.pull_request.number) || '' }}
dotnet build dotnet build
dotnet-sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}" dotnet-sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}"

View File

@ -1,4 +1,3 @@
---
name: Staleness name: Staleness
on: on:
workflow_dispatch: workflow_dispatch:
@ -11,7 +10,7 @@ jobs:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- name: Check - name: Check
uses: actions/stale@28ca1036281a5e5922ead5184a1bbf96e5fc984e # v9.0.0 uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0
with: with:
stale-issue-label: "needs-reply" stale-issue-label: "needs-reply"
stale-pr-label: "needs-changes" stale-pr-label: "needs-changes"

View File

@ -1,4 +1,3 @@
---
name: Database testing name: Database testing
on: on:
@ -18,6 +17,7 @@ on:
- "src/Infrastructure.Dapper/**" # Changes to SQL Server Dapper Repository Layer - "src/Infrastructure.Dapper/**" # Changes to SQL Server Dapper Repository Layer
- "src/Infrastructure.EntityFramework/**" # Changes to Entity Framework Repository Layer - "src/Infrastructure.EntityFramework/**" # Changes to Entity Framework Repository Layer
- "test/Infrastructure.IntegrationTest/**" # Any changes to the tests - "test/Infrastructure.IntegrationTest/**" # Any changes to the tests
- "src/**/Entities/**/*.cs" # Database entity definitions
pull_request: pull_request:
paths: paths:
- ".github/workflows/test-database.yml" # This file - ".github/workflows/test-database.yml" # This file
@ -29,6 +29,7 @@ on:
- "src/Infrastructure.Dapper/**" # Changes to SQL Server Dapper Repository Layer - "src/Infrastructure.Dapper/**" # Changes to SQL Server Dapper Repository Layer
- "src/Infrastructure.EntityFramework/**" # Changes to Entity Framework Repository Layer - "src/Infrastructure.EntityFramework/**" # Changes to Entity Framework Repository Layer
- "test/Infrastructure.IntegrationTest/**" # Any changes to the tests - "test/Infrastructure.IntegrationTest/**" # Any changes to the tests
- "src/**/Entities/**/*.cs" # Database entity definitions
jobs: jobs:
test: test:
@ -36,10 +37,10 @@ jobs:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1 uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0
- name: Restore tools - name: Restore tools
run: dotnet tool restore run: dotnet tool restore
@ -52,6 +53,11 @@ jobs:
docker compose --profile mssql --profile postgres --profile mysql up -d docker compose --profile mssql --profile postgres --profile mysql up -d
shell: pwsh shell: pwsh
- name: Add MariaDB for unified
# Use a different port than MySQL
run: |
docker run --detach --name mariadb --env MARIADB_ROOT_PASSWORD=mariadb-password -p 4306:3306 mariadb:10
# I've seen the SQL Server container not be ready for commands right after starting up and just needing a bit longer to be ready # I've seen the SQL Server container not be ready for commands right after starting up and just needing a bit longer to be ready
- name: Sleep - name: Sleep
run: sleep 15s run: sleep 15s
@ -85,6 +91,12 @@ jobs:
env: env:
CONN_STR: "server=localhost;uid=root;pwd=SET_A_PASSWORD_HERE_123;database=vault_dev;Allow User Variables=true" CONN_STR: "server=localhost;uid=root;pwd=SET_A_PASSWORD_HERE_123;database=vault_dev;Allow User Variables=true"
- name: Migrate MariaDB
working-directory: "util/MySqlMigrations"
run: 'dotnet ef database update --connection "$CONN_STR" -- --GlobalSettings:MySql:ConnectionString="$CONN_STR"'
env:
CONN_STR: "server=localhost;port=4306;uid=root;pwd=mariadb-password;database=vault_dev;Allow User Variables=true"
- name: Migrate Postgres - name: Migrate Postgres
working-directory: "util/PostgresMigrations" working-directory: "util/PostgresMigrations"
run: 'dotnet ef database update --connection "$CONN_STR" -- --GlobalSettings:PostgreSql:ConnectionString="$CONN_STR"' run: 'dotnet ef database update --connection "$CONN_STR" -- --GlobalSettings:PostgreSql:ConnectionString="$CONN_STR"'
@ -112,13 +124,20 @@ jobs:
# Default Sqlite # Default Sqlite
BW_TEST_DATABASES__3__TYPE: "Sqlite" BW_TEST_DATABASES__3__TYPE: "Sqlite"
BW_TEST_DATABASES__3__CONNECTIONSTRING: "Data Source=${{ runner.temp }}/test.db" BW_TEST_DATABASES__3__CONNECTIONSTRING: "Data Source=${{ runner.temp }}/test.db"
run: dotnet test --logger "trx;LogFileName=infrastructure-test-results.trx" # Unified MariaDB
BW_TEST_DATABASES__4__TYPE: "MySql"
BW_TEST_DATABASES__4__CONNECTIONSTRING: "server=localhost;port=4306;uid=root;pwd=mariadb-password;database=vault_dev;Allow User Variables=true"
run: dotnet test --logger "trx;LogFileName=infrastructure-test-results.trx" /p:CoverletOutputFormatter="cobertura" --collect:"XPlat Code Coverage"
shell: pwsh shell: pwsh
- name: Print MySQL Logs - name: Print MySQL Logs
if: failure() if: failure()
run: 'docker logs $(docker ps --quiet --filter "name=mysql")' run: 'docker logs $(docker ps --quiet --filter "name=mysql")'
- name: Print MariaDB Logs
if: failure()
run: 'docker logs $(docker ps --quiet --filter "name=mariadb")'
- name: Print Postgres Logs - name: Print Postgres Logs
if: failure() if: failure()
run: 'docker logs $(docker ps --quiet --filter "name=postgres")' run: 'docker logs $(docker ps --quiet --filter "name=postgres")'
@ -128,14 +147,17 @@ jobs:
run: 'docker logs $(docker ps --quiet --filter "name=mssql")' run: 'docker logs $(docker ps --quiet --filter "name=mssql")'
- name: Report test results - name: Report test results
uses: dorny/test-reporter@31a54ee7ebcacc03a09ea97a7e5465a47b84aea5 # v1.9.1 uses: dorny/test-reporter@6e6a65b7a0bd2c9197df7d0ae36ac5cee784230c # v2.0.0
if: always() if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !cancelled() }}
with: with:
name: Test Results name: Test Results
path: "**/*-test-results.trx" path: "**/*-test-results.trx"
reporter: dotnet-trx reporter: dotnet-trx
fail-on-error: true fail-on-error: true
- name: Upload to codecov.io
uses: codecov/codecov-action@1e68e06f1dbfde0e4cefc87efeba9e4643565303 # v5.1.2
- name: Docker Compose down - name: Docker Compose down
if: always() if: always()
working-directory: "dev" working-directory: "dev"
@ -147,10 +169,10 @@ jobs:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1 uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0
- name: Print environment - name: Print environment
run: | run: |
@ -164,7 +186,7 @@ jobs:
shell: pwsh shell: pwsh
- name: Upload DACPAC - name: Upload DACPAC
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with: with:
name: sql.dacpac name: sql.dacpac
path: Sql.dacpac path: Sql.dacpac
@ -190,7 +212,7 @@ jobs:
shell: pwsh shell: pwsh
- name: Report validation results - name: Report validation results
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with: with:
name: report.xml name: report.xml
path: | path: |
@ -201,7 +223,7 @@ jobs:
run: | run: |
if grep -q "<Operations>" "report.xml"; then if grep -q "<Operations>" "report.xml"; then
echo echo
echo "Migrations are out of sync with sqlproj!" echo "Migration files are not in sync with the files in the Sql project. Review to make sure that any stored procedures / other db changes match with the stored procedures in the Sql project."
exit 1 exit 1
else else
echo "Report looks good" echo "Report looks good"

View File

@ -13,29 +13,10 @@ env:
_AZ_REGISTRY: "bitwardenprod.azurecr.io" _AZ_REGISTRY: "bitwardenprod.azurecr.io"
jobs: jobs:
check-test-secrets:
name: Check for test secrets
runs-on: ubuntu-22.04
outputs:
available: ${{ steps.check-test-secrets.outputs.available }}
permissions:
contents: read
steps:
- name: Check
id: check-test-secrets
run: |
if [ "${{ secrets.CODECOV_TOKEN }}" != '' ]; then
echo "available=true" >> $GITHUB_OUTPUT;
else
echo "available=false" >> $GITHUB_OUTPUT;
fi
testing: testing:
name: Run tests name: Run tests
if: ${{ startsWith(github.head_ref, 'version_bump_') == false }} if: ${{ startsWith(github.head_ref, 'version_bump_') == false }}
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
needs: check-test-secrets
permissions: permissions:
checks: write checks: write
contents: read contents: read
@ -46,10 +27,10 @@ jobs:
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1 uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0
- name: Print environment - name: Print environment
run: | run: |
@ -68,8 +49,8 @@ jobs:
run: dotnet test ./bitwarden_license/test --configuration Debug --logger "trx;LogFileName=bw-test-results.trx" /p:CoverletOutputFormatter="cobertura" --collect:"XPlat Code Coverage" run: dotnet test ./bitwarden_license/test --configuration Debug --logger "trx;LogFileName=bw-test-results.trx" /p:CoverletOutputFormatter="cobertura" --collect:"XPlat Code Coverage"
- name: Report test results - name: Report test results
uses: dorny/test-reporter@31a54ee7ebcacc03a09ea97a7e5465a47b84aea5 # v1.9.1 uses: dorny/test-reporter@6e6a65b7a0bd2c9197df7d0ae36ac5cee784230c # v2.0.0
if: ${{ needs.check-test-secrets.outputs.available == 'true' && !cancelled() }} if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !cancelled() }}
with: with:
name: Test Results name: Test Results
path: "**/*-test-results.trx" path: "**/*-test-results.trx"
@ -77,7 +58,4 @@ jobs:
fail-on-error: true fail-on-error: true
- name: Upload to codecov.io - name: Upload to codecov.io
uses: codecov/codecov-action@b9fd7d16f6d7d1b5d2bec1a2887e65ceed900238 # v4.6.0 uses: codecov/codecov-action@1e68e06f1dbfde0e4cefc87efeba9e4643565303 # v5.1.2
if: ${{ needs.check-test-secrets.outputs.available == 'true' }}
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}

18
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,18 @@
{
"recommendations": [
"nick-rudenko.back-n-forth",
"streetsidesoftware.code-spell-checker",
"MS-vsliveshare.vsliveshare",
"mhutchie.git-graph",
"donjayamanne.githistory",
"eamodio.gitlens",
"jakebathman.mysql-syntax",
"ckolkman.vscode-postgres",
"ms-dotnettools.csharp",
"formulahendry.dotnet-test-explorer",
"adrianwilczynski.user-secrets"
]
}

View File

@ -3,11 +3,17 @@
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<Version>2024.10.0</Version> <Version>2025.4.0</Version>
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace> <RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion> <IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
<!-- Treat it as a test project if the project hasn't set their own value and it follows our test project conventions -->
<IsTestProject Condition="'$(IsTestProject)' == '' and ($(MSBuildProjectName.EndsWith('.Test')) or $(MSBuildProjectName.EndsWith('.IntegrationTest')))">true</IsTestProject>
<Nullable Condition="'$(Nullable)' == '' and '$(IsTestProject)' == 'true'">annotations</Nullable>
<!-- Uncomment the below line when we are ready to enable nullable repo wide -->
<!-- <Nullable Condition="'$(Nullable)' == '' and '$(IsTestProject)' != 'true'">enable</Nullable> -->
<TreatWarningsAsErrors Condition="'$(TreatWarningsAsErrors)' == ''">true</TreatWarningsAsErrors>
</PropertyGroup> </PropertyGroup>
<!-- <!--

View File

@ -18,7 +18,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
.editorconfig = .editorconfig .editorconfig = .editorconfig
TRADEMARK_GUIDELINES.md = TRADEMARK_GUIDELINES.md TRADEMARK_GUIDELINES.md = TRADEMARK_GUIDELINES.md
SECURITY.md = SECURITY.md SECURITY.md = SECURITY.md
NuGet.Config = NuGet.Config
LICENSE_FAQ.md = LICENSE_FAQ.md LICENSE_FAQ.md = LICENSE_FAQ.md
LICENSE_BITWARDEN.txt = LICENSE_BITWARDEN.txt LICENSE_BITWARDEN.txt = LICENSE_BITWARDEN.txt
LICENSE_AGPL.txt = LICENSE_AGPL.txt LICENSE_AGPL.txt = LICENSE_AGPL.txt
@ -126,6 +125,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Notifications.Test", "test\
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Infrastructure.Dapper.Test", "test\Infrastructure.Dapper.Test\Infrastructure.Dapper.Test.csproj", "{4A725DB3-BE4F-4C23-9087-82D0610D67AF}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Infrastructure.Dapper.Test", "test\Infrastructure.Dapper.Test\Infrastructure.Dapper.Test.csproj", "{4A725DB3-BE4F-4C23-9087-82D0610D67AF}"
EndProject 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
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@ -314,6 +317,14 @@ Global
{4A725DB3-BE4F-4C23-9087-82D0610D67AF}.Debug|Any CPU.Build.0 = Debug|Any CPU {4A725DB3-BE4F-4C23-9087-82D0610D67AF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4A725DB3-BE4F-4C23-9087-82D0610D67AF}.Release|Any CPU.ActiveCfg = Release|Any CPU {4A725DB3-BE4F-4C23-9087-82D0610D67AF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4A725DB3-BE4F-4C23-9087-82D0610D67AF}.Release|Any CPU.Build.0 = Release|Any CPU {4A725DB3-BE4F-4C23-9087-82D0610D67AF}.Release|Any CPU.Build.0 = Release|Any CPU
{4F4C63A9-AEE2-48C4-AB86-A5BCD665E401}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{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
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
@ -364,6 +375,8 @@ Global
{81673EFB-7134-4B4B-A32F-1EA05F0EF3CE} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F} {81673EFB-7134-4B4B-A32F-1EA05F0EF3CE} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
{90D85D8F-5577-4570-A96E-5A2E185F0F6F} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F} {90D85D8F-5577-4570-A96E-5A2E185F0F6F} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
{4A725DB3-BE4F-4C23-9087-82D0610D67AF} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F} {4A725DB3-BE4F-4C23-9087-82D0610D67AF} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
{4F4C63A9-AEE2-48C4-AB86-A5BCD665E401} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
{3631BA42-6731-4118-A917-DAA43C5032B9} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
EndGlobalSection EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {E01CBF68-2E20-425F-9EDB-E0A6510CA92F} SolutionGuid = {E01CBF68-2E20-425F-9EDB-E0A6510CA92F}

View File

@ -1,5 +1,4 @@
using Bit.Core; using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Providers.Interfaces;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
@ -10,7 +9,6 @@ using Bit.Core.Billing.Repositories;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services;
namespace Bit.Commercial.Core.AdminConsole.Providers; namespace Bit.Commercial.Core.AdminConsole.Providers;
@ -21,25 +19,43 @@ public class CreateProviderCommand : ICreateProviderCommand
private readonly IProviderService _providerService; private readonly IProviderService _providerService;
private readonly IUserRepository _userRepository; private readonly IUserRepository _userRepository;
private readonly IProviderPlanRepository _providerPlanRepository; private readonly IProviderPlanRepository _providerPlanRepository;
private readonly IFeatureService _featureService;
public CreateProviderCommand( public CreateProviderCommand(
IProviderRepository providerRepository, IProviderRepository providerRepository,
IProviderUserRepository providerUserRepository, IProviderUserRepository providerUserRepository,
IProviderService providerService, IProviderService providerService,
IUserRepository userRepository, IUserRepository userRepository,
IProviderPlanRepository providerPlanRepository, IProviderPlanRepository providerPlanRepository)
IFeatureService featureService)
{ {
_providerRepository = providerRepository; _providerRepository = providerRepository;
_providerUserRepository = providerUserRepository; _providerUserRepository = providerUserRepository;
_providerService = providerService; _providerService = providerService;
_userRepository = userRepository; _userRepository = userRepository;
_providerPlanRepository = providerPlanRepository; _providerPlanRepository = providerPlanRepository;
_featureService = featureService;
} }
public async Task CreateMspAsync(Provider provider, string ownerEmail, int teamsMinimumSeats, int enterpriseMinimumSeats) public async Task CreateMspAsync(Provider provider, string ownerEmail, int teamsMinimumSeats, int enterpriseMinimumSeats)
{
var providerId = await CreateProviderAsync(provider, ownerEmail);
await Task.WhenAll(
CreateProviderPlanAsync(providerId, PlanType.TeamsMonthly, teamsMinimumSeats),
CreateProviderPlanAsync(providerId, PlanType.EnterpriseMonthly, enterpriseMinimumSeats));
}
public async Task CreateResellerAsync(Provider provider)
{
await ProviderRepositoryCreateAsync(provider, ProviderStatusType.Created);
}
public async Task CreateMultiOrganizationEnterpriseAsync(Provider provider, string ownerEmail, PlanType plan, int minimumSeats)
{
var providerId = await CreateProviderAsync(provider, ownerEmail);
await CreateProviderPlanAsync(providerId, plan, minimumSeats);
}
private async Task<Guid> CreateProviderAsync(Provider provider, string ownerEmail)
{ {
var owner = await _userRepository.GetByEmailAsync(ownerEmail); var owner = await _userRepository.GetByEmailAsync(ownerEmail);
if (owner == null) if (owner == null)
@ -47,12 +63,7 @@ public class CreateProviderCommand : ICreateProviderCommand
throw new BadRequestException("Invalid owner. Owner must be an existing Bitwarden user."); throw new BadRequestException("Invalid owner. Owner must be an existing Bitwarden user.");
} }
var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling); provider.Gateway = GatewayType.Stripe;
if (isConsolidatedBillingEnabled)
{
provider.Gateway = GatewayType.Stripe;
}
await ProviderRepositoryCreateAsync(provider, ProviderStatusType.Pending); await ProviderRepositoryCreateAsync(provider, ProviderStatusType.Pending);
@ -64,27 +75,10 @@ public class CreateProviderCommand : ICreateProviderCommand
Status = ProviderUserStatusType.Confirmed, Status = ProviderUserStatusType.Confirmed,
}; };
if (isConsolidatedBillingEnabled)
{
var providerPlans = new List<ProviderPlan>
{
CreateProviderPlan(provider.Id, PlanType.TeamsMonthly, teamsMinimumSeats),
CreateProviderPlan(provider.Id, PlanType.EnterpriseMonthly, enterpriseMinimumSeats)
};
foreach (var providerPlan in providerPlans)
{
await _providerPlanRepository.CreateAsync(providerPlan);
}
}
await _providerUserRepository.CreateAsync(providerUser); await _providerUserRepository.CreateAsync(providerUser);
await _providerService.SendProviderSetupInviteEmailAsync(provider, owner.Email); await _providerService.SendProviderSetupInviteEmailAsync(provider, owner.Email);
}
public async Task CreateResellerAsync(Provider provider) return provider.Id;
{
await ProviderRepositoryCreateAsync(provider, ProviderStatusType.Created);
} }
private async Task ProviderRepositoryCreateAsync(Provider provider, ProviderStatusType status) private async Task ProviderRepositoryCreateAsync(Provider provider, ProviderStatusType status)
@ -95,9 +89,9 @@ public class CreateProviderCommand : ICreateProviderCommand
await _providerRepository.CreateAsync(provider); await _providerRepository.CreateAsync(provider);
} }
private ProviderPlan CreateProviderPlan(Guid providerId, PlanType planType, int seatMinimum) private async Task CreateProviderPlanAsync(Guid providerId, PlanType planType, int seatMinimum)
{ {
return new ProviderPlan var plan = new ProviderPlan
{ {
ProviderId = providerId, ProviderId = providerId,
PlanType = planType, PlanType = planType,
@ -105,5 +99,6 @@ public class CreateProviderCommand : ICreateProviderCommand
PurchasedSeats = 0, PurchasedSeats = 0,
AllocatedSeats = 0 AllocatedSeats = 0
}; };
await _providerPlanRepository.CreateAsync(plan);
} }
} }

View File

@ -1,18 +1,19 @@
using Bit.Core; using Bit.Core;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Providers.Interfaces;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Constants; using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Services;
using Bit.Core.Billing.Services.Implementations.AutomaticTax;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Utilities; using Microsoft.Extensions.DependencyInjection;
using Stripe; using Stripe;
namespace Bit.Commercial.Core.AdminConsole.Providers; namespace Bit.Commercial.Core.AdminConsole.Providers;
@ -29,6 +30,8 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
private readonly IProviderBillingService _providerBillingService; private readonly IProviderBillingService _providerBillingService;
private readonly ISubscriberService _subscriberService; private readonly ISubscriberService _subscriberService;
private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery; private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery;
private readonly IPricingClient _pricingClient;
private readonly IAutomaticTaxStrategy _automaticTaxStrategy;
public RemoveOrganizationFromProviderCommand( public RemoveOrganizationFromProviderCommand(
IEventService eventService, IEventService eventService,
@ -40,7 +43,9 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
IFeatureService featureService, IFeatureService featureService,
IProviderBillingService providerBillingService, IProviderBillingService providerBillingService,
ISubscriberService subscriberService, ISubscriberService subscriberService,
IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery) IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery,
IPricingClient pricingClient,
[FromKeyedServices(AutomaticTaxFactory.BusinessUse)] IAutomaticTaxStrategy automaticTaxStrategy)
{ {
_eventService = eventService; _eventService = eventService;
_mailService = mailService; _mailService = mailService;
@ -52,6 +57,8 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
_providerBillingService = providerBillingService; _providerBillingService = providerBillingService;
_subscriberService = subscriberService; _subscriberService = subscriberService;
_hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery; _hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery;
_pricingClient = pricingClient;
_automaticTaxStrategy = automaticTaxStrategy;
} }
public async Task RemoveOrganizationFromProvider( public async Task RemoveOrganizationFromProvider(
@ -102,36 +109,45 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
Provider provider, Provider provider,
IEnumerable<string> organizationOwnerEmails) IEnumerable<string> organizationOwnerEmails)
{ {
var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling); if (provider.IsBillable() &&
organization.IsValidClient() &&
if (isConsolidatedBillingEnabled &&
provider.Status == ProviderStatusType.Billable &&
organization.Status == OrganizationStatusType.Managed &&
!string.IsNullOrEmpty(organization.GatewayCustomerId)) !string.IsNullOrEmpty(organization.GatewayCustomerId))
{ {
await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, new CustomerUpdateOptions var customer = await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, new CustomerUpdateOptions
{ {
Description = string.Empty, Description = string.Empty,
Email = organization.BillingEmail Email = organization.BillingEmail,
Expand = ["tax", "tax_ids"]
}); });
var plan = StaticStore.GetPlan(organization.PlanType).PasswordManager; var plan = await _pricingClient.GetPlanOrThrow(organization.PlanType);
var subscriptionCreateOptions = new SubscriptionCreateOptions var subscriptionCreateOptions = new SubscriptionCreateOptions
{ {
Customer = organization.GatewayCustomerId, Customer = organization.GatewayCustomerId,
CollectionMethod = StripeConstants.CollectionMethod.SendInvoice, CollectionMethod = StripeConstants.CollectionMethod.SendInvoice,
DaysUntilDue = 30, DaysUntilDue = 30,
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true },
Metadata = new Dictionary<string, string> Metadata = new Dictionary<string, string>
{ {
{ "organizationId", organization.Id.ToString() } { "organizationId", organization.Id.ToString() }
}, },
OffSession = true, OffSession = true,
ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations, ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations,
Items = [new SubscriptionItemOptions { Price = plan.StripeSeatPlanId, Quantity = organization.Seats }] Items = [new SubscriptionItemOptions { Price = plan.PasswordManager.StripeSeatPlanId, Quantity = organization.Seats }]
}; };
if (_featureService.IsEnabled(FeatureFlagKeys.PM19147_AutomaticTaxImprovements))
{
_automaticTaxStrategy.SetCreateOptions(subscriptionCreateOptions, customer);
}
else
{
subscriptionCreateOptions.AutomaticTax ??= new SubscriptionAutomaticTaxOptions
{
Enabled = true
};
}
var subscription = await _stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions); var subscription = await _stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
organization.GatewaySubscriptionId = subscription.Id; organization.GatewaySubscriptionId = subscription.Id;

View File

@ -8,7 +8,7 @@ using Bit.Core.AdminConsole.Models.Business.Tokenables;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Services;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Entities; using Bit.Core.Entities;
@ -28,7 +28,11 @@ namespace Bit.Commercial.Core.AdminConsole.Services;
public class ProviderService : IProviderService public class ProviderService : IProviderService
{ {
public static PlanType[] ProviderDisallowedOrganizationTypes = new[] { PlanType.Free, PlanType.FamiliesAnnually, PlanType.FamiliesAnnually2019 }; private static readonly PlanType[] _resellerDisallowedOrganizationTypes = [
PlanType.Free,
PlanType.FamiliesAnnually,
PlanType.FamiliesAnnually2019
];
private readonly IDataProtector _dataProtector; private readonly IDataProtector _dataProtector;
private readonly IMailService _mailService; private readonly IMailService _mailService;
@ -47,6 +51,7 @@ public class ProviderService : IProviderService
private readonly IDataProtectorTokenFactory<ProviderDeleteTokenable> _providerDeleteTokenDataFactory; private readonly IDataProtectorTokenFactory<ProviderDeleteTokenable> _providerDeleteTokenDataFactory;
private readonly IApplicationCacheService _applicationCacheService; private readonly IApplicationCacheService _applicationCacheService;
private readonly IProviderBillingService _providerBillingService; private readonly IProviderBillingService _providerBillingService;
private readonly IPricingClient _pricingClient;
public ProviderService(IProviderRepository providerRepository, IProviderUserRepository providerUserRepository, public ProviderService(IProviderRepository providerRepository, IProviderUserRepository providerUserRepository,
IProviderOrganizationRepository providerOrganizationRepository, IUserRepository userRepository, IProviderOrganizationRepository providerOrganizationRepository, IUserRepository userRepository,
@ -55,7 +60,7 @@ public class ProviderService : IProviderService
IOrganizationRepository organizationRepository, GlobalSettings globalSettings, IOrganizationRepository organizationRepository, GlobalSettings globalSettings,
ICurrentContext currentContext, IStripeAdapter stripeAdapter, IFeatureService featureService, ICurrentContext currentContext, IStripeAdapter stripeAdapter, IFeatureService featureService,
IDataProtectorTokenFactory<ProviderDeleteTokenable> providerDeleteTokenDataFactory, IDataProtectorTokenFactory<ProviderDeleteTokenable> providerDeleteTokenDataFactory,
IApplicationCacheService applicationCacheService, IProviderBillingService providerBillingService) IApplicationCacheService applicationCacheService, IProviderBillingService providerBillingService, IPricingClient pricingClient)
{ {
_providerRepository = providerRepository; _providerRepository = providerRepository;
_providerUserRepository = providerUserRepository; _providerUserRepository = providerUserRepository;
@ -74,6 +79,7 @@ public class ProviderService : IProviderService
_providerDeleteTokenDataFactory = providerDeleteTokenDataFactory; _providerDeleteTokenDataFactory = providerDeleteTokenDataFactory;
_applicationCacheService = applicationCacheService; _applicationCacheService = applicationCacheService;
_providerBillingService = providerBillingService; _providerBillingService = providerBillingService;
_pricingClient = pricingClient;
} }
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 = null)
@ -101,24 +107,16 @@ public class ProviderService : IProviderService
throw new BadRequestException("Invalid owner."); throw new BadRequestException("Invalid owner.");
} }
if (!_featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling)) if (taxInfo == null || string.IsNullOrEmpty(taxInfo.BillingAddressCountry) || string.IsNullOrEmpty(taxInfo.BillingAddressPostalCode))
{ {
provider.Status = ProviderStatusType.Created; throw new BadRequestException("Both address and postal code are required to set up your provider.");
await _providerRepository.UpsertAsync(provider);
}
else
{
if (taxInfo == null || string.IsNullOrEmpty(taxInfo.BillingAddressCountry) || string.IsNullOrEmpty(taxInfo.BillingAddressPostalCode))
{
throw new BadRequestException("Both address and postal code are required to set up your provider.");
}
var customer = await _providerBillingService.SetupCustomer(provider, taxInfo);
provider.GatewayCustomerId = customer.Id;
var subscription = await _providerBillingService.SetupSubscription(provider);
provider.GatewaySubscriptionId = subscription.Id;
provider.Status = ProviderStatusType.Billable;
await _providerRepository.UpsertAsync(provider);
} }
var customer = await _providerBillingService.SetupCustomer(provider, taxInfo);
provider.GatewayCustomerId = customer.Id;
var subscription = await _providerBillingService.SetupSubscription(provider);
provider.GatewaySubscriptionId = subscription.Id;
provider.Status = ProviderStatusType.Billable;
await _providerRepository.UpsertAsync(provider);
providerUser.Key = key; providerUser.Key = key;
await _providerUserRepository.ReplaceAsync(providerUser); await _providerUserRepository.ReplaceAsync(providerUser);
@ -392,7 +390,9 @@ public class ProviderService : IProviderService
var organization = await _organizationRepository.GetByIdAsync(organizationId); var organization = await _organizationRepository.GetByIdAsync(organizationId);
ThrowOnInvalidPlanType(organization.PlanType); var provider = await _providerRepository.GetByIdAsync(providerId);
ThrowOnInvalidPlanType(provider.Type, organization.PlanType);
if (organization.UseSecretsManager) if (organization.UseSecretsManager)
{ {
@ -407,8 +407,6 @@ public class ProviderService : IProviderService
Key = key, Key = key,
}; };
var provider = await _providerRepository.GetByIdAsync(providerId);
await ApplyProviderPriceRateAsync(organization, provider); await ApplyProviderPriceRateAsync(organization, provider);
await _providerOrganizationRepository.CreateAsync(providerOrganization); await _providerOrganizationRepository.CreateAsync(providerOrganization);
@ -457,30 +455,31 @@ public class ProviderService : IProviderService
if (!string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId)) if (!string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId))
{ {
var subscriptionItem = await GetSubscriptionItemAsync(organization.GatewaySubscriptionId, var plan = await _pricingClient.GetPlanOrThrow(organization.PlanType);
GetStripeSeatPlanId(organization.PlanType));
var subscriptionItem = await GetSubscriptionItemAsync(
organization.GatewaySubscriptionId,
plan.PasswordManager.StripeSeatPlanId);
var extractedPlanType = PlanTypeMappings(organization); var extractedPlanType = PlanTypeMappings(organization);
var extractedPlan = await _pricingClient.GetPlanOrThrow(extractedPlanType);
if (subscriptionItem != null) if (subscriptionItem != null)
{ {
await UpdateSubscriptionAsync(subscriptionItem, GetStripeSeatPlanId(extractedPlanType), organization); await UpdateSubscriptionAsync(subscriptionItem, extractedPlan.PasswordManager.StripeSeatPlanId, organization);
} }
} }
await _organizationRepository.UpsertAsync(organization); await _organizationRepository.UpsertAsync(organization);
} }
private async Task<Stripe.SubscriptionItem> GetSubscriptionItemAsync(string subscriptionId, string oldPlanId) private async Task<SubscriptionItem> GetSubscriptionItemAsync(string subscriptionId, string oldPlanId)
{ {
var subscriptionDetails = await _stripeAdapter.SubscriptionGetAsync(subscriptionId); var subscriptionDetails = await _stripeAdapter.SubscriptionGetAsync(subscriptionId);
return subscriptionDetails.Items.Data.FirstOrDefault(item => item.Price.Id == oldPlanId); return subscriptionDetails.Items.Data.FirstOrDefault(item => item.Price.Id == oldPlanId);
} }
private static string GetStripeSeatPlanId(PlanType planType) private async Task UpdateSubscriptionAsync(SubscriptionItem subscriptionItem, string extractedPlanType, Organization organization)
{
return StaticStore.GetPlan(planType).PasswordManager.StripeSeatPlanId;
}
private async Task UpdateSubscriptionAsync(Stripe.SubscriptionItem subscriptionItem, string extractedPlanType, Organization organization)
{ {
try try
{ {
@ -545,13 +544,9 @@ public class ProviderService : IProviderService
{ {
var provider = await _providerRepository.GetByIdAsync(providerId); var provider = await _providerRepository.GetByIdAsync(providerId);
var consolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling) && provider.IsBillable(); ThrowOnInvalidPlanType(provider.Type, organizationSignup.Plan);
ThrowOnInvalidPlanType(organizationSignup.Plan, consolidatedBillingEnabled); var (organization, _, defaultCollection) = await _organizationService.SignupClientAsync(organizationSignup);
var (organization, _, defaultCollection) = consolidatedBillingEnabled
? await _organizationService.SignupClientAsync(organizationSignup)
: await _organizationService.SignUpAsync(organizationSignup);
var providerOrganization = new ProviderOrganization var providerOrganization = new ProviderOrganization
{ {
@ -687,16 +682,30 @@ public class ProviderService : IProviderService
return confirmedOwnersIds.Except(providerUserIds).Any(); return confirmedOwnersIds.Except(providerUserIds).Any();
} }
private void ThrowOnInvalidPlanType(PlanType requestedType, bool consolidatedBillingEnabled = false) private void ThrowOnInvalidPlanType(ProviderType providerType, PlanType requestedType)
{ {
if (consolidatedBillingEnabled && requestedType is not (PlanType.TeamsMonthly or PlanType.EnterpriseMonthly)) switch (providerType)
{ {
throw new BadRequestException($"Providers cannot manage organizations with the plan type {requestedType}. Only Teams (Monthly) and Enterprise (Monthly) are allowed."); case ProviderType.Msp:
} if (requestedType is not (PlanType.TeamsMonthly or PlanType.EnterpriseMonthly))
{
if (ProviderDisallowedOrganizationTypes.Contains(requestedType)) throw new BadRequestException($"Managed Service Providers cannot manage organizations with the plan type {requestedType}. Only Teams (Monthly) and Enterprise (Monthly) are allowed.");
{ }
throw new BadRequestException($"Providers cannot manage organizations with the requested plan type ({requestedType}). Only Teams and Enterprise accounts are allowed."); break;
case ProviderType.MultiOrganizationEnterprise:
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.");
}
break;
case ProviderType.Reseller:
if (_resellerDisallowedOrganizationTypes.Contains(requestedType))
{
throw new BadRequestException($"Providers cannot manage organizations with the requested plan type ({requestedType}). Only Teams and Enterprise accounts are allowed.");
}
break;
default:
throw new BadRequestException($"Unsupported provider type {providerType}.");
} }
} }
} }

View File

@ -1,5 +1,6 @@
using System.Globalization; using System.Globalization;
using Bit.Commercial.Core.Billing.Models; using Bit.Commercial.Core.Billing.Models;
using Bit.Core;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Enums.Provider;
@ -8,10 +9,12 @@ using Bit.Core.Billing;
using Bit.Core.Billing.Constants; using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Entities; using Bit.Core.Billing.Entities;
using Bit.Core.Billing.Enums; 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.Repositories;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Services;
using Bit.Core.Context; using Bit.Core.Billing.Services.Contracts;
using Bit.Core.Billing.Services.Implementations.AutomaticTax;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Models.Business; using Bit.Core.Models.Business;
@ -20,52 +23,191 @@ using Bit.Core.Services;
using Bit.Core.Settings; using Bit.Core.Settings;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using CsvHelper; using CsvHelper;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Stripe; using Stripe;
namespace Bit.Commercial.Core.Billing; namespace Bit.Commercial.Core.Billing;
public class ProviderBillingService( public class ProviderBillingService(
ICurrentContext currentContext, IEventService eventService,
IFeatureService featureService,
IGlobalSettings globalSettings, IGlobalSettings globalSettings,
ILogger<ProviderBillingService> logger, ILogger<ProviderBillingService> logger,
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
IPaymentService paymentService, IPricingClient pricingClient,
IProviderInvoiceItemRepository providerInvoiceItemRepository, IProviderInvoiceItemRepository providerInvoiceItemRepository,
IProviderOrganizationRepository providerOrganizationRepository, IProviderOrganizationRepository providerOrganizationRepository,
IProviderPlanRepository providerPlanRepository, IProviderPlanRepository providerPlanRepository,
IProviderRepository providerRepository, IProviderUserRepository providerUserRepository,
IStripeAdapter stripeAdapter, IStripeAdapter stripeAdapter,
ISubscriberService subscriberService) : IProviderBillingService ISubscriberService subscriberService,
ITaxService taxService,
[FromKeyedServices(AutomaticTaxFactory.BusinessUse)] IAutomaticTaxStrategy automaticTaxStrategy)
: IProviderBillingService
{ {
public async Task AssignSeatsToClientOrganization( [RequireFeature(FeatureFlagKeys.P15179_AddExistingOrgsFromProviderPortal)]
public async Task AddExistingOrganization(
Provider provider, Provider provider,
Organization organization, Organization organization,
int seats) string key)
{ {
ArgumentNullException.ThrowIfNull(organization); await stripeAdapter.SubscriptionUpdateAsync(organization.GatewaySubscriptionId,
new SubscriptionUpdateOptions
{
CancelAtPeriodEnd = false
});
if (seats < 0) var subscription =
await stripeAdapter.SubscriptionCancelAsync(organization.GatewaySubscriptionId,
new SubscriptionCancelOptions
{
CancellationDetails = new SubscriptionCancellationDetailsOptions
{
Comment = $"Organization was added to Provider with ID {provider.Id}"
},
InvoiceNow = true,
Prorate = true,
Expand = ["latest_invoice", "test_clock"]
});
var now = subscription.TestClock?.FrozenTime ?? DateTime.UtcNow;
var wasTrialing = subscription.TrialEnd.HasValue && subscription.TrialEnd.Value > now;
if (!wasTrialing && subscription.LatestInvoice.Status == StripeConstants.InvoiceStatus.Draft)
{ {
throw new BillingException( await stripeAdapter.InvoiceFinalizeInvoiceAsync(subscription.LatestInvoiceId,
"You cannot assign negative seats to a client.", new InvoiceFinalizeOptions { AutoAdvance = true });
"MSP cannot assign negative seats to a client organization");
} }
if (seats == organization.Seats) var managedPlanType = await GetManagedPlanTypeAsync(provider, organization);
{
logger.LogWarning("Client organization ({ID}) already has {Seats} seats assigned to it", organization.Id, organization.Seats);
var plan = await pricingClient.GetPlanOrThrow(managedPlanType);
organization.Plan = plan.Name;
organization.PlanType = plan.Type;
organization.MaxCollections = plan.PasswordManager.MaxCollections;
organization.MaxStorageGb = plan.PasswordManager.BaseStorageGb;
organization.UsePolicies = plan.HasPolicies;
organization.UseSso = plan.HasSso;
organization.UseGroups = plan.HasGroups;
organization.UseEvents = plan.HasEvents;
organization.UseDirectory = plan.HasDirectory;
organization.UseTotp = plan.HasTotp;
organization.Use2fa = plan.Has2fa;
organization.UseApi = plan.HasApi;
organization.UseResetPassword = plan.HasResetPassword;
organization.SelfHost = plan.HasSelfHost;
organization.UsersGetPremium = plan.UsersGetPremium;
organization.UseCustomPermissions = plan.HasCustomPermissions;
organization.UseScim = plan.HasScim;
organization.UseKeyConnector = plan.HasKeyConnector;
organization.MaxStorageGb = plan.PasswordManager.BaseStorageGb;
organization.BillingEmail = provider.BillingEmail!;
organization.GatewaySubscriptionId = null;
organization.ExpirationDate = null;
organization.MaxAutoscaleSeats = null;
organization.Status = OrganizationStatusType.Managed;
var providerOrganization = new ProviderOrganization
{
ProviderId = provider.Id,
OrganizationId = organization.Id,
Key = key
};
/*
* 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.
*/
await ScaleSeats(provider, organization.PlanType, organization.Seats!.Value);
await Task.WhenAll(
organizationRepository.ReplaceAsync(organization),
providerOrganizationRepository.CreateAsync(providerOrganization)
);
var clientCustomer = await subscriberService.GetCustomer(organization);
if (clientCustomer.Balance != 0)
{
await stripeAdapter.CustomerBalanceTransactionCreate(provider.GatewayCustomerId,
new CustomerBalanceTransactionCreateOptions
{
Amount = clientCustomer.Balance,
Currency = "USD",
Description = $"Unused, prorated time for client organization with ID {organization.Id}."
});
}
await eventService.LogProviderOrganizationEventAsync(
providerOrganization,
EventType.ProviderOrganization_Added);
}
public async Task ChangePlan(ChangeProviderPlanCommand command)
{
var (provider, providerPlanId, newPlanType) = command;
var providerPlan = await providerPlanRepository.GetByIdAsync(providerPlanId);
if (providerPlan == null)
{
throw new BadRequestException("Provider plan not found.");
}
if (providerPlan.PlanType == newPlanType)
{
return; return;
} }
var seatAdjustment = seats - (organization.Seats ?? 0); var subscription = await subscriberService.GetSubscriptionOrThrow(provider);
await ScaleSeats(provider, organization.PlanType, seatAdjustment); var oldPriceId = ProviderPriceAdapter.GetPriceId(provider, subscription, providerPlan.PlanType);
var newPriceId = ProviderPriceAdapter.GetPriceId(provider, subscription, newPlanType);
organization.Seats = seats; providerPlan.PlanType = newPlanType;
await providerPlanRepository.ReplaceAsync(providerPlan);
await organizationRepository.ReplaceAsync(organization); var oldSubscriptionItem = subscription.Items.SingleOrDefault(x => x.Price.Id == oldPriceId);
var updateOptions = new SubscriptionUpdateOptions
{
Items =
[
new SubscriptionItemOptions
{
Price = newPriceId,
Quantity = oldSubscriptionItem!.Quantity
},
new SubscriptionItemOptions
{
Id = oldSubscriptionItem.Id,
Deleted = true
}
]
};
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(providerPlan.ProviderId);
var newPlan = await pricingClient.GetPlanOrThrow(newPlanType);
foreach (var providerOrganization in providerOrganizations)
{
var organization = await organizationRepository.GetByIdAsync(providerOrganization.OrganizationId);
if (organization == null)
{
throw new ConflictException($"Organization '{providerOrganization.Id}' not found.");
}
organization.PlanType = newPlanType;
organization.Plan = newPlan.Name;
await organizationRepository.ReplaceAsync(organization);
}
} }
public async Task CreateCustomerForClientOrganization( public async Task CreateCustomerForClientOrganization(
@ -170,35 +312,78 @@ public class ProviderBillingService(
return memoryStream.ToArray(); return memoryStream.ToArray();
} }
public async Task<int> GetAssignedSeatTotalForPlanOrThrow( [RequireFeature(FeatureFlagKeys.P15179_AddExistingOrgsFromProviderPortal)]
Guid providerId, public async Task<IEnumerable<AddableOrganization>> GetAddableOrganizations(
PlanType planType) Provider provider,
Guid userId)
{ {
var provider = await providerRepository.GetByIdAsync(providerId); var providerUser = await providerUserRepository.GetByProviderUserAsync(provider.Id, userId);
if (provider == null) if (providerUser is not { Status: ProviderUserStatusType.Confirmed })
{ {
logger.LogError( throw new UnauthorizedAccessException();
"Could not find provider ({ID}) when retrieving assigned seat total",
providerId);
throw new BillingException();
} }
if (provider.Type == ProviderType.Reseller) var candidates = await organizationRepository.GetAddableToProviderByUserIdAsync(userId, provider.Type);
{
logger.LogError("Assigned seats cannot be retrieved for reseller-type provider ({ID})", providerId);
throw new BillingException(); var active = (await Task.WhenAll(candidates.Select(async organization =>
{
var subscription = await subscriberService.GetSubscription(organization);
return (organization, subscription);
})))
.Where(pair => pair.subscription is
{
Status:
StripeConstants.SubscriptionStatus.Active or
StripeConstants.SubscriptionStatus.Trialing or
StripeConstants.SubscriptionStatus.PastDue
}).ToList();
if (active.Count == 0)
{
return [];
} }
var providerOrganizations = await providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId); return await Task.WhenAll(active.Select(async pair =>
{
var (organization, _) = pair;
var plan = StaticStore.GetPlan(planType); var planName = await DerivePlanName(provider, organization);
return providerOrganizations var addable = new AddableOrganization(
.Where(providerOrganization => providerOrganization.Plan == plan.Name && providerOrganization.Status == OrganizationStatusType.Managed) organization.Id,
.Sum(providerOrganization => providerOrganization.Seats ?? 0); organization.Name,
planName,
organization.Seats!.Value);
if (providerUser.Type != ProviderUserType.ServiceUser)
{
return addable;
}
var applicablePlanType = await GetManagedPlanTypeAsync(provider, organization);
var requiresPurchase =
await SeatAdjustmentResultsInPurchase(provider, applicablePlanType, organization.Seats!.Value);
return addable with { Disabled = requiresPurchase };
}));
async Task<string> DerivePlanName(Provider localProvider, Organization localOrganization)
{
if (localProvider.Type == ProviderType.Msp)
{
return localOrganization.PlanType switch
{
var planType when PlanConstants.EnterprisePlanTypes.Contains(planType) => "Enterprise",
var planType when PlanConstants.TeamsPlanTypes.Contains(planType) => "Teams",
_ => throw new BillingException()
};
}
var plan = await pricingClient.GetPlanOrThrow(localOrganization.PlanType);
return plan.Name;
}
} }
public async Task ScaleSeats( public async Task ScaleSeats(
@ -206,40 +391,15 @@ public class ProviderBillingService(
PlanType planType, PlanType planType,
int seatAdjustment) int seatAdjustment)
{ {
ArgumentNullException.ThrowIfNull(provider); var providerPlan = await GetProviderPlanAsync(provider, planType);
if (provider.Type != ProviderType.Msp) var seatMinimum = providerPlan.SeatMinimum ?? 0;
{
logger.LogError("Non-MSP provider ({ProviderID}) cannot scale their seats", provider.Id);
throw new BillingException(); var currentlyAssignedSeatTotal = await GetAssignedSeatTotalAsync(provider, planType);
}
if (!planType.SupportsConsolidatedBilling())
{
logger.LogError("Cannot scale provider ({ProviderID}) seats for plan type {PlanType} as it does not support consolidated billing", provider.Id, planType.ToString());
throw new BillingException();
}
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
var providerPlan = providerPlans.FirstOrDefault(providerPlan => providerPlan.PlanType == planType);
if (providerPlan == null || !providerPlan.IsConfigured())
{
logger.LogError("Cannot scale provider ({ProviderID}) seats for plan type {PlanType} when their matching provider plan is not configured", provider.Id, planType);
throw new BillingException();
}
var seatMinimum = providerPlan.SeatMinimum.GetValueOrDefault(0);
var currentlyAssignedSeatTotal = await GetAssignedSeatTotalForPlanOrThrow(provider.Id, planType);
var newlyAssignedSeatTotal = currentlyAssignedSeatTotal + seatAdjustment; var newlyAssignedSeatTotal = currentlyAssignedSeatTotal + seatAdjustment;
var update = CurrySeatScalingUpdate( var scaleQuantityTo = CurrySeatScalingUpdate(
provider, provider,
providerPlan, providerPlan,
newlyAssignedSeatTotal); newlyAssignedSeatTotal);
@ -262,16 +422,7 @@ public class ProviderBillingService(
else if (currentlyAssignedSeatTotal <= seatMinimum && else if (currentlyAssignedSeatTotal <= seatMinimum &&
newlyAssignedSeatTotal > seatMinimum) newlyAssignedSeatTotal > seatMinimum)
{ {
if (!currentContext.ProviderProviderAdmin(provider.Id)) await scaleQuantityTo(newlyAssignedSeatTotal);
{
logger.LogError("Service user for provider ({ProviderID}) cannot scale a provider's seat count over the seat minimum", provider.Id);
throw new BillingException();
}
await update(
seatMinimum,
newlyAssignedSeatTotal);
} }
/* /*
* Above the limit => Above the limit: * Above the limit => Above the limit:
@ -280,9 +431,7 @@ public class ProviderBillingService(
else if (currentlyAssignedSeatTotal > seatMinimum && else if (currentlyAssignedSeatTotal > seatMinimum &&
newlyAssignedSeatTotal > seatMinimum) newlyAssignedSeatTotal > seatMinimum)
{ {
await update( await scaleQuantityTo(newlyAssignedSeatTotal);
currentlyAssignedSeatTotal,
newlyAssignedSeatTotal);
} }
/* /*
* Above the limit => Below the limit: * Above the limit => Below the limit:
@ -291,30 +440,45 @@ public class ProviderBillingService(
else if (currentlyAssignedSeatTotal > seatMinimum && else if (currentlyAssignedSeatTotal > seatMinimum &&
newlyAssignedSeatTotal <= seatMinimum) newlyAssignedSeatTotal <= seatMinimum)
{ {
await update( await scaleQuantityTo(seatMinimum);
currentlyAssignedSeatTotal,
seatMinimum);
} }
} }
public async Task<bool> SeatAdjustmentResultsInPurchase(
Provider provider,
PlanType planType,
int seatAdjustment)
{
var providerPlan = await GetProviderPlanAsync(provider, planType);
var seatMinimum = providerPlan.SeatMinimum;
var currentlyAssignedSeatTotal = await GetAssignedSeatTotalAsync(provider, planType);
var newlyAssignedSeatTotal = currentlyAssignedSeatTotal + seatAdjustment;
return
// Below the limit to above the limit
(currentlyAssignedSeatTotal <= seatMinimum && newlyAssignedSeatTotal > seatMinimum) ||
// Above the limit to further above the limit
(currentlyAssignedSeatTotal > seatMinimum && newlyAssignedSeatTotal > seatMinimum && newlyAssignedSeatTotal > currentlyAssignedSeatTotal);
}
public async Task<Customer> SetupCustomer( public async Task<Customer> SetupCustomer(
Provider provider, Provider provider,
TaxInfo taxInfo) TaxInfo taxInfo)
{ {
ArgumentNullException.ThrowIfNull(provider); if (taxInfo is not
ArgumentNullException.ThrowIfNull(taxInfo); {
BillingAddressCountry: not null and not "",
if (string.IsNullOrEmpty(taxInfo.BillingAddressCountry) || BillingAddressPostalCode: not null and not ""
string.IsNullOrEmpty(taxInfo.BillingAddressPostalCode)) })
{ {
logger.LogError("Cannot create customer for provider ({ProviderID}) without both a country and postal code", provider.Id); logger.LogError("Cannot create customer for provider ({ProviderID}) without both a country and postal code", provider.Id);
throw new BillingException(); throw new BillingException();
} }
var providerDisplayName = provider.DisplayName(); var options = new CustomerCreateOptions
var customerCreateOptions = new CustomerCreateOptions
{ {
Address = new AddressOptions Address = new AddressOptions
{ {
@ -334,26 +498,46 @@ public class ProviderBillingService(
new CustomerInvoiceSettingsCustomFieldOptions new CustomerInvoiceSettingsCustomFieldOptions
{ {
Name = provider.SubscriberType(), Name = provider.SubscriberType(),
Value = providerDisplayName?.Length <= 30 Value = provider.DisplayName()?.Length <= 30
? providerDisplayName ? provider.DisplayName()
: providerDisplayName?[..30] : provider.DisplayName()?[..30]
} }
] ]
}, },
Metadata = new Dictionary<string, string> Metadata = new Dictionary<string, string>
{ {
{ "region", globalSettings.BaseServiceUri.CloudRegion } { "region", globalSettings.BaseServiceUri.CloudRegion }
}, }
TaxIdData = taxInfo.HasTaxId ?
[
new CustomerTaxIdDataOptions { Type = taxInfo.TaxIdType, Value = taxInfo.TaxIdNumber }
]
: null
}; };
if (!string.IsNullOrEmpty(taxInfo.TaxIdNumber))
{
var taxIdType = taxService.GetStripeTaxCode(
taxInfo.BillingAddressCountry,
taxInfo.TaxIdNumber);
if (taxIdType == null)
{
logger.LogWarning("Could not infer tax ID type in country '{Country}' with tax ID '{TaxID}'.",
taxInfo.BillingAddressCountry,
taxInfo.TaxIdNumber);
throw new BadRequestException("billingTaxIdTypeInferenceError");
}
options.TaxIdData =
[
new CustomerTaxIdDataOptions { Type = taxIdType, Value = taxInfo.TaxIdNumber }
];
}
if (!string.IsNullOrEmpty(provider.DiscountId))
{
options.Coupon = provider.DiscountId;
}
try try
{ {
return await stripeAdapter.CustomerCreateAsync(customerCreateOptions); 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)
{ {
@ -366,7 +550,8 @@ public class ProviderBillingService(
{ {
ArgumentNullException.ThrowIfNull(provider); 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); var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
@ -379,48 +564,27 @@ public class ProviderBillingService(
var subscriptionItemOptionsList = new List<SubscriptionItemOptions>(); var subscriptionItemOptionsList = new List<SubscriptionItemOptions>();
var teamsProviderPlan = foreach (var providerPlan in providerPlans)
providerPlans.SingleOrDefault(providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly);
if (teamsProviderPlan == null || !teamsProviderPlan.IsConfigured())
{ {
logger.LogError("Cannot start subscription for provider ({ProviderID}) that has no configured Teams plan", provider.Id); var plan = await pricingClient.GetPlanOrThrow(providerPlan.PlanType);
throw new BillingException(); if (!providerPlan.IsConfigured())
{
logger.LogError("Cannot start subscription for provider ({ProviderID}) that has no configured {ProviderName} plan", provider.Id, plan.Name);
throw new BillingException();
}
var priceId = ProviderPriceAdapter.GetActivePriceId(provider, providerPlan.PlanType);
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{
Price = priceId,
Quantity = providerPlan.SeatMinimum
});
} }
var teamsPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{
Price = teamsPlan.PasswordManager.StripeProviderPortalSeatPlanId,
Quantity = teamsProviderPlan.SeatMinimum
});
var enterpriseProviderPlan =
providerPlans.SingleOrDefault(providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly);
if (enterpriseProviderPlan == null || !enterpriseProviderPlan.IsConfigured())
{
logger.LogError("Cannot start subscription for provider ({ProviderID}) that has no configured Enterprise plan", provider.Id);
throw new BillingException();
}
var enterprisePlan = StaticStore.GetPlan(PlanType.EnterpriseMonthly);
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{
Price = enterprisePlan.PasswordManager.StripeProviderPortalSeatPlanId,
Quantity = enterpriseProviderPlan.SeatMinimum
});
var subscriptionCreateOptions = new SubscriptionCreateOptions var subscriptionCreateOptions = new SubscriptionCreateOptions
{ {
AutomaticTax = new SubscriptionAutomaticTaxOptions
{
Enabled = true
},
CollectionMethod = StripeConstants.CollectionMethod.SendInvoice, CollectionMethod = StripeConstants.CollectionMethod.SendInvoice,
Customer = customer.Id, Customer = customer.Id,
DaysUntilDue = 30, DaysUntilDue = 30,
@ -433,6 +597,15 @@ public class ProviderBillingService(
ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations
}; };
if (featureService.IsEnabled(FeatureFlagKeys.PM19147_AutomaticTaxImprovements))
{
automaticTaxStrategy.SetCreateOptions(subscriptionCreateOptions, customer);
}
else
{
subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true };
}
try try
{ {
var subscription = await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions); var subscription = await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
@ -456,139 +629,94 @@ public class ProviderBillingService(
} }
} }
public async Task UpdateSeatMinimums( public async Task UpdatePaymentMethod(
Provider provider, Provider provider,
int enterpriseSeatMinimum, TokenizedPaymentSource tokenizedPaymentSource,
int teamsSeatMinimum) TaxInformation taxInformation)
{ {
ArgumentNullException.ThrowIfNull(provider); await Task.WhenAll(
subscriberService.UpdatePaymentSource(provider, tokenizedPaymentSource),
subscriberService.UpdateTaxInformation(provider, taxInformation));
if (enterpriseSeatMinimum < 0 || teamsSeatMinimum < 0) await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId,
new SubscriptionUpdateOptions { CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically });
}
public async Task UpdateSeatMinimums(UpdateProviderSeatMinimumsCommand command)
{
var (provider, updatedPlanConfigurations) = command;
if (updatedPlanConfigurations.Any(x => x.SeatsMinimum < 0))
{ {
throw new BadRequestException("Provider seat minimums must be at least 0."); throw new BadRequestException("Provider seat minimums must be at least 0.");
} }
var subscription = await stripeAdapter.SubscriptionGetAsync(provider.GatewaySubscriptionId); var subscription = await subscriberService.GetSubscriptionOrThrow(provider);
var subscriptionItemOptionsList = new List<SubscriptionItemOptions>(); var subscriptionItemOptionsList = new List<SubscriptionItemOptions>();
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id); var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
var enterpriseProviderPlan = foreach (var updatedPlanConfiguration in updatedPlanConfigurations)
providerPlans.Single(providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly);
if (enterpriseProviderPlan.SeatMinimum != enterpriseSeatMinimum)
{ {
var enterprisePriceId = StaticStore.GetPlan(PlanType.EnterpriseMonthly).PasswordManager var (updatedPlanType, updatedSeatMinimum) = updatedPlanConfiguration;
.StripeProviderPortalSeatPlanId;
var enterpriseSubscriptionItem = subscription.Items.First(item => item.Price.Id == enterprisePriceId); var providerPlan =
providerPlans.Single(providerPlan => providerPlan.PlanType == updatedPlanType);
if (enterpriseProviderPlan.PurchasedSeats == 0) if (providerPlan.SeatMinimum != updatedSeatMinimum)
{ {
if (enterpriseProviderPlan.AllocatedSeats > enterpriseSeatMinimum) var priceId = ProviderPriceAdapter.GetPriceId(provider, subscription, updatedPlanType);
{
enterpriseProviderPlan.PurchasedSeats =
enterpriseProviderPlan.AllocatedSeats - enterpriseSeatMinimum;
subscriptionItemOptionsList.Add(new SubscriptionItemOptions var subscriptionItem = subscription.Items.First(item => item.Price.Id == priceId);
if (providerPlan.PurchasedSeats == 0)
{
if (providerPlan.AllocatedSeats > updatedSeatMinimum)
{ {
Id = enterpriseSubscriptionItem.Id, providerPlan.PurchasedSeats = providerPlan.AllocatedSeats - updatedSeatMinimum;
Price = enterprisePriceId,
Quantity = enterpriseProviderPlan.AllocatedSeats subscriptionItemOptionsList.Add(new SubscriptionItemOptions
}); {
Id = subscriptionItem.Id,
Price = priceId,
Quantity = providerPlan.AllocatedSeats
});
}
else
{
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{
Id = subscriptionItem.Id,
Price = priceId,
Quantity = updatedSeatMinimum
});
}
} }
else else
{ {
subscriptionItemOptionsList.Add(new SubscriptionItemOptions var totalSeats = providerPlan.SeatMinimum + providerPlan.PurchasedSeats;
if (updatedSeatMinimum <= totalSeats)
{ {
Id = enterpriseSubscriptionItem.Id, providerPlan.PurchasedSeats = totalSeats - updatedSeatMinimum;
Price = enterprisePriceId, }
Quantity = enterpriseSeatMinimum else
}); {
providerPlan.PurchasedSeats = 0;
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{
Id = subscriptionItem.Id,
Price = priceId,
Quantity = updatedSeatMinimum
});
}
} }
providerPlan.SeatMinimum = updatedSeatMinimum;
await providerPlanRepository.ReplaceAsync(providerPlan);
} }
else
{
var totalEnterpriseSeats = enterpriseProviderPlan.SeatMinimum + enterpriseProviderPlan.PurchasedSeats;
if (enterpriseSeatMinimum <= totalEnterpriseSeats)
{
enterpriseProviderPlan.PurchasedSeats = totalEnterpriseSeats - enterpriseSeatMinimum;
}
else
{
enterpriseProviderPlan.PurchasedSeats = 0;
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{
Id = enterpriseSubscriptionItem.Id,
Price = enterprisePriceId,
Quantity = enterpriseSeatMinimum
});
}
}
enterpriseProviderPlan.SeatMinimum = enterpriseSeatMinimum;
await providerPlanRepository.ReplaceAsync(enterpriseProviderPlan);
}
var teamsProviderPlan =
providerPlans.Single(providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly);
if (teamsProviderPlan.SeatMinimum != teamsSeatMinimum)
{
var teamsPriceId = StaticStore.GetPlan(PlanType.TeamsMonthly).PasswordManager
.StripeProviderPortalSeatPlanId;
var teamsSubscriptionItem = subscription.Items.First(item => item.Price.Id == teamsPriceId);
if (teamsProviderPlan.PurchasedSeats == 0)
{
if (teamsProviderPlan.AllocatedSeats > teamsSeatMinimum)
{
teamsProviderPlan.PurchasedSeats = teamsProviderPlan.AllocatedSeats - teamsSeatMinimum;
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{
Id = teamsSubscriptionItem.Id,
Price = teamsPriceId,
Quantity = teamsProviderPlan.AllocatedSeats
});
}
else
{
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{
Id = teamsSubscriptionItem.Id,
Price = teamsPriceId,
Quantity = teamsSeatMinimum
});
}
}
else
{
var totalTeamsSeats = teamsProviderPlan.SeatMinimum + teamsProviderPlan.PurchasedSeats;
if (teamsSeatMinimum <= totalTeamsSeats)
{
teamsProviderPlan.PurchasedSeats = totalTeamsSeats - teamsSeatMinimum;
}
else
{
teamsProviderPlan.PurchasedSeats = 0;
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{
Id = teamsSubscriptionItem.Id,
Price = teamsPriceId,
Quantity = teamsSeatMinimum
});
}
}
teamsProviderPlan.SeatMinimum = teamsSeatMinimum;
await providerPlanRepository.ReplaceAsync(teamsProviderPlan);
} }
if (subscriptionItemOptionsList.Count > 0) if (subscriptionItemOptionsList.Count > 0)
@ -598,18 +726,28 @@ public class ProviderBillingService(
} }
} }
private Func<int, int, Task> CurrySeatScalingUpdate( private Func<int, Task> CurrySeatScalingUpdate(
Provider provider, Provider provider,
ProviderPlan providerPlan, ProviderPlan providerPlan,
int newlyAssignedSeats) => async (currentlySubscribedSeats, newlySubscribedSeats) => int newlyAssignedSeats) => async newlySubscribedSeats =>
{ {
var plan = StaticStore.GetPlan(providerPlan.PlanType); var subscription = await subscriberService.GetSubscriptionOrThrow(provider);
await paymentService.AdjustSeats( var priceId = ProviderPriceAdapter.GetPriceId(provider, subscription, providerPlan.PlanType);
provider,
plan, var item = subscription.Items.First(item => item.Price.Id == priceId);
currentlySubscribedSeats,
newlySubscribedSeats); await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId, new SubscriptionUpdateOptions
{
Items = [
new SubscriptionItemOptions
{
Id = item.Id,
Price = priceId,
Quantity = newlySubscribedSeats
}
]
});
var newlyPurchasedSeats = newlySubscribedSeats > providerPlan.SeatMinimum var newlyPurchasedSeats = newlySubscribedSeats > providerPlan.SeatMinimum
? newlySubscribedSeats - providerPlan.SeatMinimum ? newlySubscribedSeats - providerPlan.SeatMinimum
@ -620,4 +758,49 @@ public class ProviderBillingService(
await providerPlanRepository.ReplaceAsync(providerPlan); await providerPlanRepository.ReplaceAsync(providerPlan);
}; };
// TODO: Replace with SPROC
private async Task<int> GetAssignedSeatTotalAsync(Provider provider, PlanType planType)
{
var providerOrganizations =
await providerOrganizationRepository.GetManyDetailsByProviderAsync(provider.Id);
var plan = await pricingClient.GetPlanOrThrow(planType);
return providerOrganizations
.Where(providerOrganization => providerOrganization.Plan == plan.Name && providerOrganization.Status == OrganizationStatusType.Managed)
.Sum(providerOrganization => providerOrganization.Seats ?? 0);
}
// TODO: Replace with SPROC
private async Task<ProviderPlan> GetProviderPlanAsync(Provider provider, PlanType planType)
{
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
var providerPlan = providerPlans.FirstOrDefault(x => x.PlanType == planType);
if (providerPlan == null || !providerPlan.IsConfigured())
{
throw new BillingException(message: "Provider plan is missing or misconfigured");
}
return providerPlan;
}
private async Task<PlanType> GetManagedPlanTypeAsync(
Provider provider,
Organization organization)
{
if (provider.Type == ProviderType.MultiOrganizationEnterprise)
{
return (await providerPlanRepository.GetByProviderId(provider.Id)).First().PlanType;
}
return organization.PlanType switch
{
var planType when PlanConstants.TeamsPlanTypes.Contains(planType) => PlanType.TeamsMonthly,
var planType when PlanConstants.EnterprisePlanTypes.Contains(planType) => PlanType.EnterpriseMonthly,
_ => throw new BillingException()
};
}
} }

View File

@ -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;
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.MultiOrganizationEnterprise"/>.</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.MultiOrganizationEnterprise => 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.MultiOrganizationEnterprise"/>.</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.MultiOrganizationEnterprise => 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")
};
}
}

View File

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

View File

@ -28,6 +28,7 @@ public class MaxProjectsQuery : IMaxProjectsQuery
throw new NotFoundException(); throw new NotFoundException();
} }
// TODO: PRICING -> https://bitwarden.atlassian.net/browse/PM-17122
var plan = StaticStore.GetPlan(org.PlanType); var plan = StaticStore.GetPlan(org.PlanType);
if (plan?.SecretsManager == null) if (plan?.SecretsManager == null)
{ {

View File

@ -24,7 +24,6 @@ public class GroupsController : Controller
private readonly IPatchGroupCommand _patchGroupCommand; private readonly IPatchGroupCommand _patchGroupCommand;
private readonly IPostGroupCommand _postGroupCommand; private readonly IPostGroupCommand _postGroupCommand;
private readonly IPutGroupCommand _putGroupCommand; private readonly IPutGroupCommand _putGroupCommand;
private readonly ILogger<GroupsController> _logger;
public GroupsController( public GroupsController(
IGroupRepository groupRepository, IGroupRepository groupRepository,
@ -33,8 +32,8 @@ public class GroupsController : Controller
IDeleteGroupCommand deleteGroupCommand, IDeleteGroupCommand deleteGroupCommand,
IPatchGroupCommand patchGroupCommand, IPatchGroupCommand patchGroupCommand,
IPostGroupCommand postGroupCommand, IPostGroupCommand postGroupCommand,
IPutGroupCommand putGroupCommand, IPutGroupCommand putGroupCommand
ILogger<GroupsController> logger) )
{ {
_groupRepository = groupRepository; _groupRepository = groupRepository;
_organizationRepository = organizationRepository; _organizationRepository = organizationRepository;
@ -43,7 +42,6 @@ public class GroupsController : Controller
_patchGroupCommand = patchGroupCommand; _patchGroupCommand = patchGroupCommand;
_postGroupCommand = postGroupCommand; _postGroupCommand = postGroupCommand;
_putGroupCommand = putGroupCommand; _putGroupCommand = putGroupCommand;
_logger = logger;
} }
[HttpGet("{id}")] [HttpGet("{id}")]
@ -97,8 +95,13 @@ public class GroupsController : Controller
[HttpPatch("{id}")] [HttpPatch("{id}")]
public async Task<IActionResult> Patch(Guid organizationId, Guid id, [FromBody] ScimPatchModel model) public async Task<IActionResult> Patch(Guid organizationId, Guid id, [FromBody] ScimPatchModel model)
{ {
var organization = await _organizationRepository.GetByIdAsync(organizationId); var group = await _groupRepository.GetByIdAsync(id);
await _patchGroupCommand.PatchGroupAsync(organization, id, model); if (group == null || group.OrganizationId != organizationId)
{
throw new NotFoundException("Group not found.");
}
await _patchGroupCommand.PatchGroupAsync(group, model);
return new NoContentResult(); return new NoContentResult();
} }

View File

@ -1,4 +1,5 @@
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
@ -23,7 +24,7 @@ public class UsersController : Controller
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
private readonly IPatchUserCommand _patchUserCommand; private readonly IPatchUserCommand _patchUserCommand;
private readonly IPostUserCommand _postUserCommand; private readonly IPostUserCommand _postUserCommand;
private readonly ILogger<UsersController> _logger; private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand;
public UsersController( public UsersController(
IOrganizationUserRepository organizationUserRepository, IOrganizationUserRepository organizationUserRepository,
@ -32,7 +33,7 @@ public class UsersController : Controller
IRemoveOrganizationUserCommand removeOrganizationUserCommand, IRemoveOrganizationUserCommand removeOrganizationUserCommand,
IPatchUserCommand patchUserCommand, IPatchUserCommand patchUserCommand,
IPostUserCommand postUserCommand, IPostUserCommand postUserCommand,
ILogger<UsersController> logger) IRestoreOrganizationUserCommand restoreOrganizationUserCommand)
{ {
_organizationUserRepository = organizationUserRepository; _organizationUserRepository = organizationUserRepository;
_organizationService = organizationService; _organizationService = organizationService;
@ -40,7 +41,7 @@ public class UsersController : Controller
_removeOrganizationUserCommand = removeOrganizationUserCommand; _removeOrganizationUserCommand = removeOrganizationUserCommand;
_patchUserCommand = patchUserCommand; _patchUserCommand = patchUserCommand;
_postUserCommand = postUserCommand; _postUserCommand = postUserCommand;
_logger = logger; _restoreOrganizationUserCommand = restoreOrganizationUserCommand;
} }
[HttpGet("{id}")] [HttpGet("{id}")]
@ -93,7 +94,7 @@ public class UsersController : Controller
if (model.Active && orgUser.Status == OrganizationUserStatusType.Revoked) 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) else if (!model.Active && orgUser.Status != OrganizationUserStatusType.Revoked)
{ {

View File

@ -5,5 +5,5 @@ namespace Bit.Scim.Groups.Interfaces;
public interface IPatchGroupCommand public interface IPatchGroupCommand
{ {
Task PatchGroupAsync(Organization organization, Guid id, ScimPatchModel model); Task PatchGroupAsync(Group group, ScimPatchModel model);
} }

View File

@ -5,8 +5,10 @@ using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Scim.Groups.Interfaces; using Bit.Scim.Groups.Interfaces;
using Bit.Scim.Models; using Bit.Scim.Models;
using Bit.Scim.Utilities;
namespace Bit.Scim.Groups; namespace Bit.Scim.Groups;
@ -16,118 +18,137 @@ public class PatchGroupCommand : IPatchGroupCommand
private readonly IGroupService _groupService; private readonly IGroupService _groupService;
private readonly IUpdateGroupCommand _updateGroupCommand; private readonly IUpdateGroupCommand _updateGroupCommand;
private readonly ILogger<PatchGroupCommand> _logger; private readonly ILogger<PatchGroupCommand> _logger;
private readonly IOrganizationRepository _organizationRepository;
public PatchGroupCommand( public PatchGroupCommand(
IGroupRepository groupRepository, IGroupRepository groupRepository,
IGroupService groupService, IGroupService groupService,
IUpdateGroupCommand updateGroupCommand, IUpdateGroupCommand updateGroupCommand,
ILogger<PatchGroupCommand> logger) ILogger<PatchGroupCommand> logger,
IOrganizationRepository organizationRepository)
{ {
_groupRepository = groupRepository; _groupRepository = groupRepository;
_groupService = groupService; _groupService = groupService;
_updateGroupCommand = updateGroupCommand; _updateGroupCommand = updateGroupCommand;
_logger = logger; _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) foreach (var operation in model.Operations)
{ {
// Replace operations await HandleOperationAsync(group, operation);
if (operation.Op?.ToLowerInvariant() == "replace") }
{ }
// Replace a list of members
if (operation.Path?.ToLowerInvariant() == "members") 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); var ids = GetOperationValueIds(operation.Value);
await _groupRepository.UpdateUsersAsync(group.Id, ids); 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(); 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); await _updateGroupCommand.UpdateGroupAsync(group, organization, EventSystemUser.SCIM);
operationHandled = true; break;
} }
// Replace group name from value object
else if (string.IsNullOrWhiteSpace(operation.Path) && // Replace group name from value object
operation.Value.TryGetProperty("displayName", out var displayNameProperty)) case PatchOps.Replace when
string.IsNullOrWhiteSpace(operation.Path) &&
operation.Value.TryGetProperty("displayName", out var displayNameProperty):
{ {
group.Name = displayNameProperty.GetString(); group.Name = displayNameProperty.GetString();
var organization = await _organizationRepository.GetByIdAsync(group.OrganizationId);
if (organization == null)
{
throw new NotFoundException();
}
await _updateGroupCommand.UpdateGroupAsync(group, organization, EventSystemUser.SCIM); await _updateGroupCommand.UpdateGroupAsync(group, organization, EventSystemUser.SCIM);
operationHandled = true; break;
} }
}
// Add a single member // Add a single member
else if (operation.Op?.ToLowerInvariant() == "add" && case PatchOps.Add when
!string.IsNullOrWhiteSpace(operation.Path) && !string.IsNullOrWhiteSpace(operation.Path) &&
operation.Path.ToLowerInvariant().StartsWith("members[value eq ")) operation.Path.StartsWith("members[value eq ", StringComparison.OrdinalIgnoreCase) &&
{ TryGetOperationPathId(operation.Path, out var addId):
var addId = GetOperationPathId(operation.Path); {
if (addId.HasValue) 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(); 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); 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) default:
{ {
_logger.LogWarning("Group patch operation not handled: {0} : ", _logger.LogWarning("Group patch operation not handled: {OperationOp}:{OperationPath}", operation.Op, operation.Path);
string.Join(", ", model.Operations.Select(o => $"{o.Op}:{o.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()) foreach (var obj in objArray.EnumerateArray())
{ {
if (obj.TryGetProperty("value", out var valueProperty)) if (obj.TryGetProperty("value", out var valueProperty))
@ -141,13 +162,9 @@ public class PatchGroupCommand : IPatchGroupCommand
return ids; 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}"}] // Parse Guid from string like: members[value eq "{GUID}"}]
if (Guid.TryParse(path.Substring(18).Replace("\"]", string.Empty), out var id)) return Guid.TryParse(path.Substring(18).Replace("\"]", string.Empty), out pathId);
{
return id;
}
return null;
} }
} }

View File

@ -1,11 +1,8 @@
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Scim.Context;
using Bit.Scim.Groups.Interfaces; using Bit.Scim.Groups.Interfaces;
using Bit.Scim.Models; using Bit.Scim.Models;
@ -14,17 +11,13 @@ namespace Bit.Scim.Groups;
public class PostGroupCommand : IPostGroupCommand public class PostGroupCommand : IPostGroupCommand
{ {
private readonly IGroupRepository _groupRepository; private readonly IGroupRepository _groupRepository;
private readonly IScimContext _scimContext;
private readonly ICreateGroupCommand _createGroupCommand; private readonly ICreateGroupCommand _createGroupCommand;
public PostGroupCommand( public PostGroupCommand(
IGroupRepository groupRepository, IGroupRepository groupRepository,
IOrganizationRepository organizationRepository,
IScimContext scimContext,
ICreateGroupCommand createGroupCommand) ICreateGroupCommand createGroupCommand)
{ {
_groupRepository = groupRepository; _groupRepository = groupRepository;
_scimContext = scimContext;
_createGroupCommand = createGroupCommand; _createGroupCommand = createGroupCommand;
} }
@ -50,11 +43,6 @@ public class PostGroupCommand : IPostGroupCommand
private async Task UpdateGroupMembersAsync(Group group, ScimGroupRequestModel model) private async Task UpdateGroupMembersAsync(Group group, ScimGroupRequestModel model)
{ {
if (_scimContext.RequestScimProvider != ScimProviderType.Okta)
{
return;
}
if (model.Members == null) if (model.Members == null)
{ {
return; return;

View File

@ -1,10 +1,8 @@
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Scim.Context;
using Bit.Scim.Groups.Interfaces; using Bit.Scim.Groups.Interfaces;
using Bit.Scim.Models; using Bit.Scim.Models;
@ -13,16 +11,13 @@ namespace Bit.Scim.Groups;
public class PutGroupCommand : IPutGroupCommand public class PutGroupCommand : IPutGroupCommand
{ {
private readonly IGroupRepository _groupRepository; private readonly IGroupRepository _groupRepository;
private readonly IScimContext _scimContext;
private readonly IUpdateGroupCommand _updateGroupCommand; private readonly IUpdateGroupCommand _updateGroupCommand;
public PutGroupCommand( public PutGroupCommand(
IGroupRepository groupRepository, IGroupRepository groupRepository,
IScimContext scimContext,
IUpdateGroupCommand updateGroupCommand) IUpdateGroupCommand updateGroupCommand)
{ {
_groupRepository = groupRepository; _groupRepository = groupRepository;
_scimContext = scimContext;
_updateGroupCommand = updateGroupCommand; _updateGroupCommand = updateGroupCommand;
} }
@ -43,12 +38,6 @@ public class PutGroupCommand : IPutGroupCommand
private async Task UpdateGroupMembersAsync(Group group, ScimGroupRequestModel model) private async Task UpdateGroupMembersAsync(Group group, ScimGroupRequestModel model)
{ {
if (_scimContext.RequestScimProvider != ScimProviderType.Okta &&
_scimContext.RequestScimProvider != ScimProviderType.Ping)
{
return;
}
if (model.Members == null) if (model.Members == null)
{ {
return; return;

View File

@ -8,7 +8,7 @@ using Bit.Core.Utilities;
using Bit.Scim.Context; using Bit.Scim.Context;
using Bit.Scim.Utilities; using Bit.Scim.Utilities;
using Bit.SharedWeb.Utilities; using Bit.SharedWeb.Utilities;
using IdentityModel; using Duende.IdentityModel;
using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.DependencyInjection.Extensions;
using Stripe; using Stripe;

View File

@ -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.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
@ -11,15 +12,18 @@ public class PatchUserCommand : IPatchUserCommand
{ {
private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IOrganizationService _organizationService; private readonly IOrganizationService _organizationService;
private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand;
private readonly ILogger<PatchUserCommand> _logger; private readonly ILogger<PatchUserCommand> _logger;
public PatchUserCommand( public PatchUserCommand(
IOrganizationUserRepository organizationUserRepository, IOrganizationUserRepository organizationUserRepository,
IOrganizationService organizationService, IOrganizationService organizationService,
IRestoreOrganizationUserCommand restoreOrganizationUserCommand,
ILogger<PatchUserCommand> logger) ILogger<PatchUserCommand> logger)
{ {
_organizationUserRepository = organizationUserRepository; _organizationUserRepository = organizationUserRepository;
_organizationService = organizationService; _organizationService = organizationService;
_restoreOrganizationUserCommand = restoreOrganizationUserCommand;
_logger = logger; _logger = logger;
} }
@ -71,7 +75,7 @@ public class PatchUserCommand : IPatchUserCommand
{ {
if (active && orgUser.Status == OrganizationUserStatusType.Revoked) if (active && orgUser.Status == OrganizationUserStatusType.Revoked)
{ {
await _organizationService.RestoreUserAsync(orgUser, EventSystemUser.SCIM); await _restoreOrganizationUserCommand.RestoreUserAsync(orgUser, EventSystemUser.SCIM);
return true; return true;
} }
else if (!active && orgUser.Status != OrganizationUserStatusType.Revoked) else if (!active && orgUser.Status != OrganizationUserStatusType.Revoked)

View File

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

View File

@ -7,3 +7,16 @@ public static class ScimConstants
public const string Scim2SchemaUser = "urn:ietf:params:scim:schemas:core:2.0:User"; public const string Scim2SchemaUser = "urn:ietf:params:scim:schemas:core:2.0:User";
public const string Scim2SchemaGroup = "urn:ietf:params:scim:schemas:core:2.0:Group"; public const string Scim2SchemaGroup = "urn:ietf:params:scim:schemas:core:2.0:Group";
} }
public static class PatchOps
{
public const string Replace = "replace";
public const string Add = "add";
public const string Remove = "remove";
}
public static class PatchPaths
{
public const string Members = "members";
public const string DisplayName = "displayname";
}

View File

@ -19,10 +19,10 @@ using Bit.Core.Tokens;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Bit.Sso.Models; using Bit.Sso.Models;
using Bit.Sso.Utilities; using Bit.Sso.Utilities;
using Duende.IdentityModel;
using Duende.IdentityServer; using Duende.IdentityServer;
using Duende.IdentityServer.Services; using Duende.IdentityServer.Services;
using Duende.IdentityServer.Stores; using Duende.IdentityServer.Stores;
using IdentityModel;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;

View File

@ -7,9 +7,9 @@ using Bit.Core.Settings;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Bit.Sso.Models; using Bit.Sso.Models;
using Bit.Sso.Utilities; using Bit.Sso.Utilities;
using Duende.IdentityModel;
using Duende.IdentityServer; using Duende.IdentityServer;
using Duende.IdentityServer.Infrastructure; using Duende.IdentityServer.Infrastructure;
using IdentityModel;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;

View File

@ -23,7 +23,7 @@
@RenderBody() @RenderBody()
</div> </div>
<div class="container footer text-muted"> <div class="container footer text-body-secondary">
<div class="row"> <div class="row">
<div class="col"> <div class="col">
&copy; @DateTime.Now.Year, Bitwarden Inc. &copy; @DateTime.Now.Year, Bitwarden Inc.

File diff suppressed because it is too large Load Diff

View File

@ -8,18 +8,17 @@
"build": "webpack" "build": "webpack"
}, },
"dependencies": { "dependencies": {
"bootstrap": "4.6.2", "bootstrap": "5.3.3",
"font-awesome": "4.7.0", "font-awesome": "4.7.0",
"jquery": "3.7.1", "jquery": "3.7.1"
"popper.js": "1.16.1"
}, },
"devDependencies": { "devDependencies": {
"css-loader": "7.1.2", "css-loader": "7.1.2",
"expose-loader": "5.0.0", "expose-loader": "5.0.0",
"mini-css-extract-plugin": "2.9.1", "mini-css-extract-plugin": "2.9.2",
"sass": "1.79.5", "sass": "1.85.0",
"sass-loader": "16.0.2", "sass-loader": "16.0.4",
"webpack": "5.95.0", "webpack": "5.97.1",
"webpack-cli": "5.1.4" "webpack-cli": "5.1.4"
} }
} }

View File

@ -13,8 +13,6 @@ module.exports = {
entry: { entry: {
site: [ site: [
path.resolve(__dirname, paths.sassDir, "site.scss"), path.resolve(__dirname, paths.sassDir, "site.scss"),
"popper.js",
"bootstrap", "bootstrap",
"jquery", "jquery",
"font-awesome/css/font-awesome.css", "font-awesome/css/font-awesome.css",

View File

@ -3,6 +3,7 @@ using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Enums;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
@ -19,23 +20,30 @@ public class CreateProviderCommandTests
[Theory, BitAutoData] [Theory, BitAutoData]
public async Task CreateMspAsync_UserIdIsInvalid_Throws(Provider provider, SutProvider<CreateProviderCommand> sutProvider) public async Task CreateMspAsync_UserIdIsInvalid_Throws(Provider provider, SutProvider<CreateProviderCommand> sutProvider)
{ {
// Arrange
provider.Type = ProviderType.Msp; provider.Type = ProviderType.Msp;
// Act
var exception = await Assert.ThrowsAsync<BadRequestException>( var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.CreateMspAsync(provider, default, default, default)); () => sutProvider.Sut.CreateMspAsync(provider, default, default, default));
// Assert
Assert.Contains("Invalid owner.", exception.Message); Assert.Contains("Invalid owner.", exception.Message);
} }
[Theory, BitAutoData] [Theory, BitAutoData]
public async Task CreateMspAsync_Success(Provider provider, User user, SutProvider<CreateProviderCommand> sutProvider) public async Task CreateMspAsync_Success(Provider provider, User user, SutProvider<CreateProviderCommand> sutProvider)
{ {
// Arrange
provider.Type = ProviderType.Msp; provider.Type = ProviderType.Msp;
var userRepository = sutProvider.GetDependency<IUserRepository>(); var userRepository = sutProvider.GetDependency<IUserRepository>();
userRepository.GetByEmailAsync(user.Email).Returns(user); userRepository.GetByEmailAsync(user.Email).Returns(user);
// Act
await sutProvider.Sut.CreateMspAsync(provider, user.Email, default, default); await sutProvider.Sut.CreateMspAsync(provider, user.Email, default, default);
// Assert
await sutProvider.GetDependency<IProviderRepository>().ReceivedWithAnyArgs().CreateAsync(default); await sutProvider.GetDependency<IProviderRepository>().ReceivedWithAnyArgs().CreateAsync(default);
await sutProvider.GetDependency<IProviderService>().Received(1).SendProviderSetupInviteEmailAsync(provider, user.Email); await sutProvider.GetDependency<IProviderService>().Received(1).SendProviderSetupInviteEmailAsync(provider, user.Email);
} }
@ -43,11 +51,52 @@ public class CreateProviderCommandTests
[Theory, BitAutoData] [Theory, BitAutoData]
public async Task CreateResellerAsync_Success(Provider provider, SutProvider<CreateProviderCommand> sutProvider) public async Task CreateResellerAsync_Success(Provider provider, SutProvider<CreateProviderCommand> sutProvider)
{ {
// Arrange
provider.Type = ProviderType.Reseller; provider.Type = ProviderType.Reseller;
// Act
await sutProvider.Sut.CreateResellerAsync(provider); await sutProvider.Sut.CreateResellerAsync(provider);
// Assert
await sutProvider.GetDependency<IProviderRepository>().ReceivedWithAnyArgs().CreateAsync(default); await sutProvider.GetDependency<IProviderRepository>().ReceivedWithAnyArgs().CreateAsync(default);
await sutProvider.GetDependency<IProviderService>().DidNotReceiveWithAnyArgs().SendProviderSetupInviteEmailAsync(default, default); await sutProvider.GetDependency<IProviderService>().DidNotReceiveWithAnyArgs().SendProviderSetupInviteEmailAsync(default, default);
} }
[Theory, BitAutoData]
public async Task CreateMultiOrganizationEnterpriseAsync_Success(
Provider provider,
User user,
PlanType plan,
int minimumSeats,
SutProvider<CreateProviderCommand> sutProvider)
{
// Arrange
provider.Type = ProviderType.MultiOrganizationEnterprise;
var userRepository = sutProvider.GetDependency<IUserRepository>();
userRepository.GetByEmailAsync(user.Email).Returns(user);
// Act
await sutProvider.Sut.CreateMultiOrganizationEnterpriseAsync(provider, user.Email, plan, minimumSeats);
// Assert
await sutProvider.GetDependency<IProviderRepository>().ReceivedWithAnyArgs().CreateAsync(provider);
await sutProvider.GetDependency<IProviderService>().Received(1).SendProviderSetupInviteEmailAsync(provider, user.Email);
}
[Theory, BitAutoData]
public async Task CreateMultiOrganizationEnterpriseAsync_UserIdIsInvalid_Throws(
Provider provider,
SutProvider<CreateProviderCommand> sutProvider)
{
// Arrange
provider.Type = ProviderType.Msp;
// Act
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.CreateMultiOrganizationEnterpriseAsync(provider, default, default, default));
// Assert
Assert.Contains("Invalid owner.", exception.Message);
}
} }

View File

@ -1,5 +1,4 @@
using Bit.Commercial.Core.AdminConsole.Providers; using Bit.Commercial.Core.AdminConsole.Providers;
using Bit.Core;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Enums.Provider;
@ -7,6 +6,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Constants; using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Services;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
@ -155,9 +155,6 @@ public class RemoveOrganizationFromProviderCommandTests
"b@example.com" "b@example.com"
]); ]);
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling)
.Returns(false);
sutProvider.GetDependency<IStripeAdapter>().SubscriptionGetAsync(organization.GatewaySubscriptionId) sutProvider.GetDependency<IStripeAdapter>().SubscriptionGetAsync(organization.GatewaySubscriptionId)
.Returns(GetSubscription(organization.GatewaySubscriptionId)); .Returns(GetSubscription(organization.GatewaySubscriptionId));
@ -209,6 +206,8 @@ public class RemoveOrganizationFromProviderCommandTests
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(PlanType.TeamsMonthly).Returns(teamsMonthlyPlan);
sutProvider.GetDependency<IHasConfirmedOwnersExceptQuery>().HasConfirmedOwnersExceptAsync( sutProvider.GetDependency<IHasConfirmedOwnersExceptQuery>().HasConfirmedOwnersExceptAsync(
providerOrganization.OrganizationId, providerOrganization.OrganizationId,
[], [],
@ -222,9 +221,6 @@ public class RemoveOrganizationFromProviderCommandTests
"b@example.com" "b@example.com"
]); ]);
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling)
.Returns(true);
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>(); var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
stripeAdapter.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(new Subscription stripeAdapter.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(new Subscription
@ -232,6 +228,26 @@ public class RemoveOrganizationFromProviderCommandTests
Id = "subscription_id" Id = "subscription_id"
}); });
sutProvider.GetDependency<IAutomaticTaxStrategy>()
.When(x => x.SetCreateOptions(
Arg.Is<SubscriptionCreateOptions>(options =>
options.Customer == organization.GatewayCustomerId &&
options.CollectionMethod == StripeConstants.CollectionMethod.SendInvoice &&
options.DaysUntilDue == 30 &&
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)
, Arg.Any<Customer>()))
.Do(x =>
{
x.Arg<SubscriptionCreateOptions>().AutomaticTax = new SubscriptionAutomaticTaxOptions
{
Enabled = true
};
});
await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization); await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization);
await stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(options => await stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(options =>

View File

@ -1,6 +1,5 @@
using Bit.Commercial.Core.AdminConsole.Services; using Bit.Commercial.Core.AdminConsole.Services;
using Bit.Commercial.Core.Test.AdminConsole.AutoFixture; using Bit.Commercial.Core.Test.AdminConsole.AutoFixture;
using Bit.Core;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Enums.Provider;
@ -8,6 +7,7 @@ using Bit.Core.AdminConsole.Models.Business.Provider;
using Bit.Core.AdminConsole.Models.Business.Tokenables; using Bit.Core.AdminConsole.Models.Business.Tokenables;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Services;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Entities; using Bit.Core.Entities;
@ -55,36 +55,8 @@ public class ProviderServiceTests
} }
[Theory, BitAutoData] [Theory, BitAutoData]
public async Task CompleteSetupAsync_Success(User user, Provider provider, string key, public async Task CompleteSetupAsync_Success(User user, Provider provider, string key, TaxInfo taxInfo,
[ProviderUser(ProviderUserStatusType.Confirmed, ProviderUserType.ProviderAdmin)] ProviderUser providerUser, [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)}");
await sutProvider.Sut.CompleteSetupAsync(provider, user.Id, token, key);
await sutProvider.GetDependency<IProviderRepository>().Received().UpsertAsync(provider);
await sutProvider.GetDependency<IProviderUserRepository>().Received()
.ReplaceAsync(Arg.Is<ProviderUser>(pu => pu.UserId == user.Id && pu.ProviderId == provider.Id && pu.Key == key));
}
[Theory, BitAutoData]
public async Task CompleteSetupAsync_ConsolidatedBilling_Success(User user, Provider provider, string key, TaxInfo taxInfo,
[ProviderUser(ProviderUserStatusType.Confirmed, ProviderUserType.ProviderAdmin)] ProviderUser providerUser,
SutProvider<ProviderService> sutProvider) SutProvider<ProviderService> sutProvider)
{ {
providerUser.ProviderId = provider.Id; providerUser.ProviderId = provider.Id;
@ -100,9 +72,6 @@ public class ProviderServiceTests
sutProvider.GetDependency<IDataProtectionProvider>().CreateProtector("ProviderServiceDataProtector") sutProvider.GetDependency<IDataProtectionProvider>().CreateProtector("ProviderServiceDataProtector")
.Returns(protector); .Returns(protector);
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling)
.Returns(true);
var providerBillingService = sutProvider.GetDependency<IProviderBillingService>(); var providerBillingService = sutProvider.GetDependency<IProviderBillingService>();
var customer = new Customer { Id = "customer_id" }; var customer = new Customer { Id = "customer_id" };
@ -489,7 +458,7 @@ public class ProviderServiceTests
public async Task AddOrganization_OrganizationHasSecretsManager_Throws(Provider provider, Organization organization, string key, public async Task AddOrganization_OrganizationHasSecretsManager_Throws(Provider provider, Organization organization, string key,
SutProvider<ProviderService> sutProvider) SutProvider<ProviderService> sutProvider)
{ {
organization.PlanType = PlanType.EnterpriseAnnually; organization.PlanType = PlanType.EnterpriseMonthly;
organization.UseSecretsManager = true; organization.UseSecretsManager = true;
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider); sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
@ -506,7 +475,7 @@ public class ProviderServiceTests
public async Task AddOrganization_Success(Provider provider, Organization organization, string key, public async Task AddOrganization_Success(Provider provider, Organization organization, string key,
SutProvider<ProviderService> sutProvider) SutProvider<ProviderService> sutProvider)
{ {
organization.PlanType = PlanType.EnterpriseAnnually; organization.PlanType = PlanType.EnterpriseMonthly;
var providerRepository = sutProvider.GetDependency<IProviderRepository>(); var providerRepository = sutProvider.GetDependency<IProviderRepository>();
providerRepository.GetByIdAsync(provider.Id).Returns(provider); providerRepository.GetByIdAsync(provider.Id).Returns(provider);
@ -549,8 +518,8 @@ public class ProviderServiceTests
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider); sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>(); var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
var expectedPlanType = PlanType.EnterpriseAnnually; var expectedPlanType = PlanType.EnterpriseMonthly;
organization.PlanType = PlanType.EnterpriseAnnually; organization.PlanType = PlanType.EnterpriseMonthly;
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization); sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
await sutProvider.Sut.AddOrganization(provider.Id, organization.Id, key); await sutProvider.Sut.AddOrganization(provider.Id, organization.Id, key);
@ -579,12 +548,18 @@ public class ProviderServiceTests
BackdateProviderCreationDate(provider, newCreationDate); BackdateProviderCreationDate(provider, newCreationDate);
provider.Type = ProviderType.Msp; provider.Type = ProviderType.Msp;
organization.PlanType = PlanType.EnterpriseAnnually; organization.PlanType = PlanType.EnterpriseMonthly;
organization.Plan = "Enterprise (Annually)"; organization.Plan = "Enterprise (Monthly)";
var expectedPlanType = PlanType.EnterpriseAnnually2020; sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(organization.PlanType)
.Returns(StaticStore.GetPlan(organization.PlanType));
var expectedPlanId = "2020-enterprise-org-seat-annually"; var expectedPlanType = PlanType.EnterpriseMonthly2020;
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(expectedPlanType)
.Returns(StaticStore.GetPlan(expectedPlanType));
var expectedPlanId = "2020-enterprise-org-seat-monthly";
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider); sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>(); var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
@ -663,11 +638,11 @@ public class ProviderServiceTests
public async Task CreateOrganizationAsync_Success(Provider provider, OrganizationSignup organizationSignup, public async Task CreateOrganizationAsync_Success(Provider provider, OrganizationSignup organizationSignup,
Organization organization, string clientOwnerEmail, User user, SutProvider<ProviderService> sutProvider) Organization organization, string clientOwnerEmail, User user, SutProvider<ProviderService> sutProvider)
{ {
organizationSignup.Plan = PlanType.EnterpriseAnnually; organizationSignup.Plan = PlanType.EnterpriseMonthly;
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider); sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>(); var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
sutProvider.GetDependency<IOrganizationService>().SignUpAsync(organizationSignup) sutProvider.GetDependency<IOrganizationService>().SignupClientAsync(organizationSignup)
.Returns((organization, null as OrganizationUser, new Collection())); .Returns((organization, null as OrganizationUser, new Collection()));
var providerOrganization = var providerOrganization =
@ -688,7 +663,7 @@ public class ProviderServiceTests
} }
[Theory, OrganizationCustomize, BitAutoData] [Theory, OrganizationCustomize, BitAutoData]
public async Task CreateOrganizationAsync_ConsolidatedBillingEnabled_InvalidPlanType_ThrowsBadRequestException( public async Task CreateOrganizationAsync_InvalidPlanType_ThrowsBadRequestException(
Provider provider, Provider provider,
OrganizationSignup organizationSignup, OrganizationSignup organizationSignup,
Organization organization, Organization organization,
@ -696,8 +671,6 @@ public class ProviderServiceTests
User user, User user,
SutProvider<ProviderService> sutProvider) SutProvider<ProviderService> sutProvider)
{ {
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling).Returns(true);
provider.Type = ProviderType.Msp; provider.Type = ProviderType.Msp;
provider.Status = ProviderStatusType.Billable; provider.Status = ProviderStatusType.Billable;
@ -717,7 +690,7 @@ public class ProviderServiceTests
} }
[Theory, OrganizationCustomize, BitAutoData] [Theory, OrganizationCustomize, BitAutoData]
public async Task CreateOrganizationAsync_ConsolidatedBillingEnabled_InvokeSignupClientAsync( public async Task CreateOrganizationAsync_InvokeSignupClientAsync(
Provider provider, Provider provider,
OrganizationSignup organizationSignup, OrganizationSignup organizationSignup,
Organization organization, Organization organization,
@ -725,8 +698,6 @@ public class ProviderServiceTests
User user, User user,
SutProvider<ProviderService> sutProvider) SutProvider<ProviderService> sutProvider)
{ {
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling).Returns(true);
provider.Type = ProviderType.Msp; provider.Type = ProviderType.Msp;
provider.Status = ProviderStatusType.Billable; provider.Status = ProviderStatusType.Billable;
@ -771,11 +742,11 @@ public class ProviderServiceTests
(Provider provider, OrganizationSignup organizationSignup, Organization organization, string clientOwnerEmail, (Provider provider, OrganizationSignup organizationSignup, Organization organization, string clientOwnerEmail,
User user, SutProvider<ProviderService> sutProvider, Collection defaultCollection) User user, SutProvider<ProviderService> sutProvider, Collection defaultCollection)
{ {
organizationSignup.Plan = PlanType.EnterpriseAnnually; organizationSignup.Plan = PlanType.EnterpriseMonthly;
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider); sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>(); var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
sutProvider.GetDependency<IOrganizationService>().SignUpAsync(organizationSignup) sutProvider.GetDependency<IOrganizationService>().SignupClientAsync(organizationSignup)
.Returns((organization, null as OrganizationUser, defaultCollection)); .Returns((organization, null as OrganizationUser, defaultCollection));
var providerOrganization = var providerOrganization =

View File

@ -0,0 +1,151 @@
using Bit.Commercial.Core.Billing;
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;
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.MultiOrganizationEnterprise
};
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.MultiOrganizationEnterprise
};
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.MultiOrganizationEnterprise
};
var result = ProviderPriceAdapter.GetActivePriceId(provider, planType);
Assert.Equal(result, priceId);
}
}

View File

@ -0,0 +1,151 @@
using Bit.Core.Billing.Services;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Xunit;
namespace Bit.Commercial.Core.Test.Billing;
[SutProviderCustomize]
public class TaxServiceTests
{
[Theory]
[BitAutoData("AD", "A-123456-Z", "ad_nrt")]
[BitAutoData("AD", "A123456Z", "ad_nrt")]
[BitAutoData("AR", "20-12345678-9", "ar_cuit")]
[BitAutoData("AR", "20123456789", "ar_cuit")]
[BitAutoData("AU", "01259983598", "au_abn")]
[BitAutoData("AU", "123456789123", "au_arn")]
[BitAutoData("AT", "ATU12345678", "eu_vat")]
[BitAutoData("BH", "123456789012345", "bh_vat")]
[BitAutoData("BY", "123456789", "by_tin")]
[BitAutoData("BE", "BE0123456789", "eu_vat")]
[BitAutoData("BO", "123456789", "bo_tin")]
[BitAutoData("BR", "01.234.456/5432-10", "br_cnpj")]
[BitAutoData("BR", "01234456543210", "br_cnpj")]
[BitAutoData("BR", "123.456.789-87", "br_cpf")]
[BitAutoData("BR", "12345678987", "br_cpf")]
[BitAutoData("BG", "123456789", "bg_uic")]
[BitAutoData("BG", "BG012100705", "eu_vat")]
[BitAutoData("CA", "100728494", "ca_bn")]
[BitAutoData("CA", "123456789RT0001", "ca_gst_hst")]
[BitAutoData("CA", "PST-1234-1234", "ca_pst_bc")]
[BitAutoData("CA", "123456-7", "ca_pst_mb")]
[BitAutoData("CA", "1234567", "ca_pst_sk")]
[BitAutoData("CA", "1234567890TQ1234", "ca_qst")]
[BitAutoData("CL", "11.121.326-1", "cl_tin")]
[BitAutoData("CL", "11121326-1", "cl_tin")]
[BitAutoData("CL", "23.121.326-K", "cl_tin")]
[BitAutoData("CL", "43651326-K", "cl_tin")]
[BitAutoData("CN", "123456789012345678", "cn_tin")]
[BitAutoData("CN", "123456789012345", "cn_tin")]
[BitAutoData("CO", "123.456.789-0", "co_nit")]
[BitAutoData("CO", "1234567890", "co_nit")]
[BitAutoData("CR", "1-234-567890", "cr_tin")]
[BitAutoData("CR", "1234567890", "cr_tin")]
[BitAutoData("HR", "HR12345678912", "eu_vat")]
[BitAutoData("HR", "12345678901", "hr_oib")]
[BitAutoData("CY", "CY12345678X", "eu_vat")]
[BitAutoData("CZ", "CZ12345678", "eu_vat")]
[BitAutoData("DK", "DK12345678", "eu_vat")]
[BitAutoData("DO", "123-4567890-1", "do_rcn")]
[BitAutoData("DO", "12345678901", "do_rcn")]
[BitAutoData("EC", "1234567890001", "ec_ruc")]
[BitAutoData("EG", "123456789", "eg_tin")]
[BitAutoData("SV", "1234-567890-123-4", "sv_nit")]
[BitAutoData("SV", "12345678901234", "sv_nit")]
[BitAutoData("EE", "EE123456789", "eu_vat")]
[BitAutoData("EU", "EU123456789", "eu_oss_vat")]
[BitAutoData("FI", "FI12345678", "eu_vat")]
[BitAutoData("FR", "FR12345678901", "eu_vat")]
[BitAutoData("GE", "123456789", "ge_vat")]
[BitAutoData("DE", "1234567890", "de_stn")]
[BitAutoData("DE", "DE123456789", "eu_vat")]
[BitAutoData("GR", "EL123456789", "eu_vat")]
[BitAutoData("HK", "12345678", "hk_br")]
[BitAutoData("HU", "HU12345678", "eu_vat")]
[BitAutoData("HU", "12345678-1-23", "hu_tin")]
[BitAutoData("HU", "12345678123", "hu_tin")]
[BitAutoData("IS", "123456", "is_vat")]
[BitAutoData("IN", "12ABCDE1234F1Z5", "in_gst")]
[BitAutoData("IN", "12ABCDE3456FGZH", "in_gst")]
[BitAutoData("ID", "012.345.678.9-012.345", "id_npwp")]
[BitAutoData("ID", "0123456789012345", "id_npwp")]
[BitAutoData("IE", "IE1234567A", "eu_vat")]
[BitAutoData("IE", "IE1234567AB", "eu_vat")]
[BitAutoData("IL", "000012345", "il_vat")]
[BitAutoData("IL", "123456789", "il_vat")]
[BitAutoData("IT", "IT12345678901", "eu_vat")]
[BitAutoData("JP", "1234567890123", "jp_cn")]
[BitAutoData("JP", "12345", "jp_rn")]
[BitAutoData("KZ", "123456789012", "kz_bin")]
[BitAutoData("KE", "P000111111A", "ke_pin")]
[BitAutoData("LV", "LV12345678912", "eu_vat")]
[BitAutoData("LI", "CHE123456789", "li_uid")]
[BitAutoData("LI", "12345", "li_vat")]
[BitAutoData("LT", "LT123456789123", "eu_vat")]
[BitAutoData("LU", "LU12345678", "eu_vat")]
[BitAutoData("MY", "12345678", "my_frp")]
[BitAutoData("MY", "C 1234567890", "my_itn")]
[BitAutoData("MY", "C1234567890", "my_itn")]
[BitAutoData("MY", "A12-3456-78912345", "my_sst")]
[BitAutoData("MY", "A12345678912345", "my_sst")]
[BitAutoData("MT", "MT12345678", "eu_vat")]
[BitAutoData("MX", "ABC010203AB9", "mx_rfc")]
[BitAutoData("MD", "1003600", "md_vat")]
[BitAutoData("MA", "12345678", "ma_vat")]
[BitAutoData("NL", "NL123456789B12", "eu_vat")]
[BitAutoData("NZ", "123456789", "nz_gst")]
[BitAutoData("NG", "12345678-0001", "ng_tin")]
[BitAutoData("NO", "123456789MVA", "no_vat")]
[BitAutoData("NO", "1234567", "no_voec")]
[BitAutoData("OM", "OM1234567890", "om_vat")]
[BitAutoData("PE", "12345678901", "pe_ruc")]
[BitAutoData("PH", "123456789012", "ph_tin")]
[BitAutoData("PL", "PL1234567890", "eu_vat")]
[BitAutoData("PT", "PT123456789", "eu_vat")]
[BitAutoData("RO", "RO1234567891", "eu_vat")]
[BitAutoData("RO", "1234567890123", "ro_tin")]
[BitAutoData("RU", "1234567891", "ru_inn")]
[BitAutoData("RU", "123456789", "ru_kpp")]
[BitAutoData("SA", "123456789012345", "sa_vat")]
[BitAutoData("RS", "123456789", "rs_pib")]
[BitAutoData("SG", "M12345678X", "sg_gst")]
[BitAutoData("SG", "123456789F", "sg_uen")]
[BitAutoData("SK", "SK1234567891", "eu_vat")]
[BitAutoData("SI", "SI12345678", "eu_vat")]
[BitAutoData("SI", "12345678", "si_tin")]
[BitAutoData("ZA", "4123456789", "za_vat")]
[BitAutoData("KR", "123-45-67890", "kr_brn")]
[BitAutoData("KR", "1234567890", "kr_brn")]
[BitAutoData("ES", "A12345678", "es_cif")]
[BitAutoData("ES", "ESX1234567X", "eu_vat")]
[BitAutoData("SE", "SE123456789012", "eu_vat")]
[BitAutoData("CH", "CHE-123.456.789 HR", "ch_uid")]
[BitAutoData("CH", "CHE123456789HR", "ch_uid")]
[BitAutoData("CH", "CHE-123.456.789 MWST", "ch_vat")]
[BitAutoData("CH", "CHE123456789MWST", "ch_vat")]
[BitAutoData("TW", "12345678", "tw_vat")]
[BitAutoData("TH", "1234567890123", "th_vat")]
[BitAutoData("TR", "0123456789", "tr_tin")]
[BitAutoData("UA", "123456789", "ua_vat")]
[BitAutoData("AE", "123456789012345", "ae_trn")]
[BitAutoData("GB", "XI123456789", "eu_vat")]
[BitAutoData("GB", "GB123456789", "gb_vat")]
[BitAutoData("US", "12-3456789", "us_ein")]
[BitAutoData("UY", "123456789012", "uy_ruc")]
[BitAutoData("UZ", "123456789", "uz_tin")]
[BitAutoData("UZ", "123456789012", "uz_vat")]
[BitAutoData("VE", "A-12345678-9", "ve_rif")]
[BitAutoData("VE", "A123456789", "ve_rif")]
[BitAutoData("VN", "1234567890", "vn_tin")]
public void GetStripeTaxCode_WithValidCountryAndTaxId_ReturnsExpectedTaxIdType(
string country,
string taxId,
string expected,
SutProvider<TaxService> sutProvider)
{
var result = sutProvider.Sut.GetStripeTaxCode(country, taxId);
Assert.Equal(expected, result);
}
}

View File

@ -0,0 +1,238 @@
using System.Text.Json;
using Bit.Scim.IntegrationTest.Factories;
using Bit.Scim.Models;
using Bit.Scim.Utilities;
using Bit.Test.Common.Helpers;
using Xunit;
namespace Bit.Scim.IntegrationTest.Controllers.v2;
public class GroupsControllerPatchTests : IClassFixture<ScimApplicationFactory>, IAsyncLifetime
{
private readonly ScimApplicationFactory _factory;
public GroupsControllerPatchTests(ScimApplicationFactory factory)
{
_factory = factory;
}
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);
}
}

View File

@ -9,9 +9,6 @@ namespace Bit.Scim.IntegrationTest.Controllers.v2;
public class GroupsControllerTests : IClassFixture<ScimApplicationFactory>, IAsyncLifetime public class GroupsControllerTests : IClassFixture<ScimApplicationFactory>, IAsyncLifetime
{ {
private const int _initialGroupCount = 3;
private const int _initialGroupUsersCount = 2;
private readonly ScimApplicationFactory _factory; private readonly ScimApplicationFactory _factory;
public GroupsControllerTests(ScimApplicationFactory factory) public GroupsControllerTests(ScimApplicationFactory factory)
@ -237,10 +234,10 @@ public class GroupsControllerTests : IClassFixture<ScimApplicationFactory>, IAsy
AssertHelper.AssertPropertyEqual(expectedResponse, responseModel, "Id"); AssertHelper.AssertPropertyEqual(expectedResponse, responseModel, "Id");
var databaseContext = _factory.GetDatabaseContext(); var databaseContext = _factory.GetDatabaseContext();
Assert.Equal(_initialGroupCount + 1, databaseContext.Groups.Count()); Assert.Equal(ScimApplicationFactory.InitialGroupCount + 1, databaseContext.Groups.Count());
Assert.True(databaseContext.Groups.Any(g => g.Name == displayName && g.ExternalId == externalId)); Assert.True(databaseContext.Groups.Any(g => g.Name == displayName && g.ExternalId == externalId));
Assert.Equal(_initialGroupUsersCount + 1, databaseContext.GroupUsers.Count()); Assert.Equal(ScimApplicationFactory.InitialGroupUsersCount + 1, databaseContext.GroupUsers.Count());
Assert.True(databaseContext.GroupUsers.Any(gu => gu.GroupId == responseModel.Id && gu.OrganizationUserId == ScimApplicationFactory.TestOrganizationUserId1)); Assert.True(databaseContext.GroupUsers.Any(gu => gu.GroupId == responseModel.Id && gu.OrganizationUserId == ScimApplicationFactory.TestOrganizationUserId1));
} }
@ -248,7 +245,7 @@ public class GroupsControllerTests : IClassFixture<ScimApplicationFactory>, IAsy
[InlineData(null)] [InlineData(null)]
[InlineData("")] [InlineData("")]
[InlineData(" ")] [InlineData(" ")]
public async Task Post_InvalidDisplayName_BadRequest(string displayName) public async Task Post_InvalidDisplayName_BadRequest(string? displayName)
{ {
var organizationId = ScimApplicationFactory.TestOrganizationId1; var organizationId = ScimApplicationFactory.TestOrganizationId1;
var model = new ScimGroupRequestModel var model = new ScimGroupRequestModel
@ -281,7 +278,7 @@ public class GroupsControllerTests : IClassFixture<ScimApplicationFactory>, IAsy
Assert.Equal(StatusCodes.Status409Conflict, context.Response.StatusCode); Assert.Equal(StatusCodes.Status409Conflict, context.Response.StatusCode);
var databaseContext = _factory.GetDatabaseContext(); var databaseContext = _factory.GetDatabaseContext();
Assert.Equal(_initialGroupCount, databaseContext.Groups.Count()); Assert.Equal(ScimApplicationFactory.InitialGroupCount, databaseContext.Groups.Count());
Assert.False(databaseContext.Groups.Any(g => g.Name == "New Group")); Assert.False(databaseContext.Groups.Any(g => g.Name == "New Group"));
} }
@ -354,216 +351,6 @@ public class GroupsControllerTests : IClassFixture<ScimApplicationFactory>, IAsy
AssertHelper.AssertPropertyEqual(expectedResponse, responseModel); AssertHelper.AssertPropertyEqual(expectedResponse, responseModel);
} }
[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(_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(_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(_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(_initialGroupUsersCount - 1, databaseContext.GroupUsers.Count());
Assert.Equal(_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);
}
[Fact] [Fact]
public async Task Delete_Success() public async Task Delete_Success()
{ {
@ -575,7 +362,7 @@ public class GroupsControllerTests : IClassFixture<ScimApplicationFactory>, IAsy
Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode); Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode);
var databaseContext = _factory.GetDatabaseContext(); var databaseContext = _factory.GetDatabaseContext();
Assert.Equal(_initialGroupCount - 1, databaseContext.Groups.Count()); Assert.Equal(ScimApplicationFactory.InitialGroupCount - 1, databaseContext.Groups.Count());
Assert.True(databaseContext.Groups.FirstOrDefault(g => g.Id == groupId) == null); Assert.True(databaseContext.Groups.FirstOrDefault(g => g.Id == groupId) == null);
} }

View File

@ -324,7 +324,7 @@ public class UsersControllerTests : IClassFixture<ScimApplicationFactory>, IAsyn
[InlineData(null)] [InlineData(null)]
[InlineData("")] [InlineData("")]
[InlineData(" ")] [InlineData(" ")]
public async Task Post_InvalidEmail_BadRequest(string email) public async Task Post_InvalidEmail_BadRequest(string? email)
{ {
var displayName = "Test User 5"; var displayName = "Test User 5";
var externalId = "UE"; var externalId = "UE";

View File

@ -9,8 +9,6 @@ using Bit.Infrastructure.EntityFramework.Repositories;
using Bit.IntegrationTestCommon.Factories; using Bit.IntegrationTestCommon.Factories;
using Bit.Scim.Models; using Bit.Scim.Models;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Microsoft.Net.Http.Headers; using Microsoft.Net.Http.Headers;
@ -18,7 +16,8 @@ namespace Bit.Scim.IntegrationTest.Factories;
public class ScimApplicationFactory : WebApplicationFactoryBase<Startup> public class ScimApplicationFactory : WebApplicationFactoryBase<Startup>
{ {
public readonly new TestServer Server; public const int InitialGroupCount = 3;
public const int InitialGroupUsersCount = 2;
public static readonly Guid TestUserId1 = Guid.Parse("2e8173db-8e8d-4de1-ac38-91b15c6d8dcb"); public static readonly Guid TestUserId1 = Guid.Parse("2e8173db-8e8d-4de1-ac38-91b15c6d8dcb");
public static readonly Guid TestUserId2 = Guid.Parse("b57846fc-0e94-4c93-9de5-9d0389eeadfb"); public static readonly Guid TestUserId2 = Guid.Parse("b57846fc-0e94-4c93-9de5-9d0389eeadfb");
@ -33,32 +32,29 @@ public class ScimApplicationFactory : WebApplicationFactoryBase<Startup>
public static readonly Guid TestOrganizationUserId3 = Guid.Parse("be2f9045-e2b6-4173-ad44-4c69c3ea8140"); public static readonly Guid TestOrganizationUserId3 = Guid.Parse("be2f9045-e2b6-4173-ad44-4c69c3ea8140");
public static readonly Guid TestOrganizationUserId4 = Guid.Parse("1f5689b7-e96e-4840-b0b1-eb3d5b5fd514"); public static readonly Guid TestOrganizationUserId4 = Guid.Parse("1f5689b7-e96e-4840-b0b1-eb3d5b5fd514");
public ScimApplicationFactory() protected override void ConfigureWebHost(IWebHostBuilder builder)
{ {
WebApplicationFactory<Startup> webApplicationFactory = WithWebHostBuilder(builder => base.ConfigureWebHost(builder);
builder.ConfigureServices(services =>
{ {
builder.ConfigureServices(services => services
.AddAuthentication("Test")
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>("Test", options => { });
// Override to bypass SCIM authorization
services.AddAuthorization(config =>
{ {
services config.AddPolicy("Scim", policy =>
.AddAuthentication("Test")
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>("Test", options => { });
// Override to bypass SCIM authorization
services.AddAuthorization(config =>
{ {
config.AddPolicy("Scim", policy => policy.RequireAssertion(a => true);
{
policy.RequireAssertion(a => true);
});
}); });
var mailService = services.First(sd => sd.ServiceType == typeof(IMailService));
services.Remove(mailService);
services.AddSingleton<IMailService, NoopMailService>();
}); });
});
Server = webApplicationFactory.Server; var mailService = services.First(sd => sd.ServiceType == typeof(IMailService));
services.Remove(mailService);
services.AddSingleton<IMailService, NoopMailService>();
});
} }
public async Task<HttpContext> GroupsGetAsync(Guid organizationId, Guid id) public async Task<HttpContext> GroupsGetAsync(Guid organizationId, Guid id)

View File

@ -9,7 +9,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.8" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.10" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="$(MicrosoftNetTestSdkVersion)" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="$(MicrosoftNetTestSdkVersion)" />
<PackageReference Include="NSubstitute" Version="$(NSubstituteVersion)" /> <PackageReference Include="NSubstitute" Version="$(NSubstituteVersion)" />
<PackageReference Include="xunit" Version="$(XUnitVersion)" /> <PackageReference Include="xunit" Version="$(XUnitVersion)" />

View File

@ -1,15 +1,18 @@
using System.Text.Json; using System.Text.Json;
using AutoFixture;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Scim.Groups; using Bit.Scim.Groups;
using Bit.Scim.Models; using Bit.Scim.Models;
using Bit.Scim.Utilities; using Bit.Scim.Utilities;
using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes; using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.Extensions.Logging;
using NSubstitute; using NSubstitute;
using Xunit; using Xunit;
@ -20,19 +23,16 @@ public class PatchGroupCommandTests
{ {
[Theory] [Theory]
[BitAutoData] [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; group.OrganizationId = organization.Id;
sutProvider.GetDependency<IGroupRepository>() var scimPatchModel = new ScimPatchModel
.GetByIdAsync(group.Id)
.Returns(group);
var scimPatchModel = new Models.ScimPatchModel
{ {
Operations = new List<ScimPatchModel.OperationModel> Operations = new List<ScimPatchModel.OperationModel>
{ {
new ScimPatchModel.OperationModel new()
{ {
Op = "replace", Op = "replace",
Path = "members", Path = "members",
@ -42,26 +42,31 @@ public class PatchGroupCommandTests
Schemas = new List<string> { ScimConstants.Scim2SchemaUser } 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] [Theory]
[BitAutoData] [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; group.OrganizationId = organization.Id;
sutProvider.GetDependency<IGroupRepository>() sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(group.Id) .GetByIdAsync(organization.Id)
.Returns(group); .Returns(organization);
var scimPatchModel = new Models.ScimPatchModel var scimPatchModel = new ScimPatchModel
{ {
Operations = new List<ScimPatchModel.OperationModel> Operations = new List<ScimPatchModel.OperationModel>
{ {
new ScimPatchModel.OperationModel new()
{ {
Op = "replace", Op = "replace",
Path = "displayname", Path = "displayname",
@ -71,27 +76,55 @@ public class PatchGroupCommandTests
Schemas = new List<string> { ScimConstants.Scim2SchemaUser } 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); await sutProvider.GetDependency<IUpdateGroupCommand>().Received(1).UpdateGroupAsync(group, organization, EventSystemUser.SCIM);
Assert.Equal(displayName, group.Name); 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] [Theory]
[BitAutoData] [BitAutoData]
public async Task PatchGroup_ReplaceDisplayNameFromValueObject_Success(SutProvider<PatchGroupCommand> sutProvider, Organization organization, Group group, string displayName) public async Task PatchGroup_ReplaceDisplayNameFromValueObject_Success(SutProvider<PatchGroupCommand> sutProvider, Organization organization, Group group, string displayName)
{ {
group.OrganizationId = organization.Id; group.OrganizationId = organization.Id;
sutProvider.GetDependency<IGroupRepository>() sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(group.Id) .GetByIdAsync(organization.Id)
.Returns(group); .Returns(organization);
var scimPatchModel = new Models.ScimPatchModel var scimPatchModel = new ScimPatchModel
{ {
Operations = new List<ScimPatchModel.OperationModel> Operations = new List<ScimPatchModel.OperationModel>
{ {
new ScimPatchModel.OperationModel new()
{ {
Op = "replace", Op = "replace",
Value = JsonDocument.Parse($"{{\"displayName\":\"{displayName}\"}}").RootElement Value = JsonDocument.Parse($"{{\"displayName\":\"{displayName}\"}}").RootElement
@ -100,12 +133,39 @@ public class PatchGroupCommandTests
Schemas = new List<string> { ScimConstants.Scim2SchemaUser } 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); await sutProvider.GetDependency<IUpdateGroupCommand>().Received(1).UpdateGroupAsync(group, organization, EventSystemUser.SCIM);
Assert.Equal(displayName, group.Name); 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] [Theory]
[BitAutoData] [BitAutoData]
public async Task PatchGroup_AddSingleMember_Success(SutProvider<PatchGroupCommand> sutProvider, Organization organization, Group group, ICollection<Guid> existingMembers, Guid userId) 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; group.OrganizationId = organization.Id;
sutProvider.GetDependency<IGroupRepository>() sutProvider.GetDependency<IGroupRepository>()
.GetByIdAsync(group.Id) .GetManyUserIdsByIdAsync(group.Id, true)
.Returns(group);
sutProvider.GetDependency<IGroupRepository>()
.GetManyUserIdsByIdAsync(group.Id)
.Returns(existingMembers); .Returns(existingMembers);
var scimPatchModel = new Models.ScimPatchModel var scimPatchModel = new ScimPatchModel
{ {
Operations = new List<ScimPatchModel.OperationModel> Operations = new List<ScimPatchModel.OperationModel>
{ {
new ScimPatchModel.OperationModel new()
{ {
Op = "add", Op = "add",
Path = $"members[value eq \"{userId}\"]", Path = $"members[value eq \"{userId}\"]",
@ -133,9 +189,47 @@ public class PatchGroupCommandTests
Schemas = new List<string> { ScimConstants.Scim2SchemaUser } 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] [Theory]
@ -145,18 +239,14 @@ public class PatchGroupCommandTests
group.OrganizationId = organization.Id; group.OrganizationId = organization.Id;
sutProvider.GetDependency<IGroupRepository>() sutProvider.GetDependency<IGroupRepository>()
.GetByIdAsync(group.Id) .GetManyUserIdsByIdAsync(group.Id, true)
.Returns(group);
sutProvider.GetDependency<IGroupRepository>()
.GetManyUserIdsByIdAsync(group.Id)
.Returns(existingMembers); .Returns(existingMembers);
var scimPatchModel = new Models.ScimPatchModel var scimPatchModel = new ScimPatchModel
{ {
Operations = new List<ScimPatchModel.OperationModel> Operations = new List<ScimPatchModel.OperationModel>
{ {
new ScimPatchModel.OperationModel new()
{ {
Op = "add", Op = "add",
Path = $"members", Path = $"members",
@ -166,9 +256,101 @@ public class PatchGroupCommandTests
Schemas = new List<string> { ScimConstants.Scim2SchemaUser } 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] [Theory]
@ -177,10 +359,6 @@ public class PatchGroupCommandTests
{ {
group.OrganizationId = organization.Id; group.OrganizationId = organization.Id;
sutProvider.GetDependency<IGroupRepository>()
.GetByIdAsync(group.Id)
.Returns(group);
var scimPatchModel = new Models.ScimPatchModel var scimPatchModel = new Models.ScimPatchModel
{ {
Operations = new List<ScimPatchModel.OperationModel> Operations = new List<ScimPatchModel.OperationModel>
@ -194,21 +372,19 @@ public class PatchGroupCommandTests
Schemas = new List<string> { ScimConstants.Scim2SchemaUser } 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); await sutProvider.GetDependency<IGroupService>().Received(1).DeleteUserAsync(group, userId, EventSystemUser.SCIM);
} }
[Theory] [Theory]
[BitAutoData] [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; group.OrganizationId = organization.Id;
sutProvider.GetDependency<IGroupRepository>()
.GetByIdAsync(group.Id)
.Returns(group);
sutProvider.GetDependency<IGroupRepository>() sutProvider.GetDependency<IGroupRepository>()
.GetManyUserIdsByIdAsync(group.Id) .GetManyUserIdsByIdAsync(group.Id)
.Returns(existingMembers); .Returns(existingMembers);
@ -217,30 +393,58 @@ public class PatchGroupCommandTests
{ {
Operations = new List<ScimPatchModel.OperationModel> Operations = new List<ScimPatchModel.OperationModel>
{ {
new ScimPatchModel.OperationModel new()
{ {
Op = "remove", Op = "remove",
Path = $"members", 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 } 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] [Theory]
[BitAutoData] [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; group.OrganizationId = organization.Id;
sutProvider.GetDependency<IGroupRepository>() var scimPatchModel = new Models.ScimPatchModel
.GetByIdAsync(group.Id) {
.Returns(group); 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 var scimPatchModel = new Models.ScimPatchModel
{ {
@ -248,45 +452,11 @@ public class PatchGroupCommandTests
Schemas = new List<string> { ScimConstants.Scim2SchemaUser } 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().UpdateUsersAsync(default, default);
await sutProvider.GetDependency<IGroupRepository>().DidNotReceiveWithAnyArgs().GetManyUserIdsByIdAsync(default); await sutProvider.GetDependency<IGroupRepository>().DidNotReceiveWithAnyArgs().GetManyUserIdsByIdAsync(default);
await sutProvider.GetDependency<IUpdateGroupCommand>().DidNotReceiveWithAnyArgs().UpdateGroupAsync(default, default); await sutProvider.GetDependency<IUpdateGroupCommand>().DidNotReceiveWithAnyArgs().UpdateGroupAsync(default, default);
await sutProvider.GetDependency<IGroupService>().DidNotReceiveWithAnyArgs().DeleteUserAsync(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));
}
} }

View File

@ -1,10 +1,8 @@
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Scim.Context;
using Bit.Scim.Groups; using Bit.Scim.Groups;
using Bit.Scim.Models; using Bit.Scim.Models;
using Bit.Scim.Utilities; using Bit.Scim.Utilities;
@ -73,10 +71,6 @@ public class PostGroupCommandTests
.GetManyByOrganizationIdAsync(organization.Id) .GetManyByOrganizationIdAsync(organization.Id)
.Returns(groups); .Returns(groups);
sutProvider.GetDependency<IScimContext>()
.RequestScimProvider
.Returns(ScimProviderType.Okta);
var group = await sutProvider.Sut.PostGroupAsync(organization, scimGroupRequestModel); var group = await sutProvider.Sut.PostGroupAsync(organization, scimGroupRequestModel);
await sutProvider.GetDependency<ICreateGroupCommand>().Received(1).CreateGroupAsync(group, organization, EventSystemUser.SCIM, null); await sutProvider.GetDependency<ICreateGroupCommand>().Received(1).CreateGroupAsync(group, organization, EventSystemUser.SCIM, null);

View File

@ -1,10 +1,8 @@
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Scim.Context;
using Bit.Scim.Groups; using Bit.Scim.Groups;
using Bit.Scim.Models; using Bit.Scim.Models;
using Bit.Scim.Utilities; using Bit.Scim.Utilities;
@ -62,10 +60,6 @@ public class PutGroupCommandTests
.GetByIdAsync(group.Id) .GetByIdAsync(group.Id)
.Returns(group); .Returns(group);
sutProvider.GetDependency<IScimContext>()
.RequestScimProvider
.Returns(ScimProviderType.Okta);
var inputModel = new ScimGroupRequestModel var inputModel = new ScimGroupRequestModel
{ {
DisplayName = displayName, DisplayName = displayName,

View File

@ -1,4 +1,5 @@
using System.Text.Json; using System.Text.Json;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
@ -43,7 +44,7 @@ public class PatchUserCommandTests
await sutProvider.Sut.PatchUserAsync(organizationUser.OrganizationId, organizationUser.Id, scimPatchModel); 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] [Theory]
@ -71,7 +72,7 @@ public class PatchUserCommandTests
await sutProvider.Sut.PatchUserAsync(organizationUser.OrganizationId, organizationUser.Id, scimPatchModel); 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] [Theory]
@ -147,7 +148,7 @@ public class PatchUserCommandTests
await sutProvider.Sut.PatchUserAsync(organizationUser.OrganizationId, organizationUser.Id, scimPatchModel); 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); await sutProvider.GetDependency<IOrganizationService>().DidNotReceiveWithAnyArgs().RevokeUserAsync(default, EventSystemUser.SCIM);
} }

View File

@ -21,3 +21,7 @@ IDP_SP_ACS_URL=http://localhost:51822/saml2/yourOrgIdHere/Acs
# Should match server listen ports in reverse-proxy.conf # Should match server listen ports in reverse-proxy.conf
API_PROXY_PORT=4100 API_PROXY_PORT=4100
IDENTITY_PROXY_PORT=33756 IDENTITY_PROXY_PORT=33756
# Optional RabbitMQ configuration
RABBITMQ_DEFAULT_USER=bitwarden
RABBITMQ_DEFAULT_PASS=SET_A_PASSWORD_HERE_123

View File

@ -84,6 +84,20 @@ services:
profiles: profiles:
- idp - idp
rabbitmq:
image: rabbitmq:management
container_name: rabbitmq
ports:
- "5672:5672"
- "15672:15672"
environment:
RABBITMQ_DEFAULT_USER: ${RABBITMQ_DEFAULT_USER}
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_DEFAULT_PASS}
volumes:
- rabbitmq_data:/var/lib/rabbitmq_data
profiles:
- rabbitmq
reverse-proxy: reverse-proxy:
image: nginx:alpine image: nginx:alpine
container_name: reverse-proxy container_name: reverse-proxy
@ -95,7 +109,23 @@ services:
profiles: profiles:
- proxy - proxy
service-bus:
container_name: service-bus
image: mcr.microsoft.com/azure-messaging/servicebus-emulator:latest
pull_policy: always
volumes:
- "./servicebusemulator_config.json:/ServiceBus_Emulator/ConfigFiles/Config.json"
ports:
- "5672:5672"
environment:
SQL_SERVER: mssql
MSSQL_SA_PASSWORD: "${MSSQL_PASSWORD}"
ACCEPT_EULA: "Y"
profiles:
- servicebus
volumes: volumes:
mssql_dev_data: mssql_dev_data:
postgres_dev_data: postgres_dev_data:
mysql_dev_data: mysql_dev_data:
rabbitmq_data:

View File

@ -7,11 +7,13 @@ param(
[switch]$mysql, [switch]$mysql,
[switch]$mssql, [switch]$mssql,
[switch]$sqlite, [switch]$sqlite,
[switch]$selfhost [switch]$selfhost,
[switch]$test
) )
# Abort on any error # Abort on any error
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
$currentDir = Get-Location
if (!$all -and !$postgres -and !$mysql -and !$sqlite) { if (!$all -and !$postgres -and !$mysql -and !$sqlite) {
$mssql = $true; $mssql = $true;
@ -25,36 +27,62 @@ if ($all -or $postgres -or $mysql -or $sqlite) {
} }
} }
if ($all -or $mssql) { function Get-UserSecrets {
function Get-UserSecrets { # The dotnet cli command sometimes adds //BEGIN and //END comments to the output, Where-Object removes comments
# The dotnet cli command sometimes adds //BEGIN and //END comments to the output, Where-Object removes comments # to ensure a valid json
# to ensure a valid json return dotnet user-secrets list --json --project "$currentDir/../src/Api" | Where-Object { $_ -notmatch "^//" } | ConvertFrom-Json
return dotnet user-secrets list --json --project ../src/Api | Where-Object { $_ -notmatch "^//" } | ConvertFrom-Json
}
if ($selfhost) {
$msSqlConnectionString = $(Get-UserSecrets).'dev:selfHostOverride:globalSettings:sqlServer:connectionString'
$envName = "self-host"
} else {
$msSqlConnectionString = $(Get-UserSecrets).'globalSettings:sqlServer:connectionString'
$envName = "cloud"
}
Write-Host "Starting Microsoft SQL Server Migrations for $envName"
dotnet run --project ../util/MsSqlMigratorUtility/ "$msSqlConnectionString"
} }
$currentDir = Get-Location if ($all -or $mssql) {
if ($all -or !$test) {
if ($selfhost) {
$msSqlConnectionString = $(Get-UserSecrets).'dev:selfHostOverride:globalSettings:sqlServer:connectionString'
$envName = "self-host"
} else {
$msSqlConnectionString = $(Get-UserSecrets).'globalSettings:sqlServer:connectionString'
$envName = "cloud"
}
Foreach ($item in @(@($mysql, "MySQL", "MySqlMigrations"), @($postgres, "PostgreSQL", "PostgresMigrations"), @($sqlite, "SQLite", "SqliteMigrations"))) { Write-Host "Starting Microsoft SQL Server Migrations for $envName"
dotnet run --project ../util/MsSqlMigratorUtility/ "$msSqlConnectionString"
}
if ($all -or $test) {
$testMsSqlConnectionString = $(Get-UserSecrets).'databases:3:connectionString'
if ($testMsSqlConnectionString) {
$testEnvName = "test databases"
Write-Host "Starting Microsoft SQL Server Migrations for $testEnvName"
dotnet run --project ../util/MsSqlMigratorUtility/ "$testMsSqlConnectionString"
} else {
Write-Host "Connection string for a test MSSQL database not found in secrets.json!"
}
}
}
Foreach ($item in @(
@($mysql, "MySQL", "MySqlMigrations", "mySql", 2),
@($postgres, "PostgreSQL", "PostgresMigrations", "postgreSql", 0),
@($sqlite, "SQLite", "SqliteMigrations", "sqlite", 1)
)) {
if (!$item[0] -and !$all) { if (!$item[0] -and !$all) {
continue continue
} }
Write-Host "Starting $($item[1]) Migrations"
Set-Location "$currentDir/../util/$($item[2])/" Set-Location "$currentDir/../util/$($item[2])/"
dotnet ef database update if(!$test -or $all) {
Write-Host "Starting $($item[1]) Migrations"
$connectionString = $(Get-UserSecrets)."globalSettings:$($item[3]):connectionString"
dotnet ef database update --connection "$connectionString"
}
if ($test -or $all) {
$testConnectionString = $(Get-UserSecrets)."databases:$($item[4]):connectionString"
if ($testConnectionString) {
Write-Host "Starting $($item[1]) Migrations for test databases"
dotnet ef database update --connection "$testConnectionString"
} else {
Write-Host "Connection string for a test $($item[1]) database not found in secrets.json!"
}
}
} }
Set-Location "$currentDir" Set-Location "$currentDir"

View File

@ -21,7 +21,7 @@
"connectionString": "server=localhost;uid=root;pwd=SET_A_PASSWORD_HERE_123;database=vault_dev" "connectionString": "server=localhost;uid=root;pwd=SET_A_PASSWORD_HERE_123;database=vault_dev"
}, },
"sqlite": { "sqlite": {
"connectionString": "Data Source=/path/to/bitwardenServer/repository/server/dev/db/bitwarden.sqlite" "connectionString": "Data Source=/path/to/bitwardenServer/repository/server/dev/db/bitwarden.db"
}, },
"identityServer": { "identityServer": {
"certificateThumbprint": "<your Identity certificate thumbprint with no spaces>" "certificateThumbprint": "<your Identity certificate thumbprint with no spaces>"

View File

@ -0,0 +1,38 @@
{
"UserConfig": {
"Namespaces": [
{
"Name": "sbemulatorns",
"Queues": [
{
"Name": "queue.1",
"Properties": {
"DeadLetteringOnMessageExpiration": false,
"DefaultMessageTimeToLive": "PT1H",
"DuplicateDetectionHistoryTimeWindow": "PT20S",
"ForwardDeadLetteredMessagesTo": "",
"ForwardTo": "",
"LockDuration": "PT1M",
"MaxDeliveryCount": 3,
"RequiresDuplicateDetection": false,
"RequiresSession": false
}
}
],
"Topics": [
{
"Name": "event-logging",
"Subscriptions": [
{
"Name": "events-write-subscription"
}
]
}
]
}
],
"Logging": {
"Type": "File"
}
}
}

90
perf/load/sync.js Normal file
View 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"
});
}
}

View File

@ -1,3 +0,0 @@
$scriptPath = $MyInvocation.MyCommand.Path
Invoke-RestMethod -OutFile $scriptPath -Uri "https://go.btwrdn.co/bw-ps"
Write-Output "We have moved our self-hosted scripts to their own repository (https://github.com/bitwarden/self-host). Your 'bitwarden.ps1' script has been automatically upgraded. Please run it again."

View File

@ -1,31 +0,0 @@
#!/usr/bin/env bash
set -e
cat << "EOF"
_ _ _ _
| |__ (_) |___ ____ _ _ __ __| | ___ _ __
| '_ \| | __\ \ /\ / / _` | '__/ _` |/ _ \ '_ \
| |_) | | |_ \ V V / (_| | | | (_| | __/ | | |
|_.__/|_|\__| \_/\_/ \__,_|_| \__,_|\___|_| |_|
EOF
cat << EOF
Open source password management solutions
Copyright 2015-$(date +'%Y'), 8bit Solutions LLC
https://bitwarden.com, https://github.com/bitwarden
===================================================
EOF
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
SCRIPT_NAME=$(basename "$0")
SCRIPT_PATH="$DIR/$SCRIPT_NAME"
BITWARDEN_SCRIPT_URL="https://go.btwrdn.co/bw-sh"
if curl -L -s -w "http_code %{http_code}" -o $SCRIPT_PATH.1 $BITWARDEN_SCRIPT_URL | grep -q "^http_code 20[0-9]"
then
mv $SCRIPT_PATH.1 $SCRIPT_PATH
chmod u+x $SCRIPT_PATH
echo "We have moved our self-hosted scripts to their own repository (https://github.com/bitwarden/self-host). Your 'bitwarden.sh' script has been automatically upgraded. Please run it again."
else
rm -f $SCRIPT_PATH.1
fi

View File

@ -1,47 +0,0 @@
#!/bin/bash
##############################
# Builds a specified service
# Arguments:
# 1: Project to build
# 2: Project path
##############################
build() {
local project=$1
local project_dir=$2
echo "Building $project"
echo "Build Path: $project_dir"
echo "=================="
chmod u+x "$project_dir/build.sh"
"$project_dir/build.sh"
}
# Get Project
PROJECT=$1; shift
case "$PROJECT" in
"admin" | "Admin") build Admin $PWD/src/Admin ;;
"api" | "Api") build Api $PWD/src/Api ;;
"billing" | "Billing") build Billing $PWD/src/Billing ;;
"events" | "Events") build Events $PWD/src/Events ;;
"eventsprocessor" | "EventsProcessor") build EventsProcessor $PWD/src/EventsProcessor ;;
"icons" | "Icons") build Icons $PWD/src/Icons ;;
"identity" | "Identity") build Identity $PWD/src/Identity ;;
"notifications" | "Notifications") build Notifications $PWD/src/Notifications ;;
"server" | "Server") build Server $PWD/util/Server ;;
"sso" | "Sso") build Sso $PWD/bitwarden_license/src/Sso ;;
"")
build Admin $PWD/src/Admin
build Api $PWD/src/Api
build Billing $PWD/src/Billing
build Events $PWD/src/Events
build EventsProcessor $PWD/src/EventsProcessor
build Icons $PWD/src/Icons
build Identity $PWD/src/Identity
build Notifications $PWD/src/Notifications
build Server $PWD/util/Server
build Sso $PWD/bitwarden_license/src/Sso
;;
esac

View File

@ -1,88 +0,0 @@
#!/bin/bash
##############################
# Builds the docker image from a pre-built build directory
# Arguments:
# 1: Project Name
# 2: Project Directory
# 3: Docker Tag
# 4: Docker push
# Outputs:
# Output to STDOUT or STDERR.
# Returns:
# Returned values other than the default exit status of the last command run.
##############################
docker_build() {
local project_name=$1
local project_dir=$2
local docker_tag=$3
local docker_push=$4
local project_name_lower=$(echo "$project_name" | awk '{print tolower($0)}')
echo "Building docker image: bitwarden/$project_name_lower:$docker_tag"
echo "=============================="
docker build -t bitwarden/$project_name_lower:$docker_tag $project_dir
if [ "$docker_push" == "1" ]; then
docker push bitwarden/$project_name_lower:$docker_tag
fi
}
# Get Project
PROJECT=$1; shift
# Get Params
TAG="latest"
PUSH=0
while [ ! $# -eq 0 ]; do
case "$1" in
-t | --tag)
if [[ $2 ]]; then
TAG="$2"
shift
else
exp "--tag requires a value"
fi
;;
--push) PUSH=1 ;;
-h | --help ) usage && exit ;;
*) usage && exit ;;
esac
shift
done
case "$PROJECT" in
"admin" | "Admin") docker_build Admin $PWD/src/Admin $TAG $PUSH ;;
"api" | "Api") docker_build Api $PWD/src/Api $TAG $PUSH ;;
"attachments" | "Attachments") docker_build Attachments $PWD/util/Attachments $TAG $PUSH ;;
#"billing" | "Billing") docker_build Billing $PWD/src/Billing $TAG $PUSH ;;
"events" | "Events") docker_build Events $PWD/src/Events $TAG $PUSH ;;
"eventsprocessor" | "EventsProcessor") docker_build EventsProcessor $PWD/src/EventsProcessor $TAG $PUSH ;;
"icons" | "Icons") docker_build Icons $PWD/src/Icons $TAG $PUSH ;;
"identity" | "Identity") docker_build Identity $PWD/src/Identity $TAG $PUSH ;;
"mssql" | "MsSql" | "Mssql") docker_build MsSql $PWD/util/MsSql $TAG $PUSH ;;
"nginx" | "Nginx") docker_build Nginx $PWD/util/Nginx $TAG $PUSH ;;
"notifications" | "Notifications") docker_build Notifications $PWD/src/Notifications $TAG $PUSH ;;
"server" | "Server") docker_build Server $PWD/util/Server $TAG $PUSH ;;
"setup" | "Setup") docker_build Setup $PWD/util/Setup $TAG $PUSH ;;
"sso" | "Sso") docker_build Sso $PWD/bitwarden_license/src/Sso $TAG $PUSH ;;
"")
docker_build Admin $PWD/src/Admin $TAG $PUSH
docker_build Api $PWD/src/Api $TAG $PUSH
docker_build Attachments $PWD/util/Attachments $TAG $PUSH
#docker_build Billing $PWD/src/Billing $TAG $PUSH
docker_build Events $PWD/src/Events $TAG $PUSH
docker_build EventsProcessor $PWD/src/EventsProcessor $TAG $PUSH
docker_build Icons $PWD/src/Icons $TAG $PUSH
docker_build Identity $PWD/src/Identity $TAG $PUSH
docker_build MsSql $PWD/util/MsSql $TAG $PUSH
docker_build Nginx $PWD/util/Nginx $TAG $PUSH
docker_build Notifications $PWD/src/Notifications $TAG $PUSH
docker_build Server $PWD/util/Server $TAG $PUSH
docker_build Setup $PWD/util/Setup $TAG $PUSH
docker_build Sso $PWD/bitwarden_license/src/Sso $TAG $PUSH
;;
esac

View File

@ -1,42 +0,0 @@
#!/bin/bash
##############################
# Builds the docker image from a pre-built build directory
# Arguments:
# 1: Project Name
# 2: Project Directory
# 3: Docker Tag
# 4: Docker push
##############################
deploy_app_service() {
local project_name=$1
local project_dir=$2
local project_name_lower=$(echo "$project_name" | awk '{print tolower($0)}')
local webapp_name=$(az keyvault secret show --vault-name bitwarden-qa-kv --name appservices-$project_name_lower-webapp-name --query value --output tsv)
cd $project_dir/obj/build-output/publish
zip -r $project_name.zip .
mv $project_name.zip ../../../
#az webapp deploy --resource-group bw-qa-env --name $webapp_name \
# --src-path $project_name.zip --verbose --type zip --restart true --subscription "Bitwarden Test"
}
PROJECT=$1; shift
case "$PROJECT" in
"api" | "Api") deploy_app_service Api $PWD/src/Api ;;
"admin" | "Admin") deploy_app_service Admin $PWD/src/Admin ;;
"identity" | "Identity") deploy_app_service Identity $PWD/src/Identity ;;
"events" | "Events") deploy_app_service Events $PWD/src/Events ;;
"billing" | "Billing") deploy_app_service Billing $PWD/src/Billing ;;
"sso" | "Sso") deploy_app_service Sso $PWD/bitwarden_license/src/Sso ;;
"")
deploy_app_service Api $PWD/src/Api
deploy_app_service Admin $PWD/src/Admin
deploy_app_service Identity $PWD/src/Identity
deploy_app_service Events $PWD/src/Events
deploy_app_service Billing $PWD/src/Billing
deploy_app_service Sso $PWD/bitwarden_license/src/Sso
;;
esac

View File

@ -1,16 +0,0 @@
$scriptPath = $MyInvocation.MyCommand.Path
$bitwardenPath = Split-Path $scriptPath | Split-Path | Split-Path
$files = Get-ChildItem $bitwardenPath
$scriptFound = $false
foreach ($file in $files) {
if ($file.Name -eq "bitwarden.ps1") {
$scriptFound = $true
Invoke-RestMethod -OutFile "$($bitwardenPath)/bitwarden.ps1" -Uri "https://go.btwrdn.co/bw-ps"
Write-Output "We have moved our self-hosted scripts to their own repository (https://github.com/bitwarden/self-host). Your 'bitwarden.ps1' script has been automatically upgraded. Please run it again."
break
}
}
if (-not $scriptFound) {
Write-Output "We have moved our self-hosted scripts to their own repository (https://github.com/bitwarden/self-host). Please run 'bitwarden.ps1 -updateself' before updating."
}

View File

@ -1,45 +0,0 @@
#!/usr/bin/env bash
set -e
cat << "EOF"
_ _ _ _
| |__ (_) |___ ____ _ _ __ __| | ___ _ __
| '_ \| | __\ \ /\ / / _` | '__/ _` |/ _ \ '_ \
| |_) | | |_ \ V V / (_| | | | (_| | __/ | | |
|_.__/|_|\__| \_/\_/ \__,_|_| \__,_|\___|_| |_|
EOF
cat << EOF
Open source password management solutions
Copyright 2015-$(date +'%Y'), 8bit Solutions LLC
https://bitwarden.com, https://github.com/bitwarden
===================================================
EOF
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
BITWARDEN_SCRIPT_URL="https://go.btwrdn.co/bw-sh"
cd $DIR
cd ../../
FOUND=false
for i in *.sh; do
if [ $i = "bitwarden.sh" ]
then
FOUND=true
if curl -L -s -w "http_code %{http_code}" -o bitwarden.sh.1 $BITWARDEN_SCRIPT_URL | grep -q "^http_code 20[0-9]"
then
mv bitwarden.sh.1 bitwarden.sh
chmod u+x bitwarden.sh
echo "We have moved our self-hosted scripts to their own repository (https://github.com/bitwarden/self-host). Your 'bitwarden.sh' script has been automatically upgraded. Please run it again."
else
rm -f bitwarden.sh.1
fi
fi
done
if [ $FOUND = false ]
then
echo "We have moved our self-hosted scripts to their own repository (https://github.com/bitwarden/self-host). Please run 'bitwarden.sh updateself' before updating."
fi

View File

@ -16,7 +16,6 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Folder Include="Billing\Controllers\" /> <Folder Include="Billing\Controllers\" />
<Folder Include="Billing\Models\" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="8.0.10" /> <PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="8.0.10" />

View File

@ -3,15 +3,17 @@ using Bit.Admin.AdminConsole.Models;
using Bit.Admin.Enums; using Bit.Admin.Enums;
using Bit.Admin.Services; using Bit.Admin.Services;
using Bit.Admin.Utilities; using Bit.Admin.Utilities;
using Bit.Core;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Providers.Interfaces;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Services;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.OrganizationConnectionConfigs; using Bit.Core.Models.OrganizationConnectionConfigs;
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
using Bit.Core.Repositories; using Bit.Core.Repositories;
@ -54,8 +56,9 @@ public class OrganizationsController : Controller
private readonly IServiceAccountRepository _serviceAccountRepository; private readonly IServiceAccountRepository _serviceAccountRepository;
private readonly IProviderOrganizationRepository _providerOrganizationRepository; private readonly IProviderOrganizationRepository _providerOrganizationRepository;
private readonly IRemoveOrganizationFromProviderCommand _removeOrganizationFromProviderCommand; private readonly IRemoveOrganizationFromProviderCommand _removeOrganizationFromProviderCommand;
private readonly IFeatureService _featureService;
private readonly IProviderBillingService _providerBillingService; private readonly IProviderBillingService _providerBillingService;
private readonly IOrganizationInitiateDeleteCommand _organizationInitiateDeleteCommand;
private readonly IPricingClient _pricingClient;
public OrganizationsController( public OrganizationsController(
IOrganizationService organizationService, IOrganizationService organizationService,
@ -81,8 +84,9 @@ public class OrganizationsController : Controller
IServiceAccountRepository serviceAccountRepository, IServiceAccountRepository serviceAccountRepository,
IProviderOrganizationRepository providerOrganizationRepository, IProviderOrganizationRepository providerOrganizationRepository,
IRemoveOrganizationFromProviderCommand removeOrganizationFromProviderCommand, IRemoveOrganizationFromProviderCommand removeOrganizationFromProviderCommand,
IFeatureService featureService, IProviderBillingService providerBillingService,
IProviderBillingService providerBillingService) IOrganizationInitiateDeleteCommand organizationInitiateDeleteCommand,
IPricingClient pricingClient)
{ {
_organizationService = organizationService; _organizationService = organizationService;
_organizationRepository = organizationRepository; _organizationRepository = organizationRepository;
@ -107,8 +111,9 @@ public class OrganizationsController : Controller
_serviceAccountRepository = serviceAccountRepository; _serviceAccountRepository = serviceAccountRepository;
_providerOrganizationRepository = providerOrganizationRepository; _providerOrganizationRepository = providerOrganizationRepository;
_removeOrganizationFromProviderCommand = removeOrganizationFromProviderCommand; _removeOrganizationFromProviderCommand = removeOrganizationFromProviderCommand;
_featureService = featureService;
_providerBillingService = providerBillingService; _providerBillingService = providerBillingService;
_organizationInitiateDeleteCommand = organizationInitiateDeleteCommand;
_pricingClient = pricingClient;
} }
[RequirePermission(Permission.Org_List_View)] [RequirePermission(Permission.Org_List_View)]
@ -208,6 +213,8 @@ public class OrganizationsController : Controller
? await _organizationUserRepository.GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id) ? await _organizationUserRepository.GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id)
: -1; : -1;
var plans = await _pricingClient.ListPlans();
return View(new OrganizationEditModel( return View(new OrganizationEditModel(
organization, organization,
provider, provider,
@ -220,6 +227,7 @@ public class OrganizationsController : Controller
billingHistoryInfo, billingHistoryInfo,
billingSyncConnection, billingSyncConnection,
_globalSettings, _globalSettings,
plans,
secrets, secrets,
projects, projects,
serviceAccounts, serviceAccounts,
@ -231,15 +239,38 @@ public class OrganizationsController : Controller
[SelfHosted(NotSelfHostedOnly = true)] [SelfHosted(NotSelfHostedOnly = true)]
public async Task<IActionResult> Edit(Guid id, OrganizationEditModel model) public async Task<IActionResult> Edit(Guid id, OrganizationEditModel model)
{ {
var organization = await GetOrganization(id, model); var organization = await _organizationRepository.GetByIdAsync(id);
if (organization.UseSecretsManager && if (organization == null)
!StaticStore.GetPlan(organization.PlanType).SupportsSecretsManager)
{ {
throw new BadRequestException("Plan does not support Secrets Manager"); TempData["Error"] = "Could not find organization to update.";
return RedirectToAction("Index");
} }
var existingOrganizationData = new Organization
{
Id = organization.Id,
Status = organization.Status,
PlanType = organization.PlanType,
Seats = organization.Seats
};
UpdateOrganization(organization, model);
var plan = await _pricingClient.GetPlanOrThrow(organization.PlanType);
if (organization.UseSecretsManager && !plan.SupportsSecretsManager)
{
TempData["Error"] = "Plan does not support Secrets Manager";
return RedirectToAction("Edit", new { id });
}
await HandlePotentialProviderSeatScalingAsync(
existingOrganizationData,
model);
await _organizationRepository.ReplaceAsync(organization); await _organizationRepository.ReplaceAsync(organization);
await _applicationCacheService.UpsertOrganizationAbilityAsync(organization); await _applicationCacheService.UpsertOrganizationAbilityAsync(organization);
await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.OrganizationEditedByAdmin, organization, _currentContext) await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.OrganizationEditedByAdmin, organization, _currentContext)
{ {
@ -262,9 +293,7 @@ public class OrganizationsController : Controller
return RedirectToAction("Index"); return RedirectToAction("Index");
} }
var consolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling); if (organization.IsValidClient())
if (consolidatedBillingEnabled && organization.IsValidClient())
{ {
var provider = await _providerRepository.GetByOrganizationIdAsync(organization.Id); var provider = await _providerRepository.GetByOrganizationIdAsync(organization.Id);
@ -285,7 +314,7 @@ public class OrganizationsController : Controller
[HttpPost] [HttpPost]
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
[RequirePermission(Permission.Org_Delete)] [RequirePermission(Permission.Org_RequestDelete)]
public async Task<IActionResult> DeleteInitiation(Guid id, OrganizationInitiateDeleteModel model) public async Task<IActionResult> DeleteInitiation(Guid id, OrganizationInitiateDeleteModel model)
{ {
if (!ModelState.IsValid) if (!ModelState.IsValid)
@ -299,7 +328,7 @@ public class OrganizationsController : Controller
var organization = await _organizationRepository.GetByIdAsync(id); var organization = await _organizationRepository.GetByIdAsync(id);
if (organization != null) if (organization != null)
{ {
await _organizationService.InitiateDeleteAsync(organization, model.AdminEmail); await _organizationInitiateDeleteCommand.InitiateDeleteAsync(organization, model.AdminEmail);
TempData["Success"] = "The request to initiate deletion of the organization has been sent."; TempData["Success"] = "The request to initiate deletion of the organization has been sent.";
} }
} }
@ -394,9 +423,13 @@ public class OrganizationsController : Controller
return Json(null); return Json(null);
} }
private async Task<Organization> GetOrganization(Guid id, OrganizationEditModel model)
private void UpdateOrganization(Organization organization, OrganizationEditModel model)
{ {
var organization = await _organizationRepository.GetByIdAsync(id); if (_accessControlService.UserHasPermission(Permission.Org_Name_Edit))
{
organization.Name = WebUtility.HtmlEncode(model.Name);
}
if (_accessControlService.UserHasPermission(Permission.Org_CheckEnabledBox)) if (_accessControlService.UserHasPermission(Permission.Org_CheckEnabledBox))
{ {
@ -428,6 +461,7 @@ public class OrganizationsController : Controller
organization.UseTotp = model.UseTotp; organization.UseTotp = model.UseTotp;
organization.UsersGetPremium = model.UsersGetPremium; organization.UsersGetPremium = model.UsersGetPremium;
organization.UseSecretsManager = model.UseSecretsManager; organization.UseSecretsManager = model.UseSecretsManager;
organization.UseRiskInsights = model.UseRiskInsights;
//secrets //secrets
organization.SmSeats = model.SmSeats; organization.SmSeats = model.SmSeats;
@ -449,7 +483,54 @@ public class OrganizationsController : Controller
organization.GatewayCustomerId = model.GatewayCustomerId; organization.GatewayCustomerId = model.GatewayCustomerId;
organization.GatewaySubscriptionId = model.GatewaySubscriptionId; organization.GatewaySubscriptionId = model.GatewaySubscriptionId;
} }
}
return organization; private async Task HandlePotentialProviderSeatScalingAsync(
Organization organization,
OrganizationEditModel update)
{
var provider = await _providerRepository.GetByOrganizationIdAsync(organization.Id);
// No scaling required
if (provider is not { Type: ProviderType.Msp, Status: ProviderStatusType.Billable } ||
organization is not { Status: OrganizationStatusType.Managed } ||
!organization.Seats.HasValue ||
update is { Seats: null, PlanType: null } ||
update is { PlanType: not PlanType.TeamsMonthly and not PlanType.EnterpriseMonthly } ||
(PlanTypesMatch() && SeatsMatch()))
{
return;
}
// Only scale the plan
if (!PlanTypesMatch() && SeatsMatch())
{
await _providerBillingService.ScaleSeats(provider, organization.PlanType, -organization.Seats.Value);
await _providerBillingService.ScaleSeats(provider, update.PlanType!.Value, organization.Seats.Value);
}
// Only scale the seats
else if (PlanTypesMatch() && !SeatsMatch())
{
var seatAdjustment = update.Seats!.Value - organization.Seats.Value;
await _providerBillingService.ScaleSeats(provider, organization.PlanType, seatAdjustment);
}
// Scale both
else if (!PlanTypesMatch() && !SeatsMatch())
{
var seatAdjustment = update.Seats!.Value - organization.Seats.Value;
var planTypeAdjustment = organization.Seats.Value;
var totalAdjustment = seatAdjustment + planTypeAdjustment;
await _providerBillingService.ScaleSeats(provider, organization.PlanType, -organization.Seats.Value);
await _providerBillingService.ScaleSeats(provider, update.PlanType!.Value, totalAdjustment);
}
return;
bool PlanTypesMatch()
=> update.PlanType.HasValue && update.PlanType.Value == organization.PlanType;
bool SeatsMatch()
=> update.Seats.HasValue && update.Seats.Value == organization.Seats;
} }
} }

View File

@ -3,7 +3,6 @@ using System.Net;
using Bit.Admin.AdminConsole.Models; using Bit.Admin.AdminConsole.Models;
using Bit.Admin.Enums; using Bit.Admin.Enums;
using Bit.Admin.Utilities; using Bit.Admin.Utilities;
using Bit.Core;
using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Providers.Interfaces;
@ -12,8 +11,10 @@ using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Entities; using Bit.Core.Billing.Entities;
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Repositories; using Bit.Core.Billing.Repositories;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Services;
using Bit.Core.Billing.Services.Contracts;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
@ -42,6 +43,7 @@ public class ProvidersController : Controller
private readonly IFeatureService _featureService; private readonly IFeatureService _featureService;
private readonly IProviderPlanRepository _providerPlanRepository; private readonly IProviderPlanRepository _providerPlanRepository;
private readonly IProviderBillingService _providerBillingService; private readonly IProviderBillingService _providerBillingService;
private readonly IPricingClient _pricingClient;
private readonly string _stripeUrl; private readonly string _stripeUrl;
private readonly string _braintreeMerchantUrl; private readonly string _braintreeMerchantUrl;
private readonly string _braintreeMerchantId; private readonly string _braintreeMerchantId;
@ -60,7 +62,8 @@ public class ProvidersController : Controller
IFeatureService featureService, IFeatureService featureService,
IProviderPlanRepository providerPlanRepository, IProviderPlanRepository providerPlanRepository,
IProviderBillingService providerBillingService, IProviderBillingService providerBillingService,
IWebHostEnvironment webHostEnvironment) IWebHostEnvironment webHostEnvironment,
IPricingClient pricingClient)
{ {
_organizationRepository = organizationRepository; _organizationRepository = organizationRepository;
_organizationService = organizationService; _organizationService = organizationService;
@ -75,6 +78,7 @@ public class ProvidersController : Controller
_featureService = featureService; _featureService = featureService;
_providerPlanRepository = providerPlanRepository; _providerPlanRepository = providerPlanRepository;
_providerBillingService = providerBillingService; _providerBillingService = providerBillingService;
_pricingClient = pricingClient;
_stripeUrl = webHostEnvironment.GetStripeUrl(); _stripeUrl = webHostEnvironment.GetStripeUrl();
_braintreeMerchantUrl = webHostEnvironment.GetBraintreeMerchantUrl(); _braintreeMerchantUrl = webHostEnvironment.GetBraintreeMerchantUrl();
_braintreeMerchantId = globalSettings.Braintree.MerchantId; _braintreeMerchantId = globalSettings.Braintree.MerchantId;
@ -107,9 +111,15 @@ public class ProvidersController : Controller
}); });
} }
public IActionResult Create(int teamsMinimumSeats, int enterpriseMinimumSeats, string ownerEmail = null) public IActionResult Create()
{ {
return View(new CreateProviderModel return View(new CreateProviderModel());
}
[HttpGet("providers/create/msp")]
public IActionResult CreateMsp(int teamsMinimumSeats, int enterpriseMinimumSeats, string ownerEmail = null)
{
return View(new CreateMspProviderModel
{ {
OwnerEmail = ownerEmail, OwnerEmail = ownerEmail,
TeamsMonthlySeatMinimum = teamsMinimumSeats, TeamsMonthlySeatMinimum = teamsMinimumSeats,
@ -117,10 +127,45 @@ public class ProvidersController : Controller
}); });
} }
[HttpGet("providers/create/reseller")]
public IActionResult CreateReseller()
{
return View(new CreateResellerProviderModel());
}
[HttpGet("providers/create/multi-organization-enterprise")]
public IActionResult CreateMultiOrganizationEnterprise(int enterpriseMinimumSeats, string ownerEmail = null)
{
return View(new CreateMultiOrganizationEnterpriseProviderModel
{
OwnerEmail = ownerEmail,
EnterpriseSeatMinimum = enterpriseMinimumSeats
});
}
[HttpPost] [HttpPost]
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
[RequirePermission(Permission.Provider_Create)] [RequirePermission(Permission.Provider_Create)]
public async Task<IActionResult> Create(CreateProviderModel model) public IActionResult Create(CreateProviderModel model)
{
if (!ModelState.IsValid)
{
return View(model);
}
return model.Type switch
{
ProviderType.Msp => RedirectToAction("CreateMsp"),
ProviderType.Reseller => RedirectToAction("CreateReseller"),
ProviderType.MultiOrganizationEnterprise => RedirectToAction("CreateMultiOrganizationEnterprise"),
_ => View(model)
};
}
[HttpPost("providers/create/msp")]
[ValidateAntiForgeryToken]
[RequirePermission(Permission.Provider_Create)]
public async Task<IActionResult> CreateMsp(CreateMspProviderModel model)
{ {
if (!ModelState.IsValid) if (!ModelState.IsValid)
{ {
@ -128,19 +173,47 @@ public class ProvidersController : Controller
} }
var provider = model.ToProvider(); var provider = model.ToProvider();
switch (provider.Type)
await _createProviderCommand.CreateMspAsync(
provider,
model.OwnerEmail,
model.TeamsMonthlySeatMinimum,
model.EnterpriseMonthlySeatMinimum);
return RedirectToAction("Edit", new { id = provider.Id });
}
[HttpPost("providers/create/reseller")]
[ValidateAntiForgeryToken]
[RequirePermission(Permission.Provider_Create)]
public async Task<IActionResult> CreateReseller(CreateResellerProviderModel model)
{
if (!ModelState.IsValid)
{ {
case ProviderType.Msp: return View(model);
await _createProviderCommand.CreateMspAsync(
provider,
model.OwnerEmail,
model.TeamsMonthlySeatMinimum,
model.EnterpriseMonthlySeatMinimum);
break;
case ProviderType.Reseller:
await _createProviderCommand.CreateResellerAsync(provider);
break;
} }
var provider = model.ToProvider();
await _createProviderCommand.CreateResellerAsync(provider);
return RedirectToAction("Edit", new { id = provider.Id });
}
[HttpPost("providers/create/multi-organization-enterprise")]
[ValidateAntiForgeryToken]
[RequirePermission(Permission.Provider_Create)]
public async Task<IActionResult> CreateMultiOrganizationEnterprise(CreateMultiOrganizationEnterpriseProviderModel model)
{
if (!ModelState.IsValid)
{
return View(model);
}
var provider = model.ToProvider();
await _createProviderCommand.CreateMultiOrganizationEnterpriseAsync(
provider,
model.OwnerEmail,
model.Plan.Value,
model.EnterpriseSeatMinimum);
return RedirectToAction("Edit", new { id = provider.Id }); return RedirectToAction("Edit", new { id = provider.Id });
} }
@ -156,7 +229,8 @@ public class ProvidersController : Controller
var users = await _providerUserRepository.GetManyDetailsByProviderAsync(id); var users = await _providerUserRepository.GetManyDetailsByProviderAsync(id);
var providerOrganizations = await _providerOrganizationRepository.GetManyDetailsByProviderAsync(id); var providerOrganizations = await _providerOrganizationRepository.GetManyDetailsByProviderAsync(id);
return View(new ProviderViewModel(provider, users, providerOrganizations)); var providerPlans = await _providerPlanRepository.GetByProviderId(id);
return View(new ProviderViewModel(provider, users, providerOrganizations, providerPlans.ToList()));
} }
[SelfHosted(NotSelfHostedOnly = true)] [SelfHosted(NotSelfHostedOnly = true)]
@ -171,6 +245,18 @@ public class ProvidersController : Controller
return View(provider); return View(provider);
} }
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<IActionResult> Cancel(Guid id)
{
var provider = await GetEditModel(id);
if (provider == null)
{
return RedirectToAction("Index");
}
return RedirectToAction("Edit", new { id });
}
[HttpPost] [HttpPost]
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
[SelfHosted(NotSelfHostedOnly = true)] [SelfHosted(NotSelfHostedOnly = true)]
@ -203,34 +289,44 @@ public class ProvidersController : Controller
await _providerRepository.ReplaceAsync(provider); await _providerRepository.ReplaceAsync(provider);
await _applicationCacheService.UpsertProviderAbilityAsync(provider); await _applicationCacheService.UpsertProviderAbilityAsync(provider);
var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling); if (!provider.IsBillable())
if (!isConsolidatedBillingEnabled || !provider.IsBillable())
{ {
return RedirectToAction("Edit", new { id }); return RedirectToAction("Edit", new { id });
} }
var providerPlans = await _providerPlanRepository.GetByProviderId(id); var providerPlans = await _providerPlanRepository.GetByProviderId(id);
if (providerPlans.Count == 0) switch (provider.Type)
{ {
var newProviderPlans = new List<ProviderPlan> case ProviderType.Msp:
{ var updateMspSeatMinimumsCommand = new UpdateProviderSeatMinimumsCommand(
new () { ProviderId = provider.Id, PlanType = PlanType.TeamsMonthly, SeatMinimum = model.TeamsMonthlySeatMinimum, PurchasedSeats = 0, AllocatedSeats = 0 }, provider,
new () { ProviderId = provider.Id, PlanType = PlanType.EnterpriseMonthly, SeatMinimum = model.EnterpriseMonthlySeatMinimum, PurchasedSeats = 0, AllocatedSeats = 0 } [
}; (Plan: PlanType.TeamsMonthly, SeatsMinimum: model.TeamsMonthlySeatMinimum),
(Plan: PlanType.EnterpriseMonthly, SeatsMinimum: model.EnterpriseMonthlySeatMinimum)
]);
await _providerBillingService.UpdateSeatMinimums(updateMspSeatMinimumsCommand);
break;
case ProviderType.MultiOrganizationEnterprise:
{
var existingMoePlan = providerPlans.Single();
foreach (var newProviderPlan in newProviderPlans) // 1. Change the plan and take over any old values.
{ var changeMoePlanCommand = new ChangeProviderPlanCommand(
await _providerPlanRepository.CreateAsync(newProviderPlan); provider,
} existingMoePlan.Id,
} model.Plan!.Value);
else await _providerBillingService.ChangePlan(changeMoePlanCommand);
{
await _providerBillingService.UpdateSeatMinimums( // 2. Update the seat minimums.
provider, var updateMoeSeatMinimumsCommand = new UpdateProviderSeatMinimumsCommand(
model.EnterpriseMonthlySeatMinimum, provider,
model.TeamsMonthlySeatMinimum); [
(Plan: model.Plan!.Value, SeatsMinimum: model.EnterpriseMinimumSeats!.Value)
]);
await _providerBillingService.UpdateSeatMinimums(updateMoeSeatMinimumsCommand);
break;
}
} }
return RedirectToAction("Edit", new { id }); return RedirectToAction("Edit", new { id });
@ -247,10 +343,7 @@ public class ProvidersController : Controller
var users = await _providerUserRepository.GetManyDetailsByProviderAsync(id); var users = await _providerUserRepository.GetManyDetailsByProviderAsync(id);
var providerOrganizations = await _providerOrganizationRepository.GetManyDetailsByProviderAsync(id); var providerOrganizations = await _providerOrganizationRepository.GetManyDetailsByProviderAsync(id);
var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling); if (!provider.IsBillable())
if (!isConsolidatedBillingEnabled || !provider.IsBillable())
{ {
return new ProviderEditModel(provider, users, providerOrganizations, new List<ProviderPlan>()); return new ProviderEditModel(provider, users, providerOrganizations, new List<ProviderPlan>());
} }
@ -324,7 +417,9 @@ public class ProvidersController : Controller
return RedirectToAction("Index"); return RedirectToAction("Index");
} }
return View(new OrganizationEditModel(provider)); var plans = await _pricingClient.ListPlans();
return View(new OrganizationEditModel(provider, plans));
} }
[HttpPost] [HttpPost]

View File

@ -0,0 +1,49 @@
using System.ComponentModel.DataAnnotations;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.SharedWeb.Utilities;
namespace Bit.Admin.AdminConsole.Models;
public class CreateMspProviderModel : IValidatableObject
{
[Display(Name = "Owner Email")]
public string OwnerEmail { get; set; }
[Display(Name = "Subscription Discount")]
public string DiscountId { get; set; }
[Display(Name = "Teams (Monthly) Seat Minimum")]
public int TeamsMonthlySeatMinimum { get; set; }
[Display(Name = "Enterprise (Monthly) Seat Minimum")]
public int EnterpriseMonthlySeatMinimum { get; set; }
public virtual Provider ToProvider()
{
return new Provider
{
Type = ProviderType.Msp,
DiscountId = DiscountId
};
}
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (string.IsNullOrWhiteSpace(OwnerEmail))
{
var ownerEmailDisplayName = nameof(OwnerEmail).GetDisplayAttribute<CreateMspProviderModel>()?.GetName() ?? nameof(OwnerEmail);
yield return new ValidationResult($"The {ownerEmailDisplayName} field is required.");
}
if (TeamsMonthlySeatMinimum < 0)
{
var teamsMinimumSeatsDisplayName = nameof(TeamsMonthlySeatMinimum).GetDisplayAttribute<CreateMspProviderModel>()?.GetName() ?? nameof(TeamsMonthlySeatMinimum);
yield return new ValidationResult($"The {teamsMinimumSeatsDisplayName} field can not be negative.");
}
if (EnterpriseMonthlySeatMinimum < 0)
{
var enterpriseMinimumSeatsDisplayName = nameof(EnterpriseMonthlySeatMinimum).GetDisplayAttribute<CreateMspProviderModel>()?.GetName() ?? nameof(EnterpriseMonthlySeatMinimum);
yield return new ValidationResult($"The {enterpriseMinimumSeatsDisplayName} field can not be negative.");
}
}
}

View File

@ -0,0 +1,47 @@
using System.ComponentModel.DataAnnotations;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.Billing.Enums;
using Bit.SharedWeb.Utilities;
namespace Bit.Admin.AdminConsole.Models;
public class CreateMultiOrganizationEnterpriseProviderModel : IValidatableObject
{
[Display(Name = "Owner Email")]
public string OwnerEmail { get; set; }
[Display(Name = "Enterprise Seat Minimum")]
public int EnterpriseSeatMinimum { get; set; }
[Display(Name = "Plan")]
[Required]
public PlanType? Plan { get; set; }
public virtual Provider ToProvider()
{
return new Provider
{
Type = ProviderType.MultiOrganizationEnterprise
};
}
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (string.IsNullOrWhiteSpace(OwnerEmail))
{
var ownerEmailDisplayName = nameof(OwnerEmail).GetDisplayAttribute<CreateMultiOrganizationEnterpriseProviderModel>()?.GetName() ?? nameof(OwnerEmail);
yield return new ValidationResult($"The {ownerEmailDisplayName} field is required.");
}
if (EnterpriseSeatMinimum < 0)
{
var enterpriseSeatMinimumDisplayName = nameof(EnterpriseSeatMinimum).GetDisplayAttribute<CreateMultiOrganizationEnterpriseProviderModel>()?.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);
yield return new ValidationResult($"The {planDisplayName} field must be set to Enterprise Annually or Enterprise Monthly.");
}
}
}

View File

@ -1,84 +1,8 @@
using System.ComponentModel.DataAnnotations; using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.SharedWeb.Utilities;
namespace Bit.Admin.AdminConsole.Models; namespace Bit.Admin.AdminConsole.Models;
public class CreateProviderModel : IValidatableObject public class CreateProviderModel
{ {
public CreateProviderModel() { }
[Display(Name = "Provider Type")]
public ProviderType Type { get; set; } public ProviderType Type { get; set; }
[Display(Name = "Owner Email")]
public string OwnerEmail { get; set; }
[Display(Name = "Name")]
public string Name { get; set; }
[Display(Name = "Business Name")]
public string BusinessName { get; set; }
[Display(Name = "Primary Billing Email")]
public string BillingEmail { get; set; }
[Display(Name = "Teams (Monthly) Seat Minimum")]
public int TeamsMonthlySeatMinimum { get; set; }
[Display(Name = "Enterprise (Monthly) Seat Minimum")]
public int EnterpriseMonthlySeatMinimum { get; set; }
public virtual Provider ToProvider()
{
return new Provider()
{
Type = Type,
Name = Name,
BusinessName = BusinessName,
BillingEmail = BillingEmail?.ToLowerInvariant().Trim()
};
}
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
switch (Type)
{
case ProviderType.Msp:
if (string.IsNullOrWhiteSpace(OwnerEmail))
{
var ownerEmailDisplayName = nameof(OwnerEmail).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(OwnerEmail);
yield return new ValidationResult($"The {ownerEmailDisplayName} field is required.");
}
if (TeamsMonthlySeatMinimum < 0)
{
var teamsMinimumSeatsDisplayName = nameof(TeamsMonthlySeatMinimum).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(TeamsMonthlySeatMinimum);
yield return new ValidationResult($"The {teamsMinimumSeatsDisplayName} field can not be negative.");
}
if (EnterpriseMonthlySeatMinimum < 0)
{
var enterpriseMinimumSeatsDisplayName = nameof(EnterpriseMonthlySeatMinimum).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(EnterpriseMonthlySeatMinimum);
yield return new ValidationResult($"The {enterpriseMinimumSeatsDisplayName} field can not be negative.");
}
break;
case ProviderType.Reseller:
if (string.IsNullOrWhiteSpace(Name))
{
var nameDisplayName = nameof(Name).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(Name);
yield return new ValidationResult($"The {nameDisplayName} field is required.");
}
if (string.IsNullOrWhiteSpace(BusinessName))
{
var businessNameDisplayName = nameof(BusinessName).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(BusinessName);
yield return new ValidationResult($"The {businessNameDisplayName} field is required.");
}
if (string.IsNullOrWhiteSpace(BillingEmail))
{
var billingEmailDisplayName = nameof(BillingEmail).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(BillingEmail);
yield return new ValidationResult($"The {billingEmailDisplayName} field is required.");
}
break;
}
}
} }

View File

@ -0,0 +1,48 @@
using System.ComponentModel.DataAnnotations;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.SharedWeb.Utilities;
namespace Bit.Admin.AdminConsole.Models;
public class CreateResellerProviderModel : IValidatableObject
{
[Display(Name = "Name")]
public string Name { get; set; }
[Display(Name = "Business Name")]
public string BusinessName { get; set; }
[Display(Name = "Primary Billing Email")]
public string BillingEmail { get; set; }
public virtual Provider ToProvider()
{
return new Provider
{
Name = Name,
BusinessName = BusinessName,
BillingEmail = BillingEmail?.ToLowerInvariant().Trim(),
Type = ProviderType.Reseller
};
}
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (string.IsNullOrWhiteSpace(Name))
{
var nameDisplayName = nameof(Name).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(Name);
yield return new ValidationResult($"The {nameDisplayName} field is required.");
}
if (string.IsNullOrWhiteSpace(BusinessName))
{
var businessNameDisplayName = nameof(BusinessName).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(BusinessName);
yield return new ValidationResult($"The {businessNameDisplayName} field is required.");
}
if (string.IsNullOrWhiteSpace(BillingEmail))
{
var billingEmailDisplayName = nameof(BillingEmail).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(BillingEmail);
yield return new ValidationResult($"The {billingEmailDisplayName} field is required.");
}
}
}

View File

@ -8,6 +8,7 @@ using Bit.Core.Billing.Models;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Models.StaticStore;
using Bit.Core.Settings; using Bit.Core.Settings;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Bit.Core.Vault.Entities; using Bit.Core.Vault.Entities;
@ -17,15 +18,18 @@ namespace Bit.Admin.AdminConsole.Models;
public class OrganizationEditModel : OrganizationViewModel public class OrganizationEditModel : OrganizationViewModel
{ {
private readonly List<Plan> _plans;
public OrganizationEditModel() { } public OrganizationEditModel() { }
public OrganizationEditModel(Provider provider) public OrganizationEditModel(Provider provider, List<Plan> plans)
{ {
Provider = provider; Provider = provider;
BillingEmail = provider.Type == ProviderType.Reseller ? provider.BillingEmail : string.Empty; BillingEmail = provider.Type == ProviderType.Reseller ? provider.BillingEmail : string.Empty;
PlanType = Core.Billing.Enums.PlanType.TeamsMonthly; PlanType = Core.Billing.Enums.PlanType.TeamsMonthly;
Plan = Core.Billing.Enums.PlanType.TeamsMonthly.GetDisplayAttribute()?.GetName(); Plan = Core.Billing.Enums.PlanType.TeamsMonthly.GetDisplayAttribute()?.GetName();
LicenseKey = RandomLicenseKey; LicenseKey = RandomLicenseKey;
_plans = plans;
} }
public OrganizationEditModel( public OrganizationEditModel(
@ -40,6 +44,7 @@ public class OrganizationEditModel : OrganizationViewModel
BillingHistoryInfo billingHistoryInfo, BillingHistoryInfo billingHistoryInfo,
IEnumerable<OrganizationConnection> connections, IEnumerable<OrganizationConnection> connections,
GlobalSettings globalSettings, GlobalSettings globalSettings,
List<Plan> plans,
int secrets, int secrets,
int projects, int projects,
int serviceAccounts, int serviceAccounts,
@ -80,6 +85,7 @@ public class OrganizationEditModel : OrganizationViewModel
Use2fa = org.Use2fa; Use2fa = org.Use2fa;
UseApi = org.UseApi; UseApi = org.UseApi;
UseSecretsManager = org.UseSecretsManager; UseSecretsManager = org.UseSecretsManager;
UseRiskInsights = org.UseRiskInsights;
UseResetPassword = org.UseResetPassword; UseResetPassword = org.UseResetPassword;
SelfHost = org.SelfHost; SelfHost = org.SelfHost;
UsersGetPremium = org.UsersGetPremium; UsersGetPremium = org.UsersGetPremium;
@ -95,6 +101,8 @@ public class OrganizationEditModel : OrganizationViewModel
MaxAutoscaleSmSeats = org.MaxAutoscaleSmSeats; MaxAutoscaleSmSeats = org.MaxAutoscaleSmSeats;
SmServiceAccounts = org.SmServiceAccounts; SmServiceAccounts = org.SmServiceAccounts;
MaxAutoscaleSmServiceAccounts = org.MaxAutoscaleSmServiceAccounts; MaxAutoscaleSmServiceAccounts = org.MaxAutoscaleSmServiceAccounts;
_plans = plans;
} }
public BillingInfo BillingInfo { get; set; } public BillingInfo BillingInfo { get; set; }
@ -143,7 +151,9 @@ public class OrganizationEditModel : OrganizationViewModel
[Display(Name = "SCIM")] [Display(Name = "SCIM")]
public bool UseScim { get; set; } public bool UseScim { get; set; }
[Display(Name = "Secrets Manager")] [Display(Name = "Secrets Manager")]
public bool UseSecretsManager { get; set; } public new bool UseSecretsManager { get; set; }
[Display(Name = "Risk Insights")]
public new bool UseRiskInsights { get; set; }
[Display(Name = "Self Host")] [Display(Name = "Self Host")]
public bool SelfHost { get; set; } public bool SelfHost { get; set; }
[Display(Name = "Users Get Premium")] [Display(Name = "Users Get Premium")]
@ -180,8 +190,7 @@ public class OrganizationEditModel : OrganizationViewModel
* Add mappings for individual properties as you need them * Add mappings for individual properties as you need them
*/ */
public object GetPlansHelper() => public object GetPlansHelper() =>
StaticStore.Plans _plans
.Where(p => p.SupportsSecretsManager)
.Select(p => .Select(p =>
{ {
var plan = new var plan = new
@ -285,6 +294,7 @@ public class OrganizationEditModel : OrganizationViewModel
existingOrganization.Use2fa = Use2fa; existingOrganization.Use2fa = Use2fa;
existingOrganization.UseApi = UseApi; existingOrganization.UseApi = UseApi;
existingOrganization.UseSecretsManager = UseSecretsManager; existingOrganization.UseSecretsManager = UseSecretsManager;
existingOrganization.UseRiskInsights = UseRiskInsights;
existingOrganization.UseResetPassword = UseResetPassword; existingOrganization.UseResetPassword = UseResetPassword;
existingOrganization.SelfHost = SelfHost; existingOrganization.SelfHost = SelfHost;
existingOrganization.UsersGetPremium = UsersGetPremium; existingOrganization.UsersGetPremium = UsersGetPremium;

View File

@ -69,4 +69,5 @@ public class OrganizationViewModel
public int ServiceAccountsCount { get; set; } public int ServiceAccountsCount { get; set; }
public int OccupiedSmSeatsCount { get; set; } public int OccupiedSmSeatsCount { get; set; }
public bool UseSecretsManager => Organization.UseSecretsManager; public bool UseSecretsManager => Organization.UseSecretsManager;
public bool UseRiskInsights => Organization.UseRiskInsights;
} }

View File

@ -10,4 +10,6 @@ public class OrganizationsModel : PagedModel<Organization>
public bool? Paid { get; set; } public bool? Paid { get; set; }
public string Action { get; set; } public string Action { get; set; }
public bool SelfHosted { get; set; } public bool SelfHosted { get; set; }
public double StorageGB(Organization org) => org.Storage.HasValue ? Math.Round(org.Storage.Value / 1073741824D, 2) : 0;
} }

View File

@ -19,7 +19,7 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject
IEnumerable<ProviderOrganizationOrganizationDetails> organizations, IEnumerable<ProviderOrganizationOrganizationDetails> organizations,
IReadOnlyCollection<ProviderPlan> providerPlans, IReadOnlyCollection<ProviderPlan> providerPlans,
string gatewayCustomerUrl = null, string gatewayCustomerUrl = null,
string gatewaySubscriptionUrl = null) : base(provider, providerUsers, organizations) string gatewaySubscriptionUrl = null) : base(provider, providerUsers, organizations, providerPlans)
{ {
Name = provider.DisplayName(); Name = provider.DisplayName();
BusinessName = provider.DisplayBusinessName(); BusinessName = provider.DisplayBusinessName();
@ -33,6 +33,13 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject
GatewayCustomerUrl = gatewayCustomerUrl; GatewayCustomerUrl = gatewayCustomerUrl;
GatewaySubscriptionUrl = gatewaySubscriptionUrl; GatewaySubscriptionUrl = gatewaySubscriptionUrl;
Type = provider.Type; Type = provider.Type;
if (Type == ProviderType.MultiOrganizationEnterprise)
{
var plan = providerPlans.SingleOrDefault();
EnterpriseMinimumSeats = plan?.SeatMinimum ?? 0;
Plan = plan?.PlanType;
}
} }
[Display(Name = "Billing Email")] [Display(Name = "Billing Email")]
@ -58,13 +65,24 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject
[Display(Name = "Provider Type")] [Display(Name = "Provider Type")]
public ProviderType Type { get; set; } public ProviderType Type { get; set; }
[Display(Name = "Plan")]
public PlanType? Plan { get; set; }
[Display(Name = "Enterprise Seats Minimum")]
public int? EnterpriseMinimumSeats { get; set; }
public virtual Provider ToProvider(Provider existingProvider) public virtual Provider ToProvider(Provider existingProvider)
{ {
existingProvider.BillingEmail = BillingEmail?.ToLowerInvariant().Trim(); existingProvider.BillingEmail = BillingEmail?.ToLowerInvariant().Trim();
existingProvider.BillingPhone = BillingPhone?.ToLowerInvariant().Trim(); existingProvider.BillingPhone = BillingPhone?.ToLowerInvariant().Trim();
existingProvider.Gateway = Gateway; switch (Type)
existingProvider.GatewayCustomerId = GatewayCustomerId; {
existingProvider.GatewaySubscriptionId = GatewaySubscriptionId; case ProviderType.Msp:
existingProvider.Gateway = Gateway;
existingProvider.GatewayCustomerId = GatewayCustomerId;
existingProvider.GatewaySubscriptionId = GatewaySubscriptionId;
break;
}
return existingProvider; return existingProvider;
} }
@ -82,6 +100,23 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject
yield return new ValidationResult($"The {billingEmailDisplayName} field is required."); yield return new ValidationResult($"The {billingEmailDisplayName} field is required.");
} }
break; break;
case ProviderType.MultiOrganizationEnterprise:
if (Plan == null)
{
var displayName = nameof(Plan).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(Plan);
yield return new ValidationResult($"The {displayName} field is required.");
}
if (EnterpriseMinimumSeats == null)
{
var displayName = nameof(EnterpriseMinimumSeats).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(EnterpriseMinimumSeats);
yield return new ValidationResult($"The {displayName} field is required.");
}
if (EnterpriseMinimumSeats < 0)
{
var displayName = nameof(EnterpriseMinimumSeats).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(EnterpriseMinimumSeats);
yield return new ValidationResult($"The {displayName} field cannot be less than 0.");
}
break;
} }
} }
} }

View File

@ -1,6 +1,9 @@
using Bit.Core.AdminConsole.Entities.Provider; using Bit.Admin.Billing.Models;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Models.Data.Provider; using Bit.Core.AdminConsole.Models.Data.Provider;
using Bit.Core.Billing.Entities;
using Bit.Core.Billing.Enums;
namespace Bit.Admin.AdminConsole.Models; namespace Bit.Admin.AdminConsole.Models;
@ -8,17 +11,57 @@ public class ProviderViewModel
{ {
public ProviderViewModel() { } public ProviderViewModel() { }
public ProviderViewModel(Provider provider, IEnumerable<ProviderUserUserDetails> providerUsers, IEnumerable<ProviderOrganizationOrganizationDetails> organizations) public ProviderViewModel(
Provider provider,
IEnumerable<ProviderUserUserDetails> providerUsers,
IEnumerable<ProviderOrganizationOrganizationDetails> organizations,
IReadOnlyCollection<ProviderPlan> providerPlans)
{ {
Provider = provider; Provider = provider;
UserCount = providerUsers.Count(); UserCount = providerUsers.Count();
ProviderAdmins = providerUsers.Where(u => u.Type == ProviderUserType.ProviderAdmin); ProviderAdmins = providerUsers.Where(u => u.Type == ProviderUserType.ProviderAdmin);
ProviderOrganizations = organizations.Where(o => o.ProviderId == provider.Id); ProviderOrganizations = organizations.Where(o => o.ProviderId == provider.Id);
if (Provider.Type == ProviderType.Msp)
{
var usedTeamsSeats = ProviderOrganizations.Where(po => po.PlanType == PlanType.TeamsMonthly)
.Sum(po => po.OccupiedSeats) ?? 0;
var teamsProviderPlan = providerPlans.FirstOrDefault(plan => plan.PlanType == PlanType.TeamsMonthly);
if (teamsProviderPlan != null && teamsProviderPlan.IsConfigured())
{
ProviderPlanViewModels.Add(new ProviderPlanViewModel("Teams (Monthly) Subscription", teamsProviderPlan, usedTeamsSeats));
}
var usedEnterpriseSeats = ProviderOrganizations.Where(po => po.PlanType == PlanType.EnterpriseMonthly)
.Sum(po => po.OccupiedSeats) ?? 0;
var enterpriseProviderPlan = providerPlans.FirstOrDefault(plan => plan.PlanType == PlanType.EnterpriseMonthly);
if (enterpriseProviderPlan != null && enterpriseProviderPlan.IsConfigured())
{
ProviderPlanViewModels.Add(new ProviderPlanViewModel("Enterprise (Monthly) Subscription", enterpriseProviderPlan, usedEnterpriseSeats));
}
}
else if (Provider.Type == ProviderType.MultiOrganizationEnterprise)
{
var usedEnterpriseSeats = ProviderOrganizations.Where(po => po.PlanType == PlanType.EnterpriseMonthly)
.Sum(po => po.OccupiedSeats).GetValueOrDefault(0);
var enterpriseProviderPlan = providerPlans.FirstOrDefault();
if (enterpriseProviderPlan != null && enterpriseProviderPlan.IsConfigured())
{
var planLabel = enterpriseProviderPlan.PlanType switch
{
PlanType.EnterpriseMonthly => "Enterprise (Monthly) Subscription",
PlanType.EnterpriseAnnually => "Enterprise (Annually) Subscription",
_ => string.Empty
};
ProviderPlanViewModels.Add(new ProviderPlanViewModel(planLabel, enterpriseProviderPlan, usedEnterpriseSeats));
}
}
} }
public int UserCount { get; set; } public int UserCount { get; set; }
public Provider Provider { get; set; } public Provider Provider { get; set; }
public IEnumerable<ProviderUserUserDetails> ProviderAdmins { get; set; } public IEnumerable<ProviderUserUserDetails> ProviderAdmins { get; set; }
public IEnumerable<ProviderOrganizationOrganizationDetails> ProviderOrganizations { get; set; } public IEnumerable<ProviderOrganizationOrganizationDetails> ProviderOrganizations { get; set; }
public List<ProviderPlanViewModel> ProviderPlanViewModels { get; set; } = [];
} }

View File

@ -10,6 +10,7 @@
var canViewOrganizationInformation = AccessControlService.UserHasPermission(Permission.Org_OrgInformation_View); var canViewOrganizationInformation = AccessControlService.UserHasPermission(Permission.Org_OrgInformation_View);
var canViewBillingInformation = AccessControlService.UserHasPermission(Permission.Org_BillingInformation_View); var canViewBillingInformation = AccessControlService.UserHasPermission(Permission.Org_BillingInformation_View);
var canInitiateTrial = AccessControlService.UserHasPermission(Permission.Org_InitiateTrial); var canInitiateTrial = AccessControlService.UserHasPermission(Permission.Org_InitiateTrial);
var canRequestDelete = AccessControlService.UserHasPermission(Permission.Org_RequestDelete);
var canDelete = AccessControlService.UserHasPermission(Permission.Org_Delete); var canDelete = AccessControlService.UserHasPermission(Permission.Org_Delete);
var canUnlinkFromProvider = AccessControlService.UserHasPermission(Permission.Provider_Edit); var canUnlinkFromProvider = AccessControlService.UserHasPermission(Permission.Provider_Edit);
} }
@ -103,29 +104,32 @@
<div class="d-flex mt-4"> <div class="d-flex mt-4">
<button type="submit" class="btn btn-primary" form="edit-form">Save</button> <button type="submit" class="btn btn-primary" form="edit-form">Save</button>
<div class="ml-auto d-flex"> <div class="ms-auto d-flex">
@if (canInitiateTrial && Model.Provider is null) @if (canInitiateTrial && Model.Provider is null)
{ {
<button class="btn btn-secondary mr-2" type="button" id="teams-trial"> <button class="btn btn-secondary me-2" type="button" id="teams-trial">
Teams Trial Teams Trial
</button> </button>
<button class="btn btn-secondary mr-2" type="button" id="enterprise-trial"> <button class="btn btn-secondary me-2" type="button" id="enterprise-trial">
Enterprise Trial Enterprise Trial
</button> </button>
} }
@if (canUnlinkFromProvider && Model.Provider is not null) @if (canUnlinkFromProvider && Model.Provider is not null)
{ {
<button class="btn btn-outline-danger mr-2" <button class="btn btn-outline-danger me-2"
onclick="return unlinkProvider('@Model.Organization.Id');"> onclick="return unlinkProvider('@Model.Organization.Id');">
Unlink provider Unlink provider
</button> </button>
} }
@if (canDelete) @if (canRequestDelete)
{ {
<form asp-action="DeleteInitiation" asp-route-id="@Model.Organization.Id" id="initiate-delete-form"> <form asp-action="DeleteInitiation" asp-route-id="@Model.Organization.Id" id="initiate-delete-form">
<input type="hidden" name="AdminEmail" id="AdminEmail" /> <input type="hidden" name="AdminEmail" id="AdminEmail" />
<button class="btn btn-danger mr-2" type="submit">Request Delete</button> <button class="btn btn-danger me-2" type="submit">Request Delete</button>
</form> </form>
}
@if (canDelete)
{
<form asp-action="Delete" asp-route-id="@Model.Organization.Id" <form asp-action="Delete" asp-route-id="@Model.Organization.Id"
onsubmit="return confirm('Are you sure you want to hard delete this organization?')"> onsubmit="return confirm('Are you sure you want to hard delete this organization?')">
<button class="btn btn-outline-danger" type="submit">Delete</button> <button class="btn btn-outline-danger" type="submit">Delete</button>

View File

@ -5,21 +5,31 @@
<h1>Organizations</h1> <h1>Organizations</h1>
<form class="form-inline mb-2" method="get"> <form class="row row-cols-lg-auto g-3 align-items-center mb-2" method="get">
<label class="sr-only" asp-for="Name">Name</label> <div class="col-12">
<input type="text" class="form-control mb-2 mr-2" placeholder="Name" asp-for="Name" name="name"> <label class="visually-hidden" asp-for="Name">Name</label>
<label class="sr-only" asp-for="UserEmail">User email</label> <input type="text" class="form-control" placeholder="Name" asp-for="Name" name="name">
<input type="text" class="form-control mb-2 mr-2" placeholder="User email" asp-for="UserEmail" name="userEmail"> </div>
<div class="col-12">
<label class="visually-hidden" asp-for="UserEmail">User email</label>
<input type="text" class="form-control" placeholder="User email" asp-for="UserEmail" name="userEmail">
</div>
@if(!Model.SelfHosted) @if(!Model.SelfHosted)
{ {
<label class="sr-only" asp-for="Paid">Customer</label> <div class="col-12">
<select class="form-control mb-2 mr-2" asp-for="Paid" name="paid"> <label class="visually-hidden" asp-for="Paid">Customer</label>
<option asp-selected="!Model.Paid.HasValue" value="">-- Customer --</option> <select class="form-select" asp-for="Paid" name="paid">
<option asp-selected="Model.Paid.GetValueOrDefault(false)" value="true">Paid</option> <option asp-selected="!Model.Paid.HasValue" value="">-- Customer --</option>
<option asp-selected="!Model.Paid.GetValueOrDefault(true)" value="false">Freeloader</option> <option asp-selected="Model.Paid.GetValueOrDefault(false)" value="true">Paid</option>
</select> <option asp-selected="!Model.Paid.GetValueOrDefault(true)" value="false">Freeloader</option>
</select>
</div>
} }
<button type="submit" class="btn btn-primary mb-2" title="Search"><i class="fa fa-search"></i> Search</button> <div class="col-12">
<button type="submit" class="btn btn-primary" title="Search">
<i class="fa fa-search"></i> Search
</button>
</div>
</form> </form>
<div class="table-responsive"> <div class="table-responsive">
@ -68,19 +78,10 @@
} }
else else
{ {
<i class="fa fa-smile-o fa-lg fa-fw text-muted" title="Freeloader"></i> <i class="fa fa-smile-o fa-lg fa-fw text-body-secondary" title="Freeloader"></i>
} }
} }
@if(org.MaxStorageGb.HasValue && org.MaxStorageGb > 1) <i class="fa fa-hdd-o fa-lg fa-fw" title="Used Storage, @Model.StorageGB(org) GB"></i>
{
<i class="fa fa-plus-square fa-lg fa-fw"
title="Additional Storage, @(org.MaxStorageGb - 1) GB"></i>
}
else
{
<i class="fa fa-plus-square-o fa-lg fa-fw text-muted"
title="No Additional Storage"></i>
}
@if(org.Enabled) @if(org.Enabled)
{ {
<i class="fa fa-check-circle fa-lg fa-fw" <i class="fa fa-check-circle fa-lg fa-fw"
@ -88,7 +89,7 @@
} }
else else
{ {
<i class="fa fa-times-circle-o fa-lg fa-fw text-muted" title="Disabled"></i> <i class="fa fa-times-circle-o fa-lg fa-fw text-body-secondary" title="Disabled"></i>
} }
@if(org.TwoFactorIsEnabled()) @if(org.TwoFactorIsEnabled())
{ {
@ -96,7 +97,7 @@
} }
else else
{ {
<i class="fa fa-unlock fa-lg fa-fw text-muted" title="2FA Not Enabled"></i> <i class="fa fa-unlock fa-lg fa-fw text-body-secondary" title="2FA Not Enabled"></i>
} }
</td> </td>
</tr> </tr>

View File

@ -1,4 +1,6 @@
@model OrganizationViewModel @inject Bit.Core.Services.IFeatureService FeatureService
@model OrganizationViewModel
<dl class="row"> <dl class="row">
<dt class="col-sm-4 col-lg-3">Id</dt> <dt class="col-sm-4 col-lg-3">Id</dt>
<dd id="org-id" class="col-sm-8 col-lg-9"><code>@Model.Organization.Id</code></dd> <dd id="org-id" class="col-sm-8 col-lg-9"><code>@Model.Organization.Id</code></dd>
@ -54,7 +56,10 @@
<dd id="pm-manage-collections" class="col-sm-8 col-lg-9">@(Model.Organization.AllowAdminAccessToAllCollectionItems ? "On" : "Off")</dd> <dd id="pm-manage-collections" class="col-sm-8 col-lg-9">@(Model.Organization.AllowAdminAccessToAllCollectionItems ? "On" : "Off")</dd>
<dt class="col-sm-4 col-lg-3">Limit collection creation to administrators</dt> <dt class="col-sm-4 col-lg-3">Limit collection creation to administrators</dt>
<dd id="pm-collection-creation" class="col-sm-8 col-lg-9">@(Model.Organization.LimitCollectionCreationDeletion ? "On" : "Off")</dd> <dd id="pm-collection-creation" class="col-sm-8 col-lg-9">@(Model.Organization.LimitCollectionCreation ? "On" : "Off")</dd>
<dt class="col-sm-4 col-lg-3">Limit collection deletion to administrators</dt>
<dd id="pm-collection-deletion" class="col-sm-8 col-lg-9">@(Model.Organization.LimitCollectionDeletion ? "On" : "Off")</dd>
</dl> </dl>
<h2>Secrets Manager</h2> <h2>Secrets Manager</h2>

View File

@ -9,12 +9,18 @@
<h1>Add Existing Organization</h1> <h1>Add Existing Organization</h1>
<div class="row mb-2"> <div class="row mb-2">
<div class="col"> <div class="col">
<form class="form-inline mb-2" method="get" asp-route-id="@providerId"> <form class="row g-3 align-items-center mb-2" method="get" asp-route-id="@providerId">
<label class="sr-only" asp-for="OrganizationName"></label> <div class="col">
<input type="text" class="form-control mb-2 mr-2 flex-fill" placeholder="@Html.DisplayNameFor(m => m.OrganizationName)" asp-for="OrganizationName" name="name"> <label class="visually-hidden" asp-for="OrganizationName"></label>
<label class="sr-only" asp-for="OrganizationOwnerEmail"></label> <input type="text" class="form-control" placeholder="@Html.DisplayNameFor(m => m.OrganizationName)" asp-for="OrganizationName" name="name">
<input type="email" class="form-control mb-2 mr-2 flex-fill" placeholder="@Html.DisplayNameFor(m => m.OrganizationOwnerEmail)" asp-for="OrganizationOwnerEmail" name="ownerEmail"> </div>
<button type="submit" class="btn btn-primary mb-2" title="Search" formmethod="get"><i class="fa fa-search"></i> Search</button> <div class="col">
<label class="visually-hidden" asp-for="OrganizationOwnerEmail"></label>
<input type="email" class="form-control" placeholder="@Html.DisplayNameFor(m => m.OrganizationOwnerEmail)" asp-for="OrganizationOwnerEmail" name="ownerEmail">
</div>
<div class="col-auto">
<button type="submit" class="btn btn-primary" title="Search" formmethod="get"><i class="fa fa-search"></i> Search</button>
</div>
</form> </form>
</div> </div>
</div> </div>

View File

@ -1,80 +1,43 @@
@using Bit.SharedWeb.Utilities @using Bit.SharedWeb.Utilities
@using Bit.Core.AdminConsole.Enums.Provider @using Bit.Core.AdminConsole.Enums.Provider
@using Bit.Core @using Bit.Core
@model CreateProviderModel @model CreateProviderModel
@inject Bit.Core.Services.IFeatureService FeatureService @inject Bit.Core.Services.IFeatureService FeatureService
@{ @{
ViewData["Title"] = "Create Provider"; ViewData["Title"] = "Create Provider";
}
@section Scripts { var providerTypes = Enum.GetValues<ProviderType>()
<script> .OrderBy(x => x.GetDisplayAttribute().Order)
function toggleProviderTypeInfo(value) { .ToList();
document.querySelectorAll('[id^="info-"]').forEach(el => { el.classList.add('d-none'); });
document.getElementById('info-' + value).classList.remove('d-none');
}
</script>
} }
<h1>Create Provider</h1> <h1>Create Provider</h1>
<form method="post" asp-action="Create">
<form method="post">
<div asp-validation-summary="All" class="alert alert-danger"></div> <div asp-validation-summary="All" class="alert alert-danger"></div>
<div class="mb-3">
<div class="form-group"> <label asp-for="Type" class="form-label h2"></label>
<label asp-for="Type" class="h2"></label> @foreach (var providerType in providerTypes)
@foreach(ProviderType providerType in Enum.GetValues(typeof(ProviderType)))
{ {
var providerTypeValue = (int)providerType; var providerTypeValue = (int)providerType;
<div class="form-check"> <div class="mb-3">
@Html.RadioButtonFor(m => m.Type, providerType, new { id = $"providerType-{providerTypeValue}", @class = "form-check-input", onclick=$"toggleProviderTypeInfo({providerTypeValue})" }) <div class="row">
@Html.LabelFor(m => m.Type, providerType.GetDisplayAttribute()?.GetName(), new { @class = "form-check-label align-middle", @for = $"providerType-{providerTypeValue}" }) <div class="col">
<br/> <div class="form-check">
@Html.LabelFor(m => m.Type, providerType.GetDisplayAttribute()?.GetDescription(), new { @class = "form-check-label small text-muted ml-3 align-top", @for = $"providerType-{providerTypeValue}" }) @Html.RadioButtonFor(m => m.Type, providerType, new { id = $"providerType-{providerTypeValue}", @class = "form-check-input" })
</div> @Html.LabelFor(m => m.Type, providerType.GetDisplayAttribute()?.GetName(), new { @class = "form-check-label", @for = $"providerType-{providerTypeValue}" })
} </div>
</div>
<div id="@($"info-{(int)ProviderType.Msp}")" class="form-group @(Model.Type != ProviderType.Msp ? "d-none" : string.Empty)">
<h2>MSP Info</h2>
<div class="form-group">
<label asp-for="OwnerEmail"></label>
<input type="text" class="form-control" asp-for="OwnerEmail">
</div>
@if (FeatureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling))
{
<div class="row">
<div class="col-sm">
<div class="form-group">
<label asp-for="TeamsMonthlySeatMinimum"></label>
<input type="number" class="form-control" asp-for="TeamsMonthlySeatMinimum">
</div> </div>
</div> </div>
<div class="col-sm"> <div class="row">
<div class="form-group"> <div class="col">
<label asp-for="EnterpriseMonthlySeatMinimum"></label> @Html.LabelFor(m => m.Type, providerType.GetDisplayAttribute()?.GetDescription(), new { @class = "form-check-label small text-body-secondary ps-4", @for = $"providerType-{providerTypeValue}" })
<input type="number" class="form-control" asp-for="EnterpriseMonthlySeatMinimum">
</div> </div>
</div> </div>
</div> </div>
} }
</div> </div>
<button type="submit" class="btn btn-primary mb-2">Next</button>
<div id="@($"info-{(int)ProviderType.Reseller}")" class="form-group @(Model.Type != ProviderType.Reseller ? "d-none" : string.Empty)">
<h2>Reseller Info</h2>
<div class="form-group">
<label asp-for="Name"></label>
<input type="text" class="form-control" asp-for="Name">
</div>
<div class="form-group">
<label asp-for="BusinessName"></label>
<input type="text" class="form-control" asp-for="BusinessName">
</div>
<div class="form-group">
<label asp-for="BillingEmail"></label>
<input type="text" class="form-control" asp-for="BillingEmail">
</div>
</div>
<button type="submit" class="btn btn-primary mb-2">Create Provider</button>
</form> </form>

View File

@ -0,0 +1,45 @@
@using Bit.Core.Billing.Constants
@model CreateMspProviderModel
@{
ViewData["Title"] = "Create Managed Service Provider";
}
<h1>Create Managed Service Provider</h1>
<div>
<form method="post" asp-action="CreateMsp">
<div asp-validation-summary="All" class="alert alert-danger"></div>
<div class="mb-3">
<label asp-for="OwnerEmail" class="form-label"></label>
<input type="text" class="form-control" asp-for="OwnerEmail">
</div>
<div class="mb-3">
@{
var selectList = new List<SelectListItem>
{
new ("No discount", string.Empty, true),
new ("20% - Open", StripeConstants.CouponIDs.MSPDiscounts.Open),
new ("35% - Silver", StripeConstants.CouponIDs.MSPDiscounts.Silver),
new ("50% - Gold", StripeConstants.CouponIDs.MSPDiscounts.Gold)
};
}
<label asp-for="DiscountId" class="form-label"></label>
<select class="form-select" asp-for="DiscountId" asp-items="selectList"></select>
</div>
<div class="row">
<div class="col-sm">
<div class="mb-3">
<label asp-for="TeamsMonthlySeatMinimum" class="form-label"></label>
<input type="number" class="form-control" asp-for="TeamsMonthlySeatMinimum">
</div>
</div>
<div class="col-sm">
<div class="mb-3">
<label asp-for="EnterpriseMonthlySeatMinimum" class="form-label"></label>
<input type="number" class="form-control" asp-for="EnterpriseMonthlySeatMinimum">
</div>
</div>
</div>
<button type="submit" class="btn btn-primary mb-2">Create Provider</button>
</form>
</div>

View File

@ -0,0 +1,43 @@
@using Bit.Core.Billing.Enums
@using Microsoft.AspNetCore.Mvc.TagHelpers
@model CreateMultiOrganizationEnterpriseProviderModel
@{
ViewData["Title"] = "Create Multi-organization Enterprise Provider";
}
<h1 class="mb-4">Create Multi-organization Enterprise Provider</h1>
<div>
<form method="post" asp-action="CreateMultiOrganizationEnterprise">
<div asp-validation-summary="All" class="alert alert-danger"></div>
<div class="mb-3">
<label asp-for="OwnerEmail" class="form-label"></label>
<input type="text" class="form-control" asp-for="OwnerEmail">
</div>
<div class="row">
<div class="col-sm">
<div class="mb-3">
@{
var multiOrgPlans = 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)">
<option value="">--</option>
</select>
</div>
</div>
<div class="col-sm">
<div class="mb-3">
<label asp-for="EnterpriseSeatMinimum" class="form-label"></label>
<input type="number" class="form-control" asp-for="EnterpriseSeatMinimum">
</div>
</div>
</div>
<button type="submit" class="btn btn-primary">Create Provider</button>
</form>
</div>

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