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:
commit
c720e8c154
@ -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": {
|
||||||
|
@ -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
|
||||||
|
@ -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"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -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
|
||||||
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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
39
.github/CODEOWNERS
vendored
@ -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
197
.github/renovate.json
vendored
@ -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
199
.github/renovate.json5
vendored
Normal 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"],
|
||||||
|
}
|
@ -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 }}
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
---
|
|
||||||
name: Automatic responses
|
name: Automatic responses
|
||||||
on:
|
on:
|
||||||
issues:
|
issues:
|
||||||
|
167
.github/workflows/build.yml
vendored
167
.github/workflows/build.yml
vendored
@ -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
21
.github/workflows/build_target.yml
vendored
Normal 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
|
1
.github/workflows/cleanup-after-pr.yml
vendored
1
.github/workflows/cleanup-after-pr.yml
vendored
@ -1,4 +1,3 @@
|
|||||||
---
|
|
||||||
name: Container registry cleanup
|
name: Container registry cleanup
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
3
.github/workflows/cleanup-rc-branch.yml
vendored
3
.github/workflows/cleanup-rc-branch.yml
vendored
@ -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 }}
|
||||||
|
4
.github/workflows/code-references.yml
vendored
4
.github/workflows/code-references.yml
vendored
@ -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
|
||||||
|
7
.github/workflows/enforce-labels.yml
vendored
7
.github/workflows/enforce-labels.yml
vendored
@ -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
|
||||||
|
38
.github/workflows/ephemeral-environment.yml
vendored
Normal file
38
.github/workflows/ephemeral-environment.yml
vendored
Normal 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
|
||||||
|
}
|
||||||
|
})
|
3
.github/workflows/protect-files.yml
vendored
3
.github/workflows/protect-files.yml
vendored
@ -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
|
||||||
|
|
||||||
|
3
.github/workflows/publish.yml
vendored
3
.github/workflows/publish.yml
vendored
@ -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
|
||||||
|
5
.github/workflows/release.yml
vendored
5
.github/workflows/release.yml
vendored
@ -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,
|
||||||
|
193
.github/workflows/repository-management.yml
vendored
193
.github/workflows/repository-management.yml
vendored
@ -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
|
||||||
|
|
||||||
|
17
.github/workflows/scan.yml
vendored
17
.github/workflows/scan.yml
vendored
@ -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 }}"
|
||||||
|
3
.github/workflows/stale-bot.yml
vendored
3
.github/workflows/stale-bot.yml
vendored
@ -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"
|
||||||
|
44
.github/workflows/test-database.yml
vendored
44
.github/workflows/test-database.yml
vendored
@ -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"
|
||||||
|
32
.github/workflows/test.yml
vendored
32
.github/workflows/test.yml
vendored
@ -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
18
.vscode/extensions.json
vendored
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
@ -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>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
|
@ -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}
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
@ -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}.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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()
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -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>
|
||||||
|
@ -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)
|
||||||
{
|
{
|
||||||
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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)
|
||||||
{
|
{
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
@ -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;
|
||||||
|
@ -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";
|
||||||
|
}
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
@ -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">
|
||||||
© @DateTime.Now.Year, Bitwarden Inc.
|
© @DateTime.Now.Year, Bitwarden Inc.
|
||||||
|
682
bitwarden_license/src/Sso/package-lock.json
generated
682
bitwarden_license/src/Sso/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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",
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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 =>
|
||||||
|
@ -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 =
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -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);
|
||||||
|
}
|
||||||
|
}
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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";
|
||||||
|
@ -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)
|
||||||
|
@ -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)" />
|
||||||
|
@ -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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
|
@ -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,
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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:
|
||||||
|
@ -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"
|
||||||
|
@ -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>"
|
||||||
|
38
dev/servicebusemulator_config.json
Normal file
38
dev/servicebusemulator_config.json
Normal 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
90
perf/load/sync.js
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import http from "k6/http";
|
||||||
|
import { check, fail } from "k6";
|
||||||
|
import { authenticate } from "./helpers/auth.js";
|
||||||
|
|
||||||
|
const IDENTITY_URL = __ENV.IDENTITY_URL;
|
||||||
|
const API_URL = __ENV.API_URL;
|
||||||
|
const CLIENT_ID = __ENV.CLIENT_ID;
|
||||||
|
const AUTH_USERNAME = __ENV.AUTH_USER_EMAIL;
|
||||||
|
const AUTH_PASSWORD = __ENV.AUTH_USER_PASSWORD_HASH;
|
||||||
|
|
||||||
|
export const options = {
|
||||||
|
ext: {
|
||||||
|
loadimpact: {
|
||||||
|
projectID: 3639465,
|
||||||
|
name: "Sync",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scenarios: {
|
||||||
|
constant_load: {
|
||||||
|
executor: "constant-arrival-rate",
|
||||||
|
rate: 30,
|
||||||
|
timeUnit: "1m", // 0.5 requests / second
|
||||||
|
duration: "10m",
|
||||||
|
preAllocatedVUs: 5,
|
||||||
|
},
|
||||||
|
ramping_load: {
|
||||||
|
executor: "ramping-arrival-rate",
|
||||||
|
startRate: 30,
|
||||||
|
timeUnit: "1m", // 0.5 requests / second to start
|
||||||
|
stages: [
|
||||||
|
{ duration: "30s", target: 30 },
|
||||||
|
{ duration: "2m", target: 75 },
|
||||||
|
{ duration: "1m", target: 60 },
|
||||||
|
{ duration: "2m", target: 100 },
|
||||||
|
{ duration: "2m", target: 90 },
|
||||||
|
{ duration: "1m", target: 120 },
|
||||||
|
{ duration: "30s", target: 150 },
|
||||||
|
{ duration: "30s", target: 60 },
|
||||||
|
{ duration: "30s", target: 0 },
|
||||||
|
],
|
||||||
|
preAllocatedVUs: 20,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
thresholds: {
|
||||||
|
http_req_failed: ["rate<0.01"],
|
||||||
|
http_req_duration: ["p(95)<1200"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function setup() {
|
||||||
|
return authenticate(IDENTITY_URL, CLIENT_ID, AUTH_USERNAME, AUTH_PASSWORD);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function (data) {
|
||||||
|
const params = {
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${data.access_token}`,
|
||||||
|
"X-ClientId": CLIENT_ID,
|
||||||
|
},
|
||||||
|
tags: { name: "Sync" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const excludeDomains = Math.random() > 0.5;
|
||||||
|
|
||||||
|
const syncRes = http.get(`${API_URL}/sync?excludeDomains=${excludeDomains}`, params);
|
||||||
|
if (
|
||||||
|
!check(syncRes, {
|
||||||
|
"sync status is 200": (r) => r.status === 200,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
console.error(`Sync failed with status ${syncRes.status}: ${syncRes.body}`);
|
||||||
|
fail("sync status code was *not* 200");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (syncRes.status === 200) {
|
||||||
|
const syncJson = syncRes.json();
|
||||||
|
|
||||||
|
check(syncJson, {
|
||||||
|
"sync response has profile": (j) => j.profile !== undefined,
|
||||||
|
"sync response has folders": (j) => Array.isArray(j.folders),
|
||||||
|
"sync response has collections": (j) => Array.isArray(j.collections),
|
||||||
|
"sync response has ciphers": (j) => Array.isArray(j.ciphers),
|
||||||
|
"sync response has policies": (j) => Array.isArray(j.policies),
|
||||||
|
"sync response has sends": (j) => Array.isArray(j.sends),
|
||||||
|
"sync response has correct object type": (j) => j.object === "sync"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -1,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."
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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."
|
|
||||||
}
|
|
@ -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
|
|
@ -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" />
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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]
|
||||||
|
49
src/Admin/AdminConsole/Models/CreateMspProviderModel.cs
Normal file
49
src/Admin/AdminConsole/Models/CreateMspProviderModel.cs
Normal 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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
48
src/Admin/AdminConsole/Models/CreateResellerProviderModel.cs
Normal file
48
src/Admin/AdminConsole/Models/CreateResellerProviderModel.cs
Normal 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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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; } = [];
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
45
src/Admin/AdminConsole/Views/Providers/CreateMsp.cshtml
Normal file
45
src/Admin/AdminConsole/Views/Providers/CreateMsp.cshtml
Normal 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>
|
@ -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
Loading…
x
Reference in New Issue
Block a user