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

Merge branch 'main' into PM-16517-Additional-storage-separate-product-personal-use

This commit is contained in:
Jonas Hendrickx 2025-03-05 14:55:17 +01:00
commit d0c5bb9278
446 changed files with 33153 additions and 2990 deletions

200
.github/renovate.json vendored
View File

@ -1,200 +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"]
},
{
"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",
"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.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"]
}

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

@ -0,0 +1,199 @@
{
$schema: "https://docs.renovatebot.com/renovate-schema.json",
extends: ["github>bitwarden/renovate-config"], // Extends our default configuration for pinned dependencies
enabledManagers: [
"dockerfile",
"docker-compose",
"github-actions",
"npm",
"nuget",
],
packageRules: [
{
groupName: "dockerfile minor",
matchManagers: ["dockerfile"],
matchUpdateTypes: ["minor"],
},
{
groupName: "docker-compose minor",
matchManagers: ["docker-compose"],
matchUpdateTypes: ["minor"],
},
{
groupName: "github-action minor",
matchManagers: ["github-actions"],
matchUpdateTypes: ["minor"],
},
{
matchManagers: ["dockerfile", "docker-compose"],
commitMessagePrefix: "[deps] BRE:",
},
{
matchPackageNames: ["DnsClient"],
description: "Admin Console owned dependencies",
commitMessagePrefix: "[deps] AC:",
reviewers: ["team:team-admin-console-dev"],
},
{
matchFileNames: ["src/Admin/package.json", "src/Sso/package.json"],
description: "Admin & SSO npm packages",
commitMessagePrefix: "[deps] Auth:",
reviewers: ["team:team-auth-dev"],
},
{
matchPackageNames: [
"Azure.Extensions.AspNetCore.DataProtection.Blobs",
"DuoUniversal",
"Fido2.AspNet",
"Duende.IdentityServer",
"Microsoft.Extensions.Identity.Stores",
"Otp.NET",
"Sustainsys.Saml2.AspNetCore2",
"YubicoDotNetClient",
],
description: "Auth owned dependencies",
commitMessagePrefix: "[deps] Auth:",
reviewers: ["team:team-auth-dev"],
},
{
matchPackageNames: [
"AutoFixture.AutoNSubstitute",
"AutoFixture.Xunit2",
"BenchmarkDotNet",
"BitPay.Light",
"Braintree",
"coverlet.collector",
"CsvHelper",
"Kralizek.AutoFixture.Extensions.MockHttp",
"Microsoft.AspNetCore.Mvc.Testing",
"Microsoft.Extensions.Logging",
"Microsoft.Extensions.Logging.Console",
"Newtonsoft.Json",
"NSubstitute",
"Sentry.Serilog",
"Serilog.AspNetCore",
"Serilog.Extensions.Logging",
"Serilog.Extensions.Logging.File",
"Serilog.Sinks.AzureCosmosDB",
"Serilog.Sinks.SyslogMessages",
"Stripe.net",
"Swashbuckle.AspNetCore",
"Swashbuckle.AspNetCore.SwaggerGen",
"xunit",
"xunit.runner.visualstudio",
],
description: "Billing owned dependencies",
commitMessagePrefix: "[deps] Billing:",
reviewers: ["team:team-billing-dev"],
},
{
matchPackagePatterns: ["^Microsoft.Extensions.Logging"],
groupName: "Microsoft.Extensions.Logging",
description: "Group Microsoft.Extensions.Logging to exclude them from the dotnet monorepo preset",
},
{
matchPackageNames: [
"Dapper",
"dbup-sqlserver",
"dotnet-ef",
"linq2db.EntityFrameworkCore",
"Microsoft.Azure.Cosmos",
"Microsoft.Data.SqlClient",
"Microsoft.EntityFrameworkCore.Design",
"Microsoft.EntityFrameworkCore.InMemory",
"Microsoft.EntityFrameworkCore.Relational",
"Microsoft.EntityFrameworkCore.Sqlite",
"Microsoft.EntityFrameworkCore.SqlServer",
"Microsoft.Extensions.Caching.Cosmos",
"Microsoft.Extensions.Caching.SqlServer",
"Microsoft.Extensions.Caching.StackExchangeRedis",
"Npgsql.EntityFrameworkCore.PostgreSQL",
"Pomelo.EntityFrameworkCore.MySql",
],
description: "DbOps owned dependencies",
commitMessagePrefix: "[deps] DbOps:",
reviewers: ["team:dept-dbops"],
},
{
matchPackageNames: ["CommandDotNet", "YamlDotNet"],
description: "DevOps owned dependencies",
commitMessagePrefix: "[deps] BRE:",
reviewers: ["team:dept-bre"],
},
{
matchPackageNames: [
"AspNetCoreRateLimit",
"AspNetCoreRateLimit.Redis",
"Azure.Data.Tables",
"Azure.Messaging.EventGrid",
"Azure.Messaging.ServiceBus",
"Azure.Storage.Blobs",
"Azure.Storage.Queues",
"Microsoft.AspNetCore.Authentication.JwtBearer",
"Microsoft.AspNetCore.Http",
"Quartz",
],
description: "Platform owned dependencies",
commitMessagePrefix: "[deps] Platform:",
reviewers: ["team:team-platform-dev"],
},
{
matchPackagePatterns: ["EntityFrameworkCore", "^dotnet-ef"],
groupName: "EntityFrameworkCore",
description: "Group EntityFrameworkCore to exclude them from the dotnet monorepo preset",
},
{
matchPackageNames: [
"AutoMapper.Extensions.Microsoft.DependencyInjection",
"AWSSDK.SimpleEmail",
"AWSSDK.SQS",
"Handlebars.Net",
"LaunchDarkly.ServerSdk",
"MailKit",
"Microsoft.AspNetCore.SignalR.Protocols.MessagePack",
"Microsoft.AspNetCore.SignalR.StackExchangeRedis",
"Microsoft.Azure.NotificationHubs",
"Microsoft.Extensions.Configuration.EnvironmentVariables",
"Microsoft.Extensions.Configuration.UserSecrets",
"Microsoft.Extensions.Configuration",
"Microsoft.Extensions.DependencyInjection.Abstractions",
"Microsoft.Extensions.DependencyInjection",
"SendGrid",
],
description: "Tools owned dependencies",
commitMessagePrefix: "[deps] Tools:",
reviewers: ["team:team-tools-dev"],
},
{
matchPackagePatterns: ["^Microsoft.AspNetCore.SignalR"],
groupName: "SignalR",
description: "Group SignalR to exclude them from the dotnet monorepo preset",
},
{
matchPackagePatterns: ["^Microsoft.Extensions.Configuration"],
groupName: "Microsoft.Extensions.Configuration",
description: "Group Microsoft.Extensions.Configuration to exclude them from the dotnet monorepo preset",
},
{
matchPackagePatterns: ["^Microsoft.Extensions.DependencyInjection"],
groupName: "Microsoft.Extensions.DependencyInjection",
description: "Group Microsoft.Extensions.DependencyInjection to exclude them from the dotnet monorepo preset",
},
{
matchPackageNames: [
"AngleSharp",
"AspNetCore.HealthChecks.AzureServiceBus",
"AspNetCore.HealthChecks.AzureStorage",
"AspNetCore.HealthChecks.Network",
"AspNetCore.HealthChecks.Redis",
"AspNetCore.HealthChecks.SendGrid",
"AspNetCore.HealthChecks.SqlServer",
"AspNetCore.HealthChecks.Uris",
],
description: "Vault owned dependencies",
commitMessagePrefix: "[deps] Vault:",
reviewers: ["team:team-vault-dev"],
},
],
ignoreDeps: ["dotnet-sdk"],
}

View File

@ -314,7 +314,7 @@ jobs:
output-format: sarif output-format: sarif
- name: Upload Grype results to GitHub - name: Upload Grype results to GitHub
uses: github/codeql-action/upload-sarif@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3.28.0 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 }}

View File

@ -46,7 +46,7 @@ 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@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3.28.0 uses: github/codeql-action/upload-sarif@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3.28.8
with: with:
sarif_file: cx_result.sarif sarif_file: cx_result.sarif
@ -85,6 +85,6 @@ jobs:
/d:sonar.test.inclusions=test/,bitwarden_license/test/ \ /d:sonar.test.inclusions=test/,bitwarden_license/test/ \
/d:sonar.exclusions=test/,bitwarden_license/test/ \ /d:sonar.exclusions=test/,bitwarden_license/test/ \
/o:"${{ github.repository_owner }}" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" \ /o:"${{ github.repository_owner }}" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" \
/d:sonar.host.url="https://sonarcloud.io" /d:sonar.host.url="https://sonarcloud.io" ${{ contains(github.event_name, 'pull_request') && format('/d:sonar.pullrequest.key={0}', github.event.pull_request.number) || '' }}
dotnet build dotnet build
dotnet-sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}" dotnet-sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}"

View File

@ -17,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
@ -28,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:
check-test-secrets: check-test-secrets:
@ -144,7 +146,7 @@ jobs:
# Unified MariaDB # Unified MariaDB
BW_TEST_DATABASES__4__TYPE: "MySql" BW_TEST_DATABASES__4__TYPE: "MySql"
BW_TEST_DATABASES__4__CONNECTIONSTRING: "server=localhost;port=4306;uid=root;pwd=mariadb-password;database=vault_dev;Allow User Variables=true" BW_TEST_DATABASES__4__CONNECTIONSTRING: "server=localhost;port=4306;uid=root;pwd=mariadb-password;database=vault_dev;Allow User Variables=true"
run: dotnet test --logger "trx;LogFileName=infrastructure-test-results.trx" 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
@ -172,6 +174,9 @@ jobs:
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"

View File

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

View File

@ -125,6 +125,8 @@ 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
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@ -313,6 +315,10 @@ 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
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
@ -363,6 +369,7 @@ 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}
EndGlobalSection EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {E01CBF68-2E20-425F-9EDB-E0A6510CA92F} SolutionGuid = {E01CBF68-2E20-425F-9EDB-E0A6510CA92F}

View File

@ -5,12 +5,12 @@ 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.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 Stripe; using Stripe;
namespace Bit.Commercial.Core.AdminConsole.Providers; namespace Bit.Commercial.Core.AdminConsole.Providers;
@ -27,6 +27,7 @@ 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;
public RemoveOrganizationFromProviderCommand( public RemoveOrganizationFromProviderCommand(
IEventService eventService, IEventService eventService,
@ -38,7 +39,8 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
IFeatureService featureService, IFeatureService featureService,
IProviderBillingService providerBillingService, IProviderBillingService providerBillingService,
ISubscriberService subscriberService, ISubscriberService subscriberService,
IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery) IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery,
IPricingClient pricingClient)
{ {
_eventService = eventService; _eventService = eventService;
_mailService = mailService; _mailService = mailService;
@ -50,6 +52,7 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
_providerBillingService = providerBillingService; _providerBillingService = providerBillingService;
_subscriberService = subscriberService; _subscriberService = subscriberService;
_hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery; _hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery;
_pricingClient = pricingClient;
} }
public async Task RemoveOrganizationFromProvider( public async Task RemoveOrganizationFromProvider(
@ -110,7 +113,7 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
Email = organization.BillingEmail Email = organization.BillingEmail
}); });
var plan = StaticStore.GetPlan(organization.PlanType).PasswordManager; var plan = await _pricingClient.GetPlanOrThrow(organization.PlanType);
var subscriptionCreateOptions = new SubscriptionCreateOptions var subscriptionCreateOptions = new SubscriptionCreateOptions
{ {
@ -124,7 +127,7 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
}, },
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 }]
}; };
var subscription = await _stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions); var subscription = await _stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);

View File

@ -8,6 +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.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;
@ -50,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,
@ -58,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;
@ -77,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)
@ -452,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
{ {

View File

@ -1,12 +1,16 @@
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.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing; using Bit.Core.Billing;
using Bit.Core.Billing.Constants; using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Entities; using Bit.Core.Billing.Entities;
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
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.Billing.Services.Contracts; using Bit.Core.Billing.Services.Contracts;
@ -24,17 +28,119 @@ using Stripe;
namespace Bit.Commercial.Core.Billing; namespace Bit.Commercial.Core.Billing;
public class ProviderBillingService( public class ProviderBillingService(
IEventService eventService,
IGlobalSettings globalSettings, IGlobalSettings globalSettings,
ILogger<ProviderBillingService> logger, ILogger<ProviderBillingService> logger,
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
IPaymentService paymentService, IPaymentService paymentService,
IPricingClient pricingClient,
IProviderInvoiceItemRepository providerInvoiceItemRepository, IProviderInvoiceItemRepository providerInvoiceItemRepository,
IProviderOrganizationRepository providerOrganizationRepository, IProviderOrganizationRepository providerOrganizationRepository,
IProviderPlanRepository providerPlanRepository, IProviderPlanRepository providerPlanRepository,
IProviderUserRepository providerUserRepository,
IStripeAdapter stripeAdapter, IStripeAdapter stripeAdapter,
ISubscriberService subscriberService, ISubscriberService subscriberService,
ITaxService taxService) : IProviderBillingService ITaxService taxService) : IProviderBillingService
{ {
[RequireFeature(FeatureFlagKeys.P15179_AddExistingOrgsFromProviderPortal)]
public async Task AddExistingOrganization(
Provider provider,
Organization organization,
string key)
{
await stripeAdapter.SubscriptionUpdateAsync(organization.GatewaySubscriptionId,
new SubscriptionUpdateOptions
{
CancelAtPeriodEnd = false
});
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)
{
await stripeAdapter.InvoiceFinalizeInvoiceAsync(subscription.LatestInvoiceId,
new InvoiceFinalizeOptions { AutoAdvance = true });
}
var managedPlanType = await GetManagedPlanTypeAsync(provider, organization);
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) public async Task ChangePlan(ChangeProviderPlanCommand command)
{ {
var plan = await providerPlanRepository.GetByIdAsync(command.ProviderPlanId); var plan = await providerPlanRepository.GetByIdAsync(command.ProviderPlanId);
@ -49,7 +155,8 @@ public class ProviderBillingService(
return; return;
} }
var oldPlanConfiguration = StaticStore.GetPlan(plan.PlanType); var oldPlanConfiguration = await pricingClient.GetPlanOrThrow(plan.PlanType);
var newPlanConfiguration = await pricingClient.GetPlanOrThrow(command.NewPlan);
plan.PlanType = command.NewPlan; plan.PlanType = command.NewPlan;
await providerPlanRepository.ReplaceAsync(plan); await providerPlanRepository.ReplaceAsync(plan);
@ -73,7 +180,7 @@ public class ProviderBillingService(
[ [
new SubscriptionItemOptions new SubscriptionItemOptions
{ {
Price = StaticStore.GetPlan(command.NewPlan).PasswordManager.StripeProviderPortalSeatPlanId, Price = newPlanConfiguration.PasswordManager.StripeProviderPortalSeatPlanId,
Quantity = oldSubscriptionItem!.Quantity Quantity = oldSubscriptionItem!.Quantity
}, },
new SubscriptionItemOptions new SubscriptionItemOptions
@ -99,7 +206,7 @@ public class ProviderBillingService(
throw new ConflictException($"Organization '{providerOrganization.Id}' not found."); throw new ConflictException($"Organization '{providerOrganization.Id}' not found.");
} }
organization.PlanType = command.NewPlan; organization.PlanType = command.NewPlan;
organization.Plan = StaticStore.GetPlan(command.NewPlan).Name; organization.Plan = newPlanConfiguration.Name;
await organizationRepository.ReplaceAsync(organization); await organizationRepository.ReplaceAsync(organization);
} }
} }
@ -206,6 +313,80 @@ public class ProviderBillingService(
return memoryStream.ToArray(); return memoryStream.ToArray();
} }
[RequireFeature(FeatureFlagKeys.P15179_AddExistingOrgsFromProviderPortal)]
public async Task<IEnumerable<AddableOrganization>> GetAddableOrganizations(
Provider provider,
Guid userId)
{
var providerUser = await providerUserRepository.GetByProviderUserAsync(provider.Id, userId);
if (providerUser is not { Status: ProviderUserStatusType.Confirmed })
{
throw new UnauthorizedAccessException();
}
var candidates = await organizationRepository.GetAddableToProviderByUserIdAsync(userId, provider.Type);
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 [];
}
return await Task.WhenAll(active.Select(async pair =>
{
var (organization, _) = pair;
var planName = await DerivePlanName(provider, organization);
var addable = new AddableOrganization(
organization.Id,
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(
Provider provider, Provider provider,
PlanType planType, PlanType planType,
@ -388,7 +569,7 @@ public class ProviderBillingService(
foreach (var providerPlan in providerPlans) foreach (var providerPlan in providerPlans)
{ {
var plan = StaticStore.GetPlan(providerPlan.PlanType); var plan = await pricingClient.GetPlanOrThrow(providerPlan.PlanType);
if (!providerPlan.IsConfigured()) if (!providerPlan.IsConfigured())
{ {
@ -472,8 +653,10 @@ public class ProviderBillingService(
if (providerPlan.SeatMinimum != newPlanConfiguration.SeatsMinimum) if (providerPlan.SeatMinimum != newPlanConfiguration.SeatsMinimum)
{ {
var priceId = StaticStore.GetPlan(newPlanConfiguration.Plan).PasswordManager var newPlan = await pricingClient.GetPlanOrThrow(newPlanConfiguration.Plan);
.StripeProviderPortalSeatPlanId;
var priceId = newPlan.PasswordManager.StripeProviderPortalSeatPlanId;
var subscriptionItem = subscription.Items.First(item => item.Price.Id == priceId); var subscriptionItem = subscription.Items.First(item => item.Price.Id == priceId);
if (providerPlan.PurchasedSeats == 0) if (providerPlan.PurchasedSeats == 0)
@ -537,7 +720,7 @@ public class ProviderBillingService(
ProviderPlan providerPlan, ProviderPlan providerPlan,
int newlyAssignedSeats) => async (currentlySubscribedSeats, newlySubscribedSeats) => int newlyAssignedSeats) => async (currentlySubscribedSeats, newlySubscribedSeats) =>
{ {
var plan = StaticStore.GetPlan(providerPlan.PlanType); var plan = await pricingClient.GetPlanOrThrow(providerPlan.PlanType);
await paymentService.AdjustSeats( await paymentService.AdjustSeats(
provider, provider,
@ -561,7 +744,7 @@ public class ProviderBillingService(
var providerOrganizations = var providerOrganizations =
await providerOrganizationRepository.GetManyDetailsByProviderAsync(provider.Id); await providerOrganizationRepository.GetManyDetailsByProviderAsync(provider.Id);
var plan = StaticStore.GetPlan(planType); var plan = await pricingClient.GetPlanOrThrow(planType);
return providerOrganizations return providerOrganizations
.Where(providerOrganization => providerOrganization.Plan == plan.Name && providerOrganization.Status == OrganizationStatusType.Managed) .Where(providerOrganization => providerOrganization.Plan == plan.Name && providerOrganization.Status == OrganizationStatusType.Managed)
@ -582,4 +765,21 @@ public class ProviderBillingService(
return providerPlan; return providerPlan;
} }
private async Task<PlanType> GetManagedPlanTypeAsync(
Provider provider,
Organization organization)
{
if (provider.Type == ProviderType.MultiOrganizationEnterprise)
{
return (await providerPlanRepository.GetByProviderId(provider.Id)).First().PlanType;
}
return organization.PlanType switch
{
var planType when PlanConstants.TeamsPlanTypes.Contains(planType) => PlanType.TeamsMonthly,
var planType when PlanConstants.EnterprisePlanTypes.Contains(planType) => PlanType.EnterpriseMonthly,
_ => throw new BillingException()
};
}
} }

View File

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

View File

@ -1,8 +1,10 @@
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces; using Bit.Core;
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.Core.Repositories;
using Bit.Core.Services;
using Bit.Scim.Groups.Interfaces; using Bit.Scim.Groups.Interfaces;
using Bit.Scim.Models; using Bit.Scim.Models;
using Bit.Scim.Utilities; using Bit.Scim.Utilities;
@ -22,9 +24,10 @@ public class GroupsController : Controller
private readonly IGetGroupsListQuery _getGroupsListQuery; private readonly IGetGroupsListQuery _getGroupsListQuery;
private readonly IDeleteGroupCommand _deleteGroupCommand; private readonly IDeleteGroupCommand _deleteGroupCommand;
private readonly IPatchGroupCommand _patchGroupCommand; private readonly IPatchGroupCommand _patchGroupCommand;
private readonly IPatchGroupCommandvNext _patchGroupCommandvNext;
private readonly IPostGroupCommand _postGroupCommand; private readonly IPostGroupCommand _postGroupCommand;
private readonly IPutGroupCommand _putGroupCommand; private readonly IPutGroupCommand _putGroupCommand;
private readonly ILogger<GroupsController> _logger; private readonly IFeatureService _featureService;
public GroupsController( public GroupsController(
IGroupRepository groupRepository, IGroupRepository groupRepository,
@ -32,18 +35,21 @@ public class GroupsController : Controller
IGetGroupsListQuery getGroupsListQuery, IGetGroupsListQuery getGroupsListQuery,
IDeleteGroupCommand deleteGroupCommand, IDeleteGroupCommand deleteGroupCommand,
IPatchGroupCommand patchGroupCommand, IPatchGroupCommand patchGroupCommand,
IPatchGroupCommandvNext patchGroupCommandvNext,
IPostGroupCommand postGroupCommand, IPostGroupCommand postGroupCommand,
IPutGroupCommand putGroupCommand, IPutGroupCommand putGroupCommand,
ILogger<GroupsController> logger) IFeatureService featureService
)
{ {
_groupRepository = groupRepository; _groupRepository = groupRepository;
_organizationRepository = organizationRepository; _organizationRepository = organizationRepository;
_getGroupsListQuery = getGroupsListQuery; _getGroupsListQuery = getGroupsListQuery;
_deleteGroupCommand = deleteGroupCommand; _deleteGroupCommand = deleteGroupCommand;
_patchGroupCommand = patchGroupCommand; _patchGroupCommand = patchGroupCommand;
_patchGroupCommandvNext = patchGroupCommandvNext;
_postGroupCommand = postGroupCommand; _postGroupCommand = postGroupCommand;
_putGroupCommand = putGroupCommand; _putGroupCommand = putGroupCommand;
_logger = logger; _featureService = featureService;
} }
[HttpGet("{id}")] [HttpGet("{id}")]
@ -97,8 +103,21 @@ 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)
{ {
if (_featureService.IsEnabled(FeatureFlagKeys.ShortcutDuplicatePatchRequests))
{
var group = await _groupRepository.GetByIdAsync(id);
if (group == null || group.OrganizationId != organizationId)
{
throw new NotFoundException("Group not found.");
}
await _patchGroupCommandvNext.PatchGroupAsync(group, model);
return new NoContentResult();
}
var organization = await _organizationRepository.GetByIdAsync(organizationId); var organization = await _organizationRepository.GetByIdAsync(organizationId);
await _patchGroupCommand.PatchGroupAsync(organization, id, model); await _patchGroupCommand.PatchGroupAsync(organization, id, model);
return new NoContentResult(); return new NoContentResult();
} }

View File

@ -0,0 +1,9 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Scim.Models;
namespace Bit.Scim.Groups.Interfaces;
public interface IPatchGroupCommandvNext
{
Task PatchGroupAsync(Group group, ScimPatchModel model);
}

View File

@ -0,0 +1,170 @@
using System.Text.Json;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Scim.Groups.Interfaces;
using Bit.Scim.Models;
using Bit.Scim.Utilities;
namespace Bit.Scim.Groups;
public class PatchGroupCommandvNext : IPatchGroupCommandvNext
{
private readonly IGroupRepository _groupRepository;
private readonly IGroupService _groupService;
private readonly IUpdateGroupCommand _updateGroupCommand;
private readonly ILogger<PatchGroupCommandvNext> _logger;
private readonly IOrganizationRepository _organizationRepository;
public PatchGroupCommandvNext(
IGroupRepository groupRepository,
IGroupService groupService,
IUpdateGroupCommand updateGroupCommand,
ILogger<PatchGroupCommandvNext> logger,
IOrganizationRepository organizationRepository)
{
_groupRepository = groupRepository;
_groupService = groupService;
_updateGroupCommand = updateGroupCommand;
_logger = logger;
_organizationRepository = organizationRepository;
}
public async Task PatchGroupAsync(Group group, ScimPatchModel model)
{
foreach (var operation in model.Operations)
{
await HandleOperationAsync(group, operation);
}
}
private async Task HandleOperationAsync(Group group, ScimPatchModel.OperationModel operation)
{
switch (operation.Op?.ToLowerInvariant())
{
// Replace a list of members
case PatchOps.Replace when operation.Path?.ToLowerInvariant() == PatchPaths.Members:
{
var ids = GetOperationValueIds(operation.Value);
await _groupRepository.UpdateUsersAsync(group.Id, ids);
break;
}
// Replace group name from path
case PatchOps.Replace when operation.Path?.ToLowerInvariant() == PatchPaths.DisplayName:
{
group.Name = operation.Value.GetString();
var organization = await _organizationRepository.GetByIdAsync(group.OrganizationId);
if (organization == null)
{
throw new NotFoundException();
}
await _updateGroupCommand.UpdateGroupAsync(group, organization, EventSystemUser.SCIM);
break;
}
// Replace group name from value object
case PatchOps.Replace when
string.IsNullOrWhiteSpace(operation.Path) &&
operation.Value.TryGetProperty("displayName", out var displayNameProperty):
{
group.Name = displayNameProperty.GetString();
var organization = await _organizationRepository.GetByIdAsync(group.OrganizationId);
if (organization == null)
{
throw new NotFoundException();
}
await _updateGroupCommand.UpdateGroupAsync(group, organization, EventSystemUser.SCIM);
break;
}
// Add a single member
case PatchOps.Add when
!string.IsNullOrWhiteSpace(operation.Path) &&
operation.Path.StartsWith("members[value eq ", StringComparison.OrdinalIgnoreCase) &&
TryGetOperationPathId(operation.Path, out var addId):
{
await AddMembersAsync(group, [addId]);
break;
}
// Add a list of members
case PatchOps.Add when
operation.Path?.ToLowerInvariant() == PatchPaths.Members:
{
await AddMembersAsync(group, GetOperationValueIds(operation.Value));
break;
}
// Remove a single member
case PatchOps.Remove when
!string.IsNullOrWhiteSpace(operation.Path) &&
operation.Path.StartsWith("members[value eq ", StringComparison.OrdinalIgnoreCase) &&
TryGetOperationPathId(operation.Path, out var removeId):
{
await _groupService.DeleteUserAsync(group, removeId, EventSystemUser.SCIM);
break;
}
// Remove a list of members
case PatchOps.Remove when
operation.Path?.ToLowerInvariant() == PatchPaths.Members:
{
var orgUserIds = (await _groupRepository.GetManyUserIdsByIdAsync(group.Id)).ToHashSet();
foreach (var v in GetOperationValueIds(operation.Value))
{
orgUserIds.Remove(v);
}
await _groupRepository.UpdateUsersAsync(group.Id, orgUserIds);
break;
}
default:
{
_logger.LogWarning("Group patch operation not handled: {OperationOp}:{OperationPath}", operation.Op, operation.Path);
break;
}
}
}
private async Task AddMembersAsync(Group group, HashSet<Guid> usersToAdd)
{
// Azure Entra ID is known to send redundant "add" requests for each existing member every time any member
// is removed. To avoid excessive load on the database, we check against the high availability replica and
// return early if they already exist.
var groupMembers = await _groupRepository.GetManyUserIdsByIdAsync(group.Id, useReadOnlyReplica: true);
if (usersToAdd.IsSubsetOf(groupMembers))
{
_logger.LogDebug("Ignoring duplicate SCIM request to add members {Members} to group {Group}", usersToAdd, group.Id);
return;
}
await _groupRepository.AddGroupUsersByIdAsync(group.Id, usersToAdd);
}
private static HashSet<Guid> GetOperationValueIds(JsonElement objArray)
{
var ids = new HashSet<Guid>();
foreach (var obj in objArray.EnumerateArray())
{
if (obj.TryGetProperty("value", out var valueProperty))
{
if (valueProperty.TryGetGuid(out var guid))
{
ids.Add(guid);
}
}
}
return ids;
}
private static bool TryGetOperationPathId(string path, out Guid pathId)
{
// Parse Guid from string like: members[value eq "{GUID}"}]
return Guid.TryParse(path.Substring(18).Replace("\"]", string.Empty), out pathId);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -10,6 +10,7 @@ public static class ScimServiceCollectionExtensions
public static void AddScimGroupCommands(this IServiceCollection services) public static void AddScimGroupCommands(this IServiceCollection services)
{ {
services.AddScoped<IPatchGroupCommand, PatchGroupCommand>(); services.AddScoped<IPatchGroupCommand, PatchGroupCommand>();
services.AddScoped<IPatchGroupCommandvNext, PatchGroupCommandvNext>();
services.AddScoped<IPostGroupCommand, PostGroupCommand>(); services.AddScoped<IPostGroupCommand, PostGroupCommand>();
services.AddScoped<IPutGroupCommand, PutGroupCommand>(); services.AddScoped<IPutGroupCommand, PutGroupCommand>();
} }

View File

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

View File

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

View File

@ -17,7 +17,7 @@
"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.2", "mini-css-extract-plugin": "2.9.2",
"sass": "1.79.5", "sass": "1.85.0",
"sass-loader": "16.0.4", "sass-loader": "16.0.4",
"webpack": "5.97.1", "webpack": "5.97.1",
"webpack-cli": "5.1.4" "webpack-cli": "5.1.4"
@ -98,12 +98,13 @@
} }
}, },
"node_modules/@parcel/watcher": { "node_modules/@parcel/watcher": {
"version": "2.5.0", "version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.0.tgz", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz",
"integrity": "sha512-i0GV1yJnm2n3Yq1qw6QrUrd/LI9bE8WEBOTtOkpCXHHdyN3TAGgqAK/DAT05z4fq2x04cARXt2pDmjWjL92iTQ==", "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==",
"dev": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"optional": true,
"dependencies": { "dependencies": {
"detect-libc": "^1.0.3", "detect-libc": "^1.0.3",
"is-glob": "^4.0.3", "is-glob": "^4.0.3",
@ -118,25 +119,25 @@
"url": "https://opencollective.com/parcel" "url": "https://opencollective.com/parcel"
}, },
"optionalDependencies": { "optionalDependencies": {
"@parcel/watcher-android-arm64": "2.5.0", "@parcel/watcher-android-arm64": "2.5.1",
"@parcel/watcher-darwin-arm64": "2.5.0", "@parcel/watcher-darwin-arm64": "2.5.1",
"@parcel/watcher-darwin-x64": "2.5.0", "@parcel/watcher-darwin-x64": "2.5.1",
"@parcel/watcher-freebsd-x64": "2.5.0", "@parcel/watcher-freebsd-x64": "2.5.1",
"@parcel/watcher-linux-arm-glibc": "2.5.0", "@parcel/watcher-linux-arm-glibc": "2.5.1",
"@parcel/watcher-linux-arm-musl": "2.5.0", "@parcel/watcher-linux-arm-musl": "2.5.1",
"@parcel/watcher-linux-arm64-glibc": "2.5.0", "@parcel/watcher-linux-arm64-glibc": "2.5.1",
"@parcel/watcher-linux-arm64-musl": "2.5.0", "@parcel/watcher-linux-arm64-musl": "2.5.1",
"@parcel/watcher-linux-x64-glibc": "2.5.0", "@parcel/watcher-linux-x64-glibc": "2.5.1",
"@parcel/watcher-linux-x64-musl": "2.5.0", "@parcel/watcher-linux-x64-musl": "2.5.1",
"@parcel/watcher-win32-arm64": "2.5.0", "@parcel/watcher-win32-arm64": "2.5.1",
"@parcel/watcher-win32-ia32": "2.5.0", "@parcel/watcher-win32-ia32": "2.5.1",
"@parcel/watcher-win32-x64": "2.5.0" "@parcel/watcher-win32-x64": "2.5.1"
} }
}, },
"node_modules/@parcel/watcher-android-arm64": { "node_modules/@parcel/watcher-android-arm64": {
"version": "2.5.0", "version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.0.tgz", "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz",
"integrity": "sha512-qlX4eS28bUcQCdribHkg/herLe+0A9RyYC+mm2PXpncit8z5b3nSqGVzMNR3CmtAOgRutiZ02eIJJgP/b1iEFQ==", "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -155,9 +156,9 @@
} }
}, },
"node_modules/@parcel/watcher-darwin-arm64": { "node_modules/@parcel/watcher-darwin-arm64": {
"version": "2.5.0", "version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.0.tgz", "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz",
"integrity": "sha512-hyZ3TANnzGfLpRA2s/4U1kbw2ZI4qGxaRJbBH2DCSREFfubMswheh8TeiC1sGZ3z2jUf3s37P0BBlrD3sjVTUw==", "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -176,9 +177,9 @@
} }
}, },
"node_modules/@parcel/watcher-darwin-x64": { "node_modules/@parcel/watcher-darwin-x64": {
"version": "2.5.0", "version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.0.tgz", "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz",
"integrity": "sha512-9rhlwd78saKf18fT869/poydQK8YqlU26TMiNg7AIu7eBp9adqbJZqmdFOsbZ5cnLp5XvRo9wcFmNHgHdWaGYA==", "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -197,9 +198,9 @@
} }
}, },
"node_modules/@parcel/watcher-freebsd-x64": { "node_modules/@parcel/watcher-freebsd-x64": {
"version": "2.5.0", "version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.0.tgz", "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz",
"integrity": "sha512-syvfhZzyM8kErg3VF0xpV8dixJ+RzbUaaGaeb7uDuz0D3FK97/mZ5AJQ3XNnDsXX7KkFNtyQyFrXZzQIcN49Tw==", "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -218,9 +219,9 @@
} }
}, },
"node_modules/@parcel/watcher-linux-arm-glibc": { "node_modules/@parcel/watcher-linux-arm-glibc": {
"version": "2.5.0", "version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.0.tgz", "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz",
"integrity": "sha512-0VQY1K35DQET3dVYWpOaPFecqOT9dbuCfzjxoQyif1Wc574t3kOSkKevULddcR9znz1TcklCE7Ht6NIxjvTqLA==", "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -239,9 +240,9 @@
} }
}, },
"node_modules/@parcel/watcher-linux-arm-musl": { "node_modules/@parcel/watcher-linux-arm-musl": {
"version": "2.5.0", "version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.0.tgz", "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz",
"integrity": "sha512-6uHywSIzz8+vi2lAzFeltnYbdHsDm3iIB57d4g5oaB9vKwjb6N6dRIgZMujw4nm5r6v9/BQH0noq6DzHrqr2pA==", "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -260,9 +261,9 @@
} }
}, },
"node_modules/@parcel/watcher-linux-arm64-glibc": { "node_modules/@parcel/watcher-linux-arm64-glibc": {
"version": "2.5.0", "version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.0.tgz", "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz",
"integrity": "sha512-BfNjXwZKxBy4WibDb/LDCriWSKLz+jJRL3cM/DllnHH5QUyoiUNEp3GmL80ZqxeumoADfCCP19+qiYiC8gUBjA==", "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -281,9 +282,9 @@
} }
}, },
"node_modules/@parcel/watcher-linux-arm64-musl": { "node_modules/@parcel/watcher-linux-arm64-musl": {
"version": "2.5.0", "version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.0.tgz", "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz",
"integrity": "sha512-S1qARKOphxfiBEkwLUbHjCY9BWPdWnW9j7f7Hb2jPplu8UZ3nes7zpPOW9bkLbHRvWM0WDTsjdOTUgW0xLBN1Q==", "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -302,9 +303,9 @@
} }
}, },
"node_modules/@parcel/watcher-linux-x64-glibc": { "node_modules/@parcel/watcher-linux-x64-glibc": {
"version": "2.5.0", "version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.0.tgz", "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz",
"integrity": "sha512-d9AOkusyXARkFD66S6zlGXyzx5RvY+chTP9Jp0ypSTC9d4lzyRs9ovGf/80VCxjKddcUvnsGwCHWuF2EoPgWjw==", "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -323,9 +324,9 @@
} }
}, },
"node_modules/@parcel/watcher-linux-x64-musl": { "node_modules/@parcel/watcher-linux-x64-musl": {
"version": "2.5.0", "version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.0.tgz", "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz",
"integrity": "sha512-iqOC+GoTDoFyk/VYSFHwjHhYrk8bljW6zOhPuhi5t9ulqiYq1togGJB5e3PwYVFFfeVgc6pbz3JdQyDoBszVaA==", "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -344,9 +345,9 @@
} }
}, },
"node_modules/@parcel/watcher-win32-arm64": { "node_modules/@parcel/watcher-win32-arm64": {
"version": "2.5.0", "version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.0.tgz", "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz",
"integrity": "sha512-twtft1d+JRNkM5YbmexfcH/N4znDtjgysFaV9zvZmmJezQsKpkfLYJ+JFV3uygugK6AtIM2oADPkB2AdhBrNig==", "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -365,9 +366,9 @@
} }
}, },
"node_modules/@parcel/watcher-win32-ia32": { "node_modules/@parcel/watcher-win32-ia32": {
"version": "2.5.0", "version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.0.tgz", "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz",
"integrity": "sha512-+rgpsNRKwo8A53elqbbHXdOMtY/tAtTzManTWShB5Kk54N8Q9mzNWV7tV+IbGueCbcj826MfWGU3mprWtuf1TA==", "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@ -386,9 +387,9 @@
} }
}, },
"node_modules/@parcel/watcher-win32-x64": { "node_modules/@parcel/watcher-win32-x64": {
"version": "2.5.0", "version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.0.tgz", "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz",
"integrity": "sha512-lPrxve92zEHdgeff3aiu4gDOIt4u7sJYha6wbdEZDCDUhtjTsOMiaJzG5lMY4GkWH8p0fMmO2Ppq5G5XXG+DQw==", "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -454,9 +455,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.10.2", "version": "22.13.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.2.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.5.tgz",
"integrity": "sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ==", "integrity": "sha512-+lTU0PxZXn0Dr1NBtC7Y8cR21AJr87dLLU953CWA6pMxxv/UDc7jYAY90upcrie1nRcD6XNG5HOYEDtgW5TxAg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -771,6 +772,7 @@
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true,
"dependencies": { "dependencies": {
"fill-range": "^7.1.1" "fill-range": "^7.1.1"
}, },
@ -779,9 +781,9 @@
} }
}, },
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.24.3", "version": "4.24.4",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.3.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz",
"integrity": "sha512-1CPmv8iobE2fyRMV97dAcMVegvvWKxmq94hkLiAkUGwKVTyDLw33K+ZxiFrREKmmps4rIw6grcCFCnTMSZ/YiA==", "integrity": "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -819,9 +821,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001690", "version": "1.0.30001700",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001690.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001700.tgz",
"integrity": "sha512-5ExiE3qQN6oF8Clf8ifIDcMRCRE/dMGcETG/XGMD8/XiXm6HXQgQTh1yZYLXXpSOsEUlJm1Xr7kGULZTuGtP/w==", "integrity": "sha512-2S6XIXwaE7K7erT8dY+kLQcpa5ms63XlRkMkReXjle+kf6c5g38vyMl+Z5y8dSxOFDhcFe+nxnn261PLxBSQsQ==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -964,6 +966,7 @@
"integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true,
"bin": { "bin": {
"detect-libc": "bin/detect-libc.js" "detect-libc": "bin/detect-libc.js"
}, },
@ -972,16 +975,16 @@
} }
}, },
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.75", "version": "1.5.103",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.75.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.103.tgz",
"integrity": "sha512-Lf3++DumRE/QmweGjU+ZcKqQ+3bKkU/qjaKYhIJKEOhgIO9Xs6IiAQFkfFoj+RhgDk4LUeNsLo6plExHqSyu6Q==", "integrity": "sha512-P6+XzIkfndgsrjROJWfSvVEgNHtPgbhVyTkwLjUM2HU/h7pZRORgaTlHqfAikqxKmdJMLW8fftrdGWbd/Ds0FA==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/enhanced-resolve": { "node_modules/enhanced-resolve": {
"version": "5.18.0", "version": "5.18.1",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.0.tgz", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz",
"integrity": "sha512-0/r0MySGYG8YqlayBZ6MuCfECmHFdJ5qyPh8s8wa5Hnm6SaFLSK1VYCbj+NKp090Nm1caZhD+QTnmxO7esYGyQ==", "integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -1006,9 +1009,9 @@
} }
}, },
"node_modules/es-module-lexer": { "node_modules/es-module-lexer": {
"version": "1.5.4", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.5.4.tgz", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.6.0.tgz",
"integrity": "sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw==", "integrity": "sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@ -1111,10 +1114,20 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-uri": { "node_modules/fast-uri": {
"version": "3.0.3", "version": "3.0.6",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.3.tgz", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz",
"integrity": "sha512-aLrHthzCjH5He4Z2H9YZ+v6Ujb9ocRuW6ZzkJQOrTxleEijANq4v1TsaPaVG1PZcuurEzrLcWRyYBYXD5cEiaw==", "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==",
"dev": true, "dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/fastest-levenshtein": { "node_modules/fastest-levenshtein": {
@ -1133,6 +1146,7 @@
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true,
"dependencies": { "dependencies": {
"to-regex-range": "^5.0.1" "to-regex-range": "^5.0.1"
}, },
@ -1234,9 +1248,9 @@
} }
}, },
"node_modules/immutable": { "node_modules/immutable": {
"version": "4.3.7", "version": "5.0.3",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.7.tgz", "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.0.3.tgz",
"integrity": "sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==", "integrity": "sha512-P8IdPQHq3lA1xVeBRi5VPqUm5HDgKnx0Ru51wZz5mjxHr5n3RWhjIpOFU7ybkUxfB+5IToy+OLaHYDBIWsv+uw==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@ -1292,6 +1306,7 @@
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@ -1302,6 +1317,7 @@
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true,
"dependencies": { "dependencies": {
"is-extglob": "^2.1.1" "is-extglob": "^2.1.1"
}, },
@ -1315,6 +1331,7 @@
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true,
"engines": { "engines": {
"node": ">=0.12.0" "node": ">=0.12.0"
} }
@ -1430,6 +1447,7 @@
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true,
"dependencies": { "dependencies": {
"braces": "^3.0.3", "braces": "^3.0.3",
"picomatch": "^2.3.1" "picomatch": "^2.3.1"
@ -1513,7 +1531,8 @@
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"optional": true
}, },
"node_modules/node-releases": { "node_modules/node-releases": {
"version": "2.0.19", "version": "2.0.19",
@ -1601,6 +1620,7 @@
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true,
"engines": { "engines": {
"node": ">=8.6" "node": ">=8.6"
}, },
@ -1622,9 +1642,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.4.49", "version": "8.5.3",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz",
"integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==", "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -1642,7 +1662,7 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"nanoid": "^3.3.7", "nanoid": "^3.3.8",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
"source-map-js": "^1.2.1" "source-map-js": "^1.2.1"
}, },
@ -1714,9 +1734,9 @@
} }
}, },
"node_modules/postcss-selector-parser": { "node_modules/postcss-selector-parser": {
"version": "7.0.0", "version": "7.1.0",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.0.0.tgz", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz",
"integrity": "sha512-9RbEr1Y7FFfptd/1eEdntyjMwLeghW1bHX9GWjXo19vx4ytPQhANltvVxDggzJl7mnWM+dX28kb6cyS/4iQjlQ==", "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -1755,13 +1775,13 @@
} }
}, },
"node_modules/readdirp": { "node_modules/readdirp": {
"version": "4.0.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==", "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 14.16.0" "node": ">= 14.18.0"
}, },
"funding": { "funding": {
"type": "individual", "type": "individual",
@ -1857,15 +1877,14 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/sass": { "node_modules/sass": {
"version": "1.79.5", "version": "1.85.0",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.79.5.tgz", "resolved": "https://registry.npmjs.org/sass/-/sass-1.85.0.tgz",
"integrity": "sha512-W1h5kp6bdhqFh2tk3DsI771MoEJjvrSY/2ihJRJS4pjIyfJCw0nTsxqhnrUzaLMOJjFchj8rOvraI/YUVjtx5g==", "integrity": "sha512-3ToiC1xZ1Y8aU7+CkgCI/tqyuPXEmYGJXO7H4uqp0xkLXUqp88rQQ4j1HmP37xSJLbCJPaIiv+cT1y+grssrww==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@parcel/watcher": "^2.4.1",
"chokidar": "^4.0.0", "chokidar": "^4.0.0",
"immutable": "^4.0.0", "immutable": "^5.0.2",
"source-map-js": ">=0.6.2 <2.0.0" "source-map-js": ">=0.6.2 <2.0.0"
}, },
"bin": { "bin": {
@ -1873,6 +1892,9 @@
}, },
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"
},
"optionalDependencies": {
"@parcel/watcher": "^2.4.1"
} }
}, },
"node_modules/sass-loader": { "node_modules/sass-loader": {
@ -1937,9 +1959,9 @@
} }
}, },
"node_modules/semver": { "node_modules/semver": {
"version": "7.6.3", "version": "7.7.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
"integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"bin": { "bin": {
@ -2066,9 +2088,9 @@
} }
}, },
"node_modules/terser": { "node_modules/terser": {
"version": "5.37.0", "version": "5.39.0",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.37.0.tgz", "resolved": "https://registry.npmjs.org/terser/-/terser-5.39.0.tgz",
"integrity": "sha512-B8wRRkmre4ERucLM/uXx4MOV5cbnOlVAqUst+1+iLKPI0dOgFO28f84ptoQt9HEI537PMzfYa/d+GEPKTRXmYA==", "integrity": "sha512-LBAhFyLho16harJoWMg/nZsQYgTrg5jXOn2nCYjRUcZZEdE3qa2zb8QEDRUGVZBW4rlazf2fxkg8tztybTaqWw==",
"dev": true, "dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"dependencies": { "dependencies": {
@ -2125,6 +2147,7 @@
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true,
"dependencies": { "dependencies": {
"is-number": "^7.0.0" "is-number": "^7.0.0"
}, },
@ -2140,9 +2163,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/update-browserslist-db": { "node_modules/update-browserslist-db": {
"version": "1.1.1", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.2.tgz",
"integrity": "sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==", "integrity": "sha512-PPypAm5qvlD7XMZC3BujecnaOxwhrtoFR+Dqkk5Aa/6DssiH0ibKoketaj9w8LP7Bont1rYeoV5plxD7RTEPRg==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -2161,7 +2184,7 @@
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"escalade": "^3.2.0", "escalade": "^3.2.0",
"picocolors": "^1.1.0" "picocolors": "^1.1.1"
}, },
"bin": { "bin": {
"update-browserslist-db": "cli.js" "update-browserslist-db": "cli.js"

View File

@ -16,7 +16,7 @@
"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.2", "mini-css-extract-plugin": "2.9.2",
"sass": "1.79.5", "sass": "1.85.0",
"sass-loader": "16.0.4", "sass-loader": "16.0.4",
"webpack": "5.97.1", "webpack": "5.97.1",
"webpack-cli": "5.1.4" "webpack-cli": "5.1.4"

View File

@ -6,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;
@ -205,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,
[], [],

View File

@ -7,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;
@ -550,8 +551,14 @@ public class ProviderServiceTests
organization.PlanType = PlanType.EnterpriseMonthly; organization.PlanType = PlanType.EnterpriseMonthly;
organization.Plan = "Enterprise (Monthly)"; organization.Plan = "Enterprise (Monthly)";
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(organization.PlanType)
.Returns(StaticStore.GetPlan(organization.PlanType));
var expectedPlanType = PlanType.EnterpriseMonthly2020; var expectedPlanType = PlanType.EnterpriseMonthly2020;
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(expectedPlanType)
.Returns(StaticStore.GetPlan(expectedPlanType));
var expectedPlanId = "2020-enterprise-org-seat-monthly"; var expectedPlanId = "2020-enterprise-org-seat-monthly";
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider); sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);

View File

@ -9,6 +9,7 @@ using Bit.Core.AdminConsole.Repositories;
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.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.Billing.Services.Contracts;
@ -128,6 +129,9 @@ public class ProviderBillingServiceTests
.GetByIdAsync(Arg.Is<Guid>(p => p == providerPlanId)) .GetByIdAsync(Arg.Is<Guid>(p => p == providerPlanId))
.Returns(existingPlan); .Returns(existingPlan);
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(existingPlan.PlanType)
.Returns(StaticStore.GetPlan(existingPlan.PlanType));
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>(); var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
stripeAdapter.ProviderSubscriptionGetAsync( stripeAdapter.ProviderSubscriptionGetAsync(
Arg.Is(provider.GatewaySubscriptionId), Arg.Is(provider.GatewaySubscriptionId),
@ -156,6 +160,9 @@ public class ProviderBillingServiceTests
var command = var command =
new ChangeProviderPlanCommand(providerPlanId, PlanType.EnterpriseMonthly, provider.GatewaySubscriptionId); new ChangeProviderPlanCommand(providerPlanId, PlanType.EnterpriseMonthly, provider.GatewaySubscriptionId);
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(command.NewPlan)
.Returns(StaticStore.GetPlan(command.NewPlan));
// Act // Act
await sutProvider.Sut.ChangePlan(command); await sutProvider.Sut.ChangePlan(command);
@ -390,6 +397,12 @@ public class ProviderBillingServiceTests
} }
}; };
foreach (var plan in providerPlans)
{
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(StaticStore.GetPlan(plan.PlanType));
}
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id).Returns(providerPlans); sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id).Returns(providerPlans);
// 50 seats currently assigned with a seat minimum of 100 // 50 seats currently assigned with a seat minimum of 100
@ -451,6 +464,12 @@ public class ProviderBillingServiceTests
} }
}; };
foreach (var plan in providerPlans)
{
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(StaticStore.GetPlan(plan.PlanType));
}
var providerPlan = providerPlans.First(); var providerPlan = providerPlans.First();
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id).Returns(providerPlans); sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id).Returns(providerPlans);
@ -515,6 +534,12 @@ public class ProviderBillingServiceTests
} }
}; };
foreach (var plan in providerPlans)
{
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(StaticStore.GetPlan(plan.PlanType));
}
var providerPlan = providerPlans.First(); var providerPlan = providerPlans.First();
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id).Returns(providerPlans); sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id).Returns(providerPlans);
@ -579,6 +604,12 @@ public class ProviderBillingServiceTests
} }
}; };
foreach (var plan in providerPlans)
{
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(StaticStore.GetPlan(plan.PlanType));
}
var providerPlan = providerPlans.First(); var providerPlan = providerPlans.First();
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id).Returns(providerPlans); sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id).Returns(providerPlans);
@ -636,6 +667,8 @@ public class ProviderBillingServiceTests
} }
]); ]);
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(planType).Returns(StaticStore.GetPlan(planType));
sutProvider.GetDependency<IProviderOrganizationRepository>().GetManyDetailsByProviderAsync(provider.Id).Returns( sutProvider.GetDependency<IProviderOrganizationRepository>().GetManyDetailsByProviderAsync(provider.Id).Returns(
[ [
new ProviderOrganizationOrganizationDetails new ProviderOrganizationOrganizationDetails
@ -672,6 +705,8 @@ public class ProviderBillingServiceTests
} }
]); ]);
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(planType).Returns(StaticStore.GetPlan(planType));
sutProvider.GetDependency<IProviderOrganizationRepository>().GetManyDetailsByProviderAsync(provider.Id).Returns( sutProvider.GetDependency<IProviderOrganizationRepository>().GetManyDetailsByProviderAsync(provider.Id).Returns(
[ [
new ProviderOrganizationOrganizationDetails new ProviderOrganizationOrganizationDetails
@ -856,6 +891,9 @@ public class ProviderBillingServiceTests
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id) sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
.Returns(providerPlans); .Returns(providerPlans);
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(PlanType.EnterpriseMonthly)
.Returns(StaticStore.GetPlan(PlanType.EnterpriseMonthly));
await ThrowsBillingExceptionAsync(() => sutProvider.Sut.SetupSubscription(provider)); await ThrowsBillingExceptionAsync(() => sutProvider.Sut.SetupSubscription(provider));
await sutProvider.GetDependency<IStripeAdapter>() await sutProvider.GetDependency<IStripeAdapter>()
@ -881,6 +919,9 @@ public class ProviderBillingServiceTests
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id) sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
.Returns(providerPlans); .Returns(providerPlans);
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(PlanType.TeamsMonthly)
.Returns(StaticStore.GetPlan(PlanType.TeamsMonthly));
await ThrowsBillingExceptionAsync(() => sutProvider.Sut.SetupSubscription(provider)); await ThrowsBillingExceptionAsync(() => sutProvider.Sut.SetupSubscription(provider));
await sutProvider.GetDependency<IStripeAdapter>() await sutProvider.GetDependency<IStripeAdapter>()
@ -923,6 +964,12 @@ public class ProviderBillingServiceTests
} }
}; };
foreach (var plan in providerPlans)
{
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(StaticStore.GetPlan(plan.PlanType));
}
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id) sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
.Returns(providerPlans); .Returns(providerPlans);
@ -968,6 +1015,12 @@ public class ProviderBillingServiceTests
} }
}; };
foreach (var plan in providerPlans)
{
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(StaticStore.GetPlan(plan.PlanType));
}
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id) sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
.Returns(providerPlans); .Returns(providerPlans);
@ -1066,6 +1119,12 @@ public class ProviderBillingServiceTests
new() { PlanType = PlanType.TeamsMonthly, SeatMinimum = 30, PurchasedSeats = 0, AllocatedSeats = 25 } new() { PlanType = PlanType.TeamsMonthly, SeatMinimum = 30, PurchasedSeats = 0, AllocatedSeats = 25 }
}; };
foreach (var plan in providerPlans)
{
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(StaticStore.GetPlan(plan.PlanType));
}
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans); providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
var command = new UpdateProviderSeatMinimumsCommand( var command = new UpdateProviderSeatMinimumsCommand(
@ -1139,6 +1198,12 @@ public class ProviderBillingServiceTests
new() { PlanType = PlanType.TeamsMonthly, SeatMinimum = 30, PurchasedSeats = 0, AllocatedSeats = 15 } new() { PlanType = PlanType.TeamsMonthly, SeatMinimum = 30, PurchasedSeats = 0, AllocatedSeats = 15 }
}; };
foreach (var plan in providerPlans)
{
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(StaticStore.GetPlan(plan.PlanType));
}
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans); providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
var command = new UpdateProviderSeatMinimumsCommand( var command = new UpdateProviderSeatMinimumsCommand(
@ -1212,6 +1277,12 @@ public class ProviderBillingServiceTests
new() { PlanType = PlanType.TeamsMonthly, SeatMinimum = 50, PurchasedSeats = 20 } new() { PlanType = PlanType.TeamsMonthly, SeatMinimum = 50, PurchasedSeats = 20 }
}; };
foreach (var plan in providerPlans)
{
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(StaticStore.GetPlan(plan.PlanType));
}
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans); providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
var command = new UpdateProviderSeatMinimumsCommand( var command = new UpdateProviderSeatMinimumsCommand(
@ -1279,6 +1350,12 @@ public class ProviderBillingServiceTests
new() { PlanType = PlanType.TeamsMonthly, SeatMinimum = 50, PurchasedSeats = 20 } new() { PlanType = PlanType.TeamsMonthly, SeatMinimum = 50, PurchasedSeats = 20 }
}; };
foreach (var plan in providerPlans)
{
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(StaticStore.GetPlan(plan.PlanType));
}
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans); providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
var command = new UpdateProviderSeatMinimumsCommand( var command = new UpdateProviderSeatMinimumsCommand(
@ -1352,6 +1429,12 @@ public class ProviderBillingServiceTests
new() { PlanType = PlanType.TeamsMonthly, SeatMinimum = 30, PurchasedSeats = 0 } new() { PlanType = PlanType.TeamsMonthly, SeatMinimum = 30, PurchasedSeats = 0 }
}; };
foreach (var plan in providerPlans)
{
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(StaticStore.GetPlan(plan.PlanType));
}
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans); providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
var command = new UpdateProviderSeatMinimumsCommand( var command = new UpdateProviderSeatMinimumsCommand(

View File

@ -0,0 +1,237 @@
using System.Text.Json;
using Bit.Scim.IntegrationTest.Factories;
using Bit.Scim.Models;
using Bit.Scim.Utilities;
using Bit.Test.Common.Helpers;
using Xunit;
namespace Bit.Scim.IntegrationTest.Controllers.v2;
public class GroupsControllerPatchTests : IClassFixture<ScimApplicationFactory>, IAsyncLifetime
{
private readonly ScimApplicationFactory _factory;
public GroupsControllerPatchTests(ScimApplicationFactory factory)
{
_factory = factory;
}
public Task InitializeAsync()
{
var databaseContext = _factory.GetDatabaseContext();
_factory.ReinitializeDbForTests(databaseContext);
return Task.CompletedTask;
}
Task IAsyncLifetime.DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task Patch_ReplaceDisplayName_Success()
{
var organizationId = ScimApplicationFactory.TestOrganizationId1;
var groupId = ScimApplicationFactory.TestGroupId1;
var newDisplayName = "Patch Display Name";
var inputModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>()
{
new ScimPatchModel.OperationModel
{
Op = "replace",
Value = JsonDocument.Parse($"{{\"displayName\":\"{newDisplayName}\"}}").RootElement
}
},
Schemas = new List<string>() { ScimConstants.Scim2SchemaGroup }
};
var context = await _factory.GroupsPatchAsync(organizationId, groupId, inputModel);
Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode);
var databaseContext = _factory.GetDatabaseContext();
var group = databaseContext.Groups.FirstOrDefault(g => g.Id == groupId);
Assert.Equal(newDisplayName, group.Name);
Assert.Equal(ScimApplicationFactory.InitialGroupUsersCount, databaseContext.GroupUsers.Count());
Assert.True(databaseContext.GroupUsers.Any(gu => gu.OrganizationUserId == ScimApplicationFactory.TestOrganizationUserId1));
Assert.True(databaseContext.GroupUsers.Any(gu => gu.OrganizationUserId == ScimApplicationFactory.TestOrganizationUserId4));
}
[Fact]
public async Task Patch_ReplaceMembers_Success()
{
var organizationId = ScimApplicationFactory.TestOrganizationId1;
var groupId = ScimApplicationFactory.TestGroupId1;
var inputModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>()
{
new ScimPatchModel.OperationModel
{
Op = "replace",
Path = "members",
Value = JsonDocument.Parse($"[{{\"value\":\"{ScimApplicationFactory.TestOrganizationUserId2}\"}}]").RootElement
}
},
Schemas = new List<string>() { ScimConstants.Scim2SchemaGroup }
};
var context = await _factory.GroupsPatchAsync(organizationId, groupId, inputModel);
Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode);
var databaseContext = _factory.GetDatabaseContext();
Assert.Single(databaseContext.GroupUsers);
Assert.Equal(ScimApplicationFactory.InitialGroupUsersCount - 1, databaseContext.GroupUsers.Count());
var groupUser = databaseContext.GroupUsers.FirstOrDefault();
Assert.Equal(ScimApplicationFactory.TestOrganizationUserId2, groupUser.OrganizationUserId);
}
[Fact]
public async Task Patch_AddSingleMember_Success()
{
var organizationId = ScimApplicationFactory.TestOrganizationId1;
var groupId = ScimApplicationFactory.TestGroupId1;
var inputModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>()
{
new ScimPatchModel.OperationModel
{
Op = "add",
Path = $"members[value eq \"{ScimApplicationFactory.TestOrganizationUserId2}\"]",
Value = JsonDocument.Parse("{}").RootElement
}
},
Schemas = new List<string>() { ScimConstants.Scim2SchemaGroup }
};
var context = await _factory.GroupsPatchAsync(organizationId, groupId, inputModel);
Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode);
var databaseContext = _factory.GetDatabaseContext();
Assert.Equal(ScimApplicationFactory.InitialGroupUsersCount + 1, databaseContext.GroupUsers.Count());
Assert.True(databaseContext.GroupUsers.Any(gu => gu.GroupId == groupId && gu.OrganizationUserId == ScimApplicationFactory.TestOrganizationUserId1));
Assert.True(databaseContext.GroupUsers.Any(gu => gu.GroupId == groupId && gu.OrganizationUserId == ScimApplicationFactory.TestOrganizationUserId2));
Assert.True(databaseContext.GroupUsers.Any(gu => gu.GroupId == groupId && gu.OrganizationUserId == ScimApplicationFactory.TestOrganizationUserId4));
}
[Fact]
public async Task Patch_AddListMembers_Success()
{
var organizationId = ScimApplicationFactory.TestOrganizationId1;
var groupId = ScimApplicationFactory.TestGroupId2;
var inputModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>()
{
new ScimPatchModel.OperationModel
{
Op = "add",
Path = "members",
Value = JsonDocument.Parse($"[{{\"value\":\"{ScimApplicationFactory.TestOrganizationUserId2}\"}},{{\"value\":\"{ScimApplicationFactory.TestOrganizationUserId3}\"}}]").RootElement
}
},
Schemas = new List<string>() { ScimConstants.Scim2SchemaGroup }
};
var context = await _factory.GroupsPatchAsync(organizationId, groupId, inputModel);
Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode);
var databaseContext = _factory.GetDatabaseContext();
Assert.True(databaseContext.GroupUsers.Any(gu => gu.GroupId == groupId && gu.OrganizationUserId == ScimApplicationFactory.TestOrganizationUserId2));
Assert.True(databaseContext.GroupUsers.Any(gu => gu.GroupId == groupId && gu.OrganizationUserId == ScimApplicationFactory.TestOrganizationUserId3));
}
[Fact]
public async Task Patch_RemoveSingleMember_ReplaceDisplayName_Success()
{
var organizationId = ScimApplicationFactory.TestOrganizationId1;
var groupId = ScimApplicationFactory.TestGroupId1;
var newDisplayName = "Patch Display Name";
var inputModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>()
{
new ScimPatchModel.OperationModel
{
Op = "remove",
Path = $"members[value eq \"{ScimApplicationFactory.TestOrganizationUserId1}\"]",
Value = JsonDocument.Parse("{}").RootElement
},
new ScimPatchModel.OperationModel
{
Op = "replace",
Value = JsonDocument.Parse($"{{\"displayName\":\"{newDisplayName}\"}}").RootElement
}
},
Schemas = new List<string>() { ScimConstants.Scim2SchemaGroup }
};
var context = await _factory.GroupsPatchAsync(organizationId, groupId, inputModel);
Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode);
var databaseContext = _factory.GetDatabaseContext();
Assert.Equal(ScimApplicationFactory.InitialGroupUsersCount - 1, databaseContext.GroupUsers.Count());
Assert.Equal(ScimApplicationFactory.InitialGroupCount, databaseContext.Groups.Count());
var group = databaseContext.Groups.FirstOrDefault(g => g.Id == groupId);
Assert.Equal(newDisplayName, group.Name);
}
[Fact]
public async Task Patch_RemoveListMembers_Success()
{
var organizationId = ScimApplicationFactory.TestOrganizationId1;
var groupId = ScimApplicationFactory.TestGroupId1;
var inputModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>()
{
new ScimPatchModel.OperationModel
{
Op = "remove",
Path = "members",
Value = JsonDocument.Parse($"[{{\"value\":\"{ScimApplicationFactory.TestOrganizationUserId1}\"}}, {{\"value\":\"{ScimApplicationFactory.TestOrganizationUserId4}\"}}]").RootElement
}
},
Schemas = new List<string>() { ScimConstants.Scim2SchemaGroup }
};
var context = await _factory.GroupsPatchAsync(organizationId, groupId, inputModel);
Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode);
var databaseContext = _factory.GetDatabaseContext();
Assert.Empty(databaseContext.GroupUsers);
}
[Fact]
public async Task Patch_NotFound()
{
var organizationId = ScimApplicationFactory.TestOrganizationId1;
var groupId = Guid.NewGuid();
var inputModel = new Models.ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>(),
Schemas = new List<string>() { ScimConstants.Scim2SchemaGroup }
};
var expectedResponse = new ScimErrorResponseModel
{
Status = StatusCodes.Status404NotFound,
Detail = "Group not found.",
Schemas = new List<string> { ScimConstants.Scim2SchemaError }
};
var context = await _factory.GroupsPatchAsync(organizationId, groupId, inputModel);
Assert.Equal(StatusCodes.Status404NotFound, context.Response.StatusCode);
var responseModel = JsonSerializer.Deserialize<ScimErrorResponseModel>(context.Response.Body, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
AssertHelper.AssertPropertyEqual(expectedResponse, responseModel);
}
}

View File

@ -0,0 +1,251 @@
using System.Text.Json;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Services;
using Bit.Scim.Groups.Interfaces;
using Bit.Scim.IntegrationTest.Factories;
using Bit.Scim.Models;
using Bit.Scim.Utilities;
using Bit.Test.Common.Helpers;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Xunit;
namespace Bit.Scim.IntegrationTest.Controllers.v2;
public class GroupsControllerPatchTestsvNext : IClassFixture<ScimApplicationFactory>, IAsyncLifetime
{
private readonly ScimApplicationFactory _factory;
public GroupsControllerPatchTestsvNext(ScimApplicationFactory factory)
{
_factory = factory;
// Enable the feature flag for new PatchGroupsCommand and stub out the old command to be safe
_factory.SubstituteService((IFeatureService featureService)
=> featureService.IsEnabled(FeatureFlagKeys.ShortcutDuplicatePatchRequests).Returns(true));
_factory.SubstituteService((IPatchGroupCommand patchGroupCommand)
=> patchGroupCommand.PatchGroupAsync(Arg.Any<Organization>(), Arg.Any<Guid>(), Arg.Any<ScimPatchModel>())
.ThrowsAsync(new Exception("This test suite should be testing the vNext command, but the existing command was called.")));
}
public Task InitializeAsync()
{
var databaseContext = _factory.GetDatabaseContext();
_factory.ReinitializeDbForTests(databaseContext);
return Task.CompletedTask;
}
Task IAsyncLifetime.DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task Patch_ReplaceDisplayName_Success()
{
var organizationId = ScimApplicationFactory.TestOrganizationId1;
var groupId = ScimApplicationFactory.TestGroupId1;
var newDisplayName = "Patch Display Name";
var inputModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>()
{
new ScimPatchModel.OperationModel
{
Op = "replace",
Value = JsonDocument.Parse($"{{\"displayName\":\"{newDisplayName}\"}}").RootElement
}
},
Schemas = new List<string>() { ScimConstants.Scim2SchemaGroup }
};
var context = await _factory.GroupsPatchAsync(organizationId, groupId, inputModel);
Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode);
var databaseContext = _factory.GetDatabaseContext();
var group = databaseContext.Groups.FirstOrDefault(g => g.Id == groupId);
Assert.Equal(newDisplayName, group.Name);
Assert.Equal(ScimApplicationFactory.InitialGroupUsersCount, databaseContext.GroupUsers.Count());
Assert.True(databaseContext.GroupUsers.Any(gu => gu.OrganizationUserId == ScimApplicationFactory.TestOrganizationUserId1));
Assert.True(databaseContext.GroupUsers.Any(gu => gu.OrganizationUserId == ScimApplicationFactory.TestOrganizationUserId4));
}
[Fact]
public async Task Patch_ReplaceMembers_Success()
{
var organizationId = ScimApplicationFactory.TestOrganizationId1;
var groupId = ScimApplicationFactory.TestGroupId1;
var inputModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>()
{
new ScimPatchModel.OperationModel
{
Op = "replace",
Path = "members",
Value = JsonDocument.Parse($"[{{\"value\":\"{ScimApplicationFactory.TestOrganizationUserId2}\"}}]").RootElement
}
},
Schemas = new List<string>() { ScimConstants.Scim2SchemaGroup }
};
var context = await _factory.GroupsPatchAsync(organizationId, groupId, inputModel);
Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode);
var databaseContext = _factory.GetDatabaseContext();
Assert.Single(databaseContext.GroupUsers);
Assert.Equal(ScimApplicationFactory.InitialGroupUsersCount - 1, databaseContext.GroupUsers.Count());
var groupUser = databaseContext.GroupUsers.FirstOrDefault();
Assert.Equal(ScimApplicationFactory.TestOrganizationUserId2, groupUser.OrganizationUserId);
}
[Fact]
public async Task Patch_AddSingleMember_Success()
{
var organizationId = ScimApplicationFactory.TestOrganizationId1;
var groupId = ScimApplicationFactory.TestGroupId1;
var inputModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>()
{
new ScimPatchModel.OperationModel
{
Op = "add",
Path = $"members[value eq \"{ScimApplicationFactory.TestOrganizationUserId2}\"]",
Value = JsonDocument.Parse("{}").RootElement
}
},
Schemas = new List<string>() { ScimConstants.Scim2SchemaGroup }
};
var context = await _factory.GroupsPatchAsync(organizationId, groupId, inputModel);
Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode);
var databaseContext = _factory.GetDatabaseContext();
Assert.Equal(ScimApplicationFactory.InitialGroupUsersCount + 1, databaseContext.GroupUsers.Count());
Assert.True(databaseContext.GroupUsers.Any(gu => gu.GroupId == groupId && gu.OrganizationUserId == ScimApplicationFactory.TestOrganizationUserId1));
Assert.True(databaseContext.GroupUsers.Any(gu => gu.GroupId == groupId && gu.OrganizationUserId == ScimApplicationFactory.TestOrganizationUserId2));
Assert.True(databaseContext.GroupUsers.Any(gu => gu.GroupId == groupId && gu.OrganizationUserId == ScimApplicationFactory.TestOrganizationUserId4));
}
[Fact]
public async Task Patch_AddListMembers_Success()
{
var organizationId = ScimApplicationFactory.TestOrganizationId1;
var groupId = ScimApplicationFactory.TestGroupId2;
var inputModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>()
{
new ScimPatchModel.OperationModel
{
Op = "add",
Path = "members",
Value = JsonDocument.Parse($"[{{\"value\":\"{ScimApplicationFactory.TestOrganizationUserId2}\"}},{{\"value\":\"{ScimApplicationFactory.TestOrganizationUserId3}\"}}]").RootElement
}
},
Schemas = new List<string>() { ScimConstants.Scim2SchemaGroup }
};
var context = await _factory.GroupsPatchAsync(organizationId, groupId, inputModel);
Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode);
var databaseContext = _factory.GetDatabaseContext();
Assert.True(databaseContext.GroupUsers.Any(gu => gu.GroupId == groupId && gu.OrganizationUserId == ScimApplicationFactory.TestOrganizationUserId2));
Assert.True(databaseContext.GroupUsers.Any(gu => gu.GroupId == groupId && gu.OrganizationUserId == ScimApplicationFactory.TestOrganizationUserId3));
}
[Fact]
public async Task Patch_RemoveSingleMember_ReplaceDisplayName_Success()
{
var organizationId = ScimApplicationFactory.TestOrganizationId1;
var groupId = ScimApplicationFactory.TestGroupId1;
var newDisplayName = "Patch Display Name";
var inputModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>()
{
new ScimPatchModel.OperationModel
{
Op = "remove",
Path = $"members[value eq \"{ScimApplicationFactory.TestOrganizationUserId1}\"]",
Value = JsonDocument.Parse("{}").RootElement
},
new ScimPatchModel.OperationModel
{
Op = "replace",
Value = JsonDocument.Parse($"{{\"displayName\":\"{newDisplayName}\"}}").RootElement
}
},
Schemas = new List<string>() { ScimConstants.Scim2SchemaGroup }
};
var context = await _factory.GroupsPatchAsync(organizationId, groupId, inputModel);
Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode);
var databaseContext = _factory.GetDatabaseContext();
Assert.Equal(ScimApplicationFactory.InitialGroupUsersCount - 1, databaseContext.GroupUsers.Count());
Assert.Equal(ScimApplicationFactory.InitialGroupCount, databaseContext.Groups.Count());
var group = databaseContext.Groups.FirstOrDefault(g => g.Id == groupId);
Assert.Equal(newDisplayName, group.Name);
}
[Fact]
public async Task Patch_RemoveListMembers_Success()
{
var organizationId = ScimApplicationFactory.TestOrganizationId1;
var groupId = ScimApplicationFactory.TestGroupId1;
var inputModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>()
{
new ScimPatchModel.OperationModel
{
Op = "remove",
Path = "members",
Value = JsonDocument.Parse($"[{{\"value\":\"{ScimApplicationFactory.TestOrganizationUserId1}\"}}, {{\"value\":\"{ScimApplicationFactory.TestOrganizationUserId4}\"}}]").RootElement
}
},
Schemas = new List<string>() { ScimConstants.Scim2SchemaGroup }
};
var context = await _factory.GroupsPatchAsync(organizationId, groupId, inputModel);
Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode);
var databaseContext = _factory.GetDatabaseContext();
Assert.Empty(databaseContext.GroupUsers);
}
[Fact]
public async Task Patch_NotFound()
{
var organizationId = ScimApplicationFactory.TestOrganizationId1;
var groupId = Guid.NewGuid();
var inputModel = new Models.ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>(),
Schemas = new List<string>() { ScimConstants.Scim2SchemaGroup }
};
var expectedResponse = new ScimErrorResponseModel
{
Status = StatusCodes.Status404NotFound,
Detail = "Group not found.",
Schemas = new List<string> { ScimConstants.Scim2SchemaError }
};
var context = await _factory.GroupsPatchAsync(organizationId, groupId, inputModel);
Assert.Equal(StatusCodes.Status404NotFound, context.Response.StatusCode);
var responseModel = JsonSerializer.Deserialize<ScimErrorResponseModel>(context.Response.Body, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
AssertHelper.AssertPropertyEqual(expectedResponse, responseModel);
}
}

View File

@ -9,9 +9,6 @@ namespace Bit.Scim.IntegrationTest.Controllers.v2;
public class GroupsControllerTests : IClassFixture<ScimApplicationFactory>, IAsyncLifetime public class GroupsControllerTests : IClassFixture<ScimApplicationFactory>, IAsyncLifetime
{ {
private const int _initialGroupCount = 3;
private const int _initialGroupUsersCount = 2;
private readonly ScimApplicationFactory _factory; private readonly ScimApplicationFactory _factory;
public GroupsControllerTests(ScimApplicationFactory factory) public GroupsControllerTests(ScimApplicationFactory factory)
@ -237,10 +234,10 @@ public class GroupsControllerTests : IClassFixture<ScimApplicationFactory>, IAsy
AssertHelper.AssertPropertyEqual(expectedResponse, responseModel, "Id"); AssertHelper.AssertPropertyEqual(expectedResponse, responseModel, "Id");
var databaseContext = _factory.GetDatabaseContext(); var databaseContext = _factory.GetDatabaseContext();
Assert.Equal(_initialGroupCount + 1, databaseContext.Groups.Count()); Assert.Equal(ScimApplicationFactory.InitialGroupCount + 1, databaseContext.Groups.Count());
Assert.True(databaseContext.Groups.Any(g => g.Name == displayName && g.ExternalId == externalId)); Assert.True(databaseContext.Groups.Any(g => g.Name == displayName && g.ExternalId == externalId));
Assert.Equal(_initialGroupUsersCount + 1, databaseContext.GroupUsers.Count()); Assert.Equal(ScimApplicationFactory.InitialGroupUsersCount + 1, databaseContext.GroupUsers.Count());
Assert.True(databaseContext.GroupUsers.Any(gu => gu.GroupId == responseModel.Id && gu.OrganizationUserId == ScimApplicationFactory.TestOrganizationUserId1)); Assert.True(databaseContext.GroupUsers.Any(gu => gu.GroupId == responseModel.Id && gu.OrganizationUserId == ScimApplicationFactory.TestOrganizationUserId1));
} }
@ -248,7 +245,7 @@ public class GroupsControllerTests : IClassFixture<ScimApplicationFactory>, IAsy
[InlineData(null)] [InlineData(null)]
[InlineData("")] [InlineData("")]
[InlineData(" ")] [InlineData(" ")]
public async Task Post_InvalidDisplayName_BadRequest(string displayName) public async Task Post_InvalidDisplayName_BadRequest(string? displayName)
{ {
var organizationId = ScimApplicationFactory.TestOrganizationId1; var organizationId = ScimApplicationFactory.TestOrganizationId1;
var model = new ScimGroupRequestModel var model = new ScimGroupRequestModel
@ -281,7 +278,7 @@ public class GroupsControllerTests : IClassFixture<ScimApplicationFactory>, IAsy
Assert.Equal(StatusCodes.Status409Conflict, context.Response.StatusCode); Assert.Equal(StatusCodes.Status409Conflict, context.Response.StatusCode);
var databaseContext = _factory.GetDatabaseContext(); var databaseContext = _factory.GetDatabaseContext();
Assert.Equal(_initialGroupCount, databaseContext.Groups.Count()); Assert.Equal(ScimApplicationFactory.InitialGroupCount, databaseContext.Groups.Count());
Assert.False(databaseContext.Groups.Any(g => g.Name == "New Group")); Assert.False(databaseContext.Groups.Any(g => g.Name == "New Group"));
} }
@ -354,216 +351,6 @@ public class GroupsControllerTests : IClassFixture<ScimApplicationFactory>, IAsy
AssertHelper.AssertPropertyEqual(expectedResponse, responseModel); AssertHelper.AssertPropertyEqual(expectedResponse, responseModel);
} }
[Fact]
public async Task Patch_ReplaceDisplayName_Success()
{
var organizationId = ScimApplicationFactory.TestOrganizationId1;
var groupId = ScimApplicationFactory.TestGroupId1;
var newDisplayName = "Patch Display Name";
var inputModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>()
{
new ScimPatchModel.OperationModel
{
Op = "replace",
Value = JsonDocument.Parse($"{{\"displayName\":\"{newDisplayName}\"}}").RootElement
}
},
Schemas = new List<string>() { ScimConstants.Scim2SchemaGroup }
};
var context = await _factory.GroupsPatchAsync(organizationId, groupId, inputModel);
Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode);
var databaseContext = _factory.GetDatabaseContext();
var group = databaseContext.Groups.FirstOrDefault(g => g.Id == groupId);
Assert.Equal(newDisplayName, group.Name);
Assert.Equal(_initialGroupUsersCount, databaseContext.GroupUsers.Count());
Assert.True(databaseContext.GroupUsers.Any(gu => gu.OrganizationUserId == ScimApplicationFactory.TestOrganizationUserId1));
Assert.True(databaseContext.GroupUsers.Any(gu => gu.OrganizationUserId == ScimApplicationFactory.TestOrganizationUserId4));
}
[Fact]
public async Task Patch_ReplaceMembers_Success()
{
var organizationId = ScimApplicationFactory.TestOrganizationId1;
var groupId = ScimApplicationFactory.TestGroupId1;
var inputModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>()
{
new ScimPatchModel.OperationModel
{
Op = "replace",
Path = "members",
Value = JsonDocument.Parse($"[{{\"value\":\"{ScimApplicationFactory.TestOrganizationUserId2}\"}}]").RootElement
}
},
Schemas = new List<string>() { ScimConstants.Scim2SchemaGroup }
};
var context = await _factory.GroupsPatchAsync(organizationId, groupId, inputModel);
Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode);
var databaseContext = _factory.GetDatabaseContext();
Assert.Single(databaseContext.GroupUsers);
Assert.Equal(_initialGroupUsersCount - 1, databaseContext.GroupUsers.Count());
var groupUser = databaseContext.GroupUsers.FirstOrDefault();
Assert.Equal(ScimApplicationFactory.TestOrganizationUserId2, groupUser.OrganizationUserId);
}
[Fact]
public async Task Patch_AddSingleMember_Success()
{
var organizationId = ScimApplicationFactory.TestOrganizationId1;
var groupId = ScimApplicationFactory.TestGroupId1;
var inputModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>()
{
new ScimPatchModel.OperationModel
{
Op = "add",
Path = $"members[value eq \"{ScimApplicationFactory.TestOrganizationUserId2}\"]",
Value = JsonDocument.Parse("{}").RootElement
}
},
Schemas = new List<string>() { ScimConstants.Scim2SchemaGroup }
};
var context = await _factory.GroupsPatchAsync(organizationId, groupId, inputModel);
Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode);
var databaseContext = _factory.GetDatabaseContext();
Assert.Equal(_initialGroupUsersCount + 1, databaseContext.GroupUsers.Count());
Assert.True(databaseContext.GroupUsers.Any(gu => gu.GroupId == groupId && gu.OrganizationUserId == ScimApplicationFactory.TestOrganizationUserId1));
Assert.True(databaseContext.GroupUsers.Any(gu => gu.GroupId == groupId && gu.OrganizationUserId == ScimApplicationFactory.TestOrganizationUserId2));
Assert.True(databaseContext.GroupUsers.Any(gu => gu.GroupId == groupId && gu.OrganizationUserId == ScimApplicationFactory.TestOrganizationUserId4));
}
[Fact]
public async Task Patch_AddListMembers_Success()
{
var organizationId = ScimApplicationFactory.TestOrganizationId1;
var groupId = ScimApplicationFactory.TestGroupId2;
var inputModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>()
{
new ScimPatchModel.OperationModel
{
Op = "add",
Path = "members",
Value = JsonDocument.Parse($"[{{\"value\":\"{ScimApplicationFactory.TestOrganizationUserId2}\"}},{{\"value\":\"{ScimApplicationFactory.TestOrganizationUserId3}\"}}]").RootElement
}
},
Schemas = new List<string>() { ScimConstants.Scim2SchemaGroup }
};
var context = await _factory.GroupsPatchAsync(organizationId, groupId, inputModel);
Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode);
var databaseContext = _factory.GetDatabaseContext();
Assert.True(databaseContext.GroupUsers.Any(gu => gu.GroupId == groupId && gu.OrganizationUserId == ScimApplicationFactory.TestOrganizationUserId2));
Assert.True(databaseContext.GroupUsers.Any(gu => gu.GroupId == groupId && gu.OrganizationUserId == ScimApplicationFactory.TestOrganizationUserId3));
}
[Fact]
public async Task Patch_RemoveSingleMember_ReplaceDisplayName_Success()
{
var organizationId = ScimApplicationFactory.TestOrganizationId1;
var groupId = ScimApplicationFactory.TestGroupId1;
var newDisplayName = "Patch Display Name";
var inputModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>()
{
new ScimPatchModel.OperationModel
{
Op = "remove",
Path = $"members[value eq \"{ScimApplicationFactory.TestOrganizationUserId1}\"]",
Value = JsonDocument.Parse("{}").RootElement
},
new ScimPatchModel.OperationModel
{
Op = "replace",
Value = JsonDocument.Parse($"{{\"displayName\":\"{newDisplayName}\"}}").RootElement
}
},
Schemas = new List<string>() { ScimConstants.Scim2SchemaGroup }
};
var context = await _factory.GroupsPatchAsync(organizationId, groupId, inputModel);
Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode);
var databaseContext = _factory.GetDatabaseContext();
Assert.Equal(_initialGroupUsersCount - 1, databaseContext.GroupUsers.Count());
Assert.Equal(_initialGroupCount, databaseContext.Groups.Count());
var group = databaseContext.Groups.FirstOrDefault(g => g.Id == groupId);
Assert.Equal(newDisplayName, group.Name);
}
[Fact]
public async Task Patch_RemoveListMembers_Success()
{
var organizationId = ScimApplicationFactory.TestOrganizationId1;
var groupId = ScimApplicationFactory.TestGroupId1;
var inputModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>()
{
new ScimPatchModel.OperationModel
{
Op = "remove",
Path = "members",
Value = JsonDocument.Parse($"[{{\"value\":\"{ScimApplicationFactory.TestOrganizationUserId1}\"}}, {{\"value\":\"{ScimApplicationFactory.TestOrganizationUserId4}\"}}]").RootElement
}
},
Schemas = new List<string>() { ScimConstants.Scim2SchemaGroup }
};
var context = await _factory.GroupsPatchAsync(organizationId, groupId, inputModel);
Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode);
var databaseContext = _factory.GetDatabaseContext();
Assert.Empty(databaseContext.GroupUsers);
}
[Fact]
public async Task Patch_NotFound()
{
var organizationId = ScimApplicationFactory.TestOrganizationId1;
var groupId = Guid.NewGuid();
var inputModel = new Models.ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>(),
Schemas = new List<string>() { ScimConstants.Scim2SchemaGroup }
};
var expectedResponse = new ScimErrorResponseModel
{
Status = StatusCodes.Status404NotFound,
Detail = "Group not found.",
Schemas = new List<string> { ScimConstants.Scim2SchemaError }
};
var context = await _factory.GroupsPatchAsync(organizationId, groupId, inputModel);
Assert.Equal(StatusCodes.Status404NotFound, context.Response.StatusCode);
var responseModel = JsonSerializer.Deserialize<ScimErrorResponseModel>(context.Response.Body, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
AssertHelper.AssertPropertyEqual(expectedResponse, responseModel);
}
[Fact] [Fact]
public async Task Delete_Success() public async Task Delete_Success()
{ {
@ -575,7 +362,7 @@ public class GroupsControllerTests : IClassFixture<ScimApplicationFactory>, IAsy
Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode); Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode);
var databaseContext = _factory.GetDatabaseContext(); var databaseContext = _factory.GetDatabaseContext();
Assert.Equal(_initialGroupCount - 1, databaseContext.Groups.Count()); Assert.Equal(ScimApplicationFactory.InitialGroupCount - 1, databaseContext.Groups.Count());
Assert.True(databaseContext.Groups.FirstOrDefault(g => g.Id == groupId) == null); Assert.True(databaseContext.Groups.FirstOrDefault(g => g.Id == groupId) == null);
} }

View File

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

View File

@ -9,8 +9,6 @@ using Bit.Infrastructure.EntityFramework.Repositories;
using Bit.IntegrationTestCommon.Factories; using Bit.IntegrationTestCommon.Factories;
using Bit.Scim.Models; using Bit.Scim.Models;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Microsoft.Net.Http.Headers; using Microsoft.Net.Http.Headers;
@ -18,7 +16,8 @@ namespace Bit.Scim.IntegrationTest.Factories;
public class ScimApplicationFactory : WebApplicationFactoryBase<Startup> public class ScimApplicationFactory : WebApplicationFactoryBase<Startup>
{ {
public readonly new TestServer Server; public const int InitialGroupCount = 3;
public const int InitialGroupUsersCount = 2;
public static readonly Guid TestUserId1 = Guid.Parse("2e8173db-8e8d-4de1-ac38-91b15c6d8dcb"); public static readonly Guid TestUserId1 = Guid.Parse("2e8173db-8e8d-4de1-ac38-91b15c6d8dcb");
public static readonly Guid TestUserId2 = Guid.Parse("b57846fc-0e94-4c93-9de5-9d0389eeadfb"); public static readonly Guid TestUserId2 = Guid.Parse("b57846fc-0e94-4c93-9de5-9d0389eeadfb");
@ -33,10 +32,10 @@ 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 services
@ -56,9 +55,6 @@ public class ScimApplicationFactory : WebApplicationFactoryBase<Startup>
services.Remove(mailService); services.Remove(mailService);
services.AddSingleton<IMailService, NoopMailService>(); services.AddSingleton<IMailService, NoopMailService>();
}); });
});
Server = webApplicationFactory.Server;
} }
public async Task<HttpContext> GroupsGetAsync(Guid organizationId, Guid id) public async Task<HttpContext> GroupsGetAsync(Guid organizationId, Guid id)

View File

@ -0,0 +1,381 @@
using System.Text.Json;
using AutoFixture;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using Bit.Scim.Groups;
using Bit.Scim.Models;
using Bit.Scim.Utilities;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using Xunit;
namespace Bit.Scim.Test.Groups;
[SutProviderCustomize]
public class PatchGroupCommandvNextTests
{
[Theory]
[BitAutoData]
public async Task PatchGroup_ReplaceListMembers_Success(SutProvider<PatchGroupCommandvNext> sutProvider,
Organization organization, Group group, IEnumerable<Guid> userIds)
{
group.OrganizationId = organization.Id;
var scimPatchModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>
{
new()
{
Op = "replace",
Path = "members",
Value = JsonDocument.Parse(JsonSerializer.Serialize(userIds.Select(uid => new { value = uid }).ToArray())).RootElement
}
},
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
await sutProvider.GetDependency<IGroupRepository>().Received(1).UpdateUsersAsync(
group.Id,
Arg.Is<IEnumerable<Guid>>(arg =>
arg.Count() == userIds.Count() &&
arg.ToHashSet().SetEquals(userIds)));
}
[Theory]
[BitAutoData]
public async Task PatchGroup_ReplaceDisplayNameFromPath_Success(
SutProvider<PatchGroupCommandvNext> sutProvider, Organization organization, Group group, string displayName)
{
group.OrganizationId = organization.Id;
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(organization.Id)
.Returns(organization);
var scimPatchModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>
{
new()
{
Op = "replace",
Path = "displayname",
Value = JsonDocument.Parse($"\"{displayName}\"").RootElement
}
},
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
await sutProvider.GetDependency<IUpdateGroupCommand>().Received(1).UpdateGroupAsync(group, organization, EventSystemUser.SCIM);
Assert.Equal(displayName, group.Name);
}
[Theory]
[BitAutoData]
public async Task PatchGroup_ReplaceDisplayNameFromValueObject_Success(SutProvider<PatchGroupCommandvNext> sutProvider, Organization organization, Group group, string displayName)
{
group.OrganizationId = organization.Id;
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(organization.Id)
.Returns(organization);
var scimPatchModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>
{
new()
{
Op = "replace",
Value = JsonDocument.Parse($"{{\"displayName\":\"{displayName}\"}}").RootElement
}
},
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
await sutProvider.GetDependency<IUpdateGroupCommand>().Received(1).UpdateGroupAsync(group, organization, EventSystemUser.SCIM);
Assert.Equal(displayName, group.Name);
}
[Theory]
[BitAutoData]
public async Task PatchGroup_AddSingleMember_Success(SutProvider<PatchGroupCommandvNext> sutProvider, Organization organization, Group group, ICollection<Guid> existingMembers, Guid userId)
{
group.OrganizationId = organization.Id;
sutProvider.GetDependency<IGroupRepository>()
.GetManyUserIdsByIdAsync(group.Id, true)
.Returns(existingMembers);
var scimPatchModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>
{
new()
{
Op = "add",
Path = $"members[value eq \"{userId}\"]",
}
},
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
await sutProvider.GetDependency<IGroupRepository>().Received(1).AddGroupUsersByIdAsync(
group.Id,
Arg.Is<IEnumerable<Guid>>(arg => arg.Single() == userId));
}
[Theory]
[BitAutoData]
public async Task PatchGroup_AddSingleMember_ReturnsEarlyIfAlreadyInGroup(
SutProvider<PatchGroupCommandvNext> sutProvider,
Organization organization,
Group group,
ICollection<Guid> existingMembers)
{
// User being added is already in group
var userId = existingMembers.First();
group.OrganizationId = organization.Id;
sutProvider.GetDependency<IGroupRepository>()
.GetManyUserIdsByIdAsync(group.Id, true)
.Returns(existingMembers);
var scimPatchModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>
{
new()
{
Op = "add",
Path = $"members[value eq \"{userId}\"]",
}
},
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
await sutProvider.GetDependency<IGroupRepository>()
.DidNotReceiveWithAnyArgs()
.AddGroupUsersByIdAsync(default, default);
}
[Theory]
[BitAutoData]
public async Task PatchGroup_AddListMembers_Success(SutProvider<PatchGroupCommandvNext> sutProvider, Organization organization, Group group, ICollection<Guid> existingMembers, ICollection<Guid> userIds)
{
group.OrganizationId = organization.Id;
sutProvider.GetDependency<IGroupRepository>()
.GetManyUserIdsByIdAsync(group.Id, true)
.Returns(existingMembers);
var scimPatchModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>
{
new()
{
Op = "add",
Path = $"members",
Value = JsonDocument.Parse(JsonSerializer.Serialize(userIds.Select(uid => new { value = uid }).ToArray())).RootElement
}
},
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
await sutProvider.GetDependency<IGroupRepository>().Received(1).AddGroupUsersByIdAsync(
group.Id,
Arg.Is<IEnumerable<Guid>>(arg =>
arg.Count() == userIds.Count &&
arg.ToHashSet().SetEquals(userIds)));
}
[Theory]
[BitAutoData]
public async Task PatchGroup_AddListMembers_IgnoresDuplicatesInRequest(
SutProvider<PatchGroupCommandvNext> sutProvider, Organization organization, Group group,
ICollection<Guid> existingMembers)
{
// Create 3 userIds
var fixture = new Fixture { RepeatCount = 3 };
var userIds = fixture.CreateMany<Guid>().ToList();
// Copy the list and add a duplicate
var userIdsWithDuplicate = userIds.Append(userIds.First()).ToList();
Assert.Equal(4, userIdsWithDuplicate.Count);
group.OrganizationId = organization.Id;
sutProvider.GetDependency<IGroupRepository>()
.GetManyUserIdsByIdAsync(group.Id, true)
.Returns(existingMembers);
var scimPatchModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>
{
new()
{
Op = "add",
Path = $"members",
Value = JsonDocument.Parse(JsonSerializer
.Serialize(userIdsWithDuplicate
.Select(uid => new { value = uid })
.ToArray())).RootElement
}
},
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
await sutProvider.GetDependency<IGroupRepository>().Received(1).AddGroupUsersByIdAsync(
group.Id,
Arg.Is<IEnumerable<Guid>>(arg =>
arg.Count() == 3 &&
arg.ToHashSet().SetEquals(userIds)));
}
[Theory]
[BitAutoData]
public async Task PatchGroup_AddListMembers_SuccessIfOnlySomeUsersAreInGroup(
SutProvider<PatchGroupCommandvNext> sutProvider,
Organization organization, Group group,
ICollection<Guid> existingMembers,
ICollection<Guid> userIds)
{
// A user is already in the group, but some still need to be added
userIds.Add(existingMembers.First());
group.OrganizationId = organization.Id;
sutProvider.GetDependency<IGroupRepository>()
.GetManyUserIdsByIdAsync(group.Id, true)
.Returns(existingMembers);
var scimPatchModel = new ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>
{
new()
{
Op = "add",
Path = $"members",
Value = JsonDocument.Parse(JsonSerializer.Serialize(userIds.Select(uid => new { value = uid }).ToArray())).RootElement
}
},
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
await sutProvider.GetDependency<IGroupRepository>()
.Received(1)
.AddGroupUsersByIdAsync(
group.Id,
Arg.Is<IEnumerable<Guid>>(arg =>
arg.Count() == userIds.Count &&
arg.ToHashSet().SetEquals(userIds)));
}
[Theory]
[BitAutoData]
public async Task PatchGroup_RemoveSingleMember_Success(SutProvider<PatchGroupCommandvNext> sutProvider, Organization organization, Group group, Guid userId)
{
group.OrganizationId = organization.Id;
var scimPatchModel = new Models.ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>
{
new ScimPatchModel.OperationModel
{
Op = "remove",
Path = $"members[value eq \"{userId}\"]",
}
},
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
await sutProvider.GetDependency<IGroupService>().Received(1).DeleteUserAsync(group, userId, EventSystemUser.SCIM);
}
[Theory]
[BitAutoData]
public async Task PatchGroup_RemoveListMembers_Success(SutProvider<PatchGroupCommandvNext> sutProvider,
Organization organization, Group group, ICollection<Guid> existingMembers)
{
List<Guid> usersToRemove = [existingMembers.First(), existingMembers.Skip(1).First()];
group.OrganizationId = organization.Id;
sutProvider.GetDependency<IGroupRepository>()
.GetManyUserIdsByIdAsync(group.Id)
.Returns(existingMembers);
var scimPatchModel = new Models.ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>
{
new()
{
Op = "remove",
Path = $"members",
Value = JsonDocument.Parse(JsonSerializer.Serialize(usersToRemove.Select(uid => new { value = uid }).ToArray())).RootElement
}
},
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
var expectedRemainingUsers = existingMembers.Skip(2).ToList();
await sutProvider.GetDependency<IGroupRepository>()
.Received(1)
.UpdateUsersAsync(
group.Id,
Arg.Is<IEnumerable<Guid>>(arg =>
arg.Count() == expectedRemainingUsers.Count &&
arg.ToHashSet().SetEquals(expectedRemainingUsers)));
}
[Theory]
[BitAutoData]
public async Task PatchGroup_NoAction_Success(
SutProvider<PatchGroupCommandvNext> sutProvider, Organization organization, Group group)
{
group.OrganizationId = organization.Id;
var scimPatchModel = new Models.ScimPatchModel
{
Operations = new List<ScimPatchModel.OperationModel>(),
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};
await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel);
await sutProvider.GetDependency<IGroupRepository>().DidNotReceiveWithAnyArgs().UpdateUsersAsync(default, default);
await sutProvider.GetDependency<IGroupRepository>().DidNotReceiveWithAnyArgs().GetManyUserIdsByIdAsync(default);
await sutProvider.GetDependency<IUpdateGroupCommand>().DidNotReceiveWithAnyArgs().UpdateGroupAsync(default, default);
await sutProvider.GetDependency<IGroupService>().DidNotReceiveWithAnyArgs().DeleteUserAsync(default, default);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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"
}
}
}

View File

@ -16,7 +16,6 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Folder Include="Billing\Controllers\" /> <Folder Include="Billing\Controllers\" />
<Folder Include="Billing\Models\" />
</ItemGroup> </ItemGroup>
<Choose> <Choose>

View File

@ -10,6 +10,7 @@ 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.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;
@ -56,8 +57,8 @@ public class OrganizationsController : Controller
private readonly IProviderOrganizationRepository _providerOrganizationRepository; private readonly IProviderOrganizationRepository _providerOrganizationRepository;
private readonly IRemoveOrganizationFromProviderCommand _removeOrganizationFromProviderCommand; private readonly IRemoveOrganizationFromProviderCommand _removeOrganizationFromProviderCommand;
private readonly IProviderBillingService _providerBillingService; private readonly IProviderBillingService _providerBillingService;
private readonly IFeatureService _featureService;
private readonly IOrganizationInitiateDeleteCommand _organizationInitiateDeleteCommand; private readonly IOrganizationInitiateDeleteCommand _organizationInitiateDeleteCommand;
private readonly IPricingClient _pricingClient;
public OrganizationsController( public OrganizationsController(
IOrganizationService organizationService, IOrganizationService organizationService,
@ -84,8 +85,8 @@ public class OrganizationsController : Controller
IProviderOrganizationRepository providerOrganizationRepository, IProviderOrganizationRepository providerOrganizationRepository,
IRemoveOrganizationFromProviderCommand removeOrganizationFromProviderCommand, IRemoveOrganizationFromProviderCommand removeOrganizationFromProviderCommand,
IProviderBillingService providerBillingService, IProviderBillingService providerBillingService,
IFeatureService featureService, IOrganizationInitiateDeleteCommand organizationInitiateDeleteCommand,
IOrganizationInitiateDeleteCommand organizationInitiateDeleteCommand) IPricingClient pricingClient)
{ {
_organizationService = organizationService; _organizationService = organizationService;
_organizationRepository = organizationRepository; _organizationRepository = organizationRepository;
@ -111,8 +112,8 @@ public class OrganizationsController : Controller
_providerOrganizationRepository = providerOrganizationRepository; _providerOrganizationRepository = providerOrganizationRepository;
_removeOrganizationFromProviderCommand = removeOrganizationFromProviderCommand; _removeOrganizationFromProviderCommand = removeOrganizationFromProviderCommand;
_providerBillingService = providerBillingService; _providerBillingService = providerBillingService;
_featureService = featureService;
_organizationInitiateDeleteCommand = organizationInitiateDeleteCommand; _organizationInitiateDeleteCommand = organizationInitiateDeleteCommand;
_pricingClient = pricingClient;
} }
[RequirePermission(Permission.Org_List_View)] [RequirePermission(Permission.Org_List_View)]
@ -212,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,
@ -224,6 +227,7 @@ public class OrganizationsController : Controller
billingHistoryInfo, billingHistoryInfo,
billingSyncConnection, billingSyncConnection,
_globalSettings, _globalSettings,
plans,
secrets, secrets,
projects, projects,
serviceAccounts, serviceAccounts,
@ -253,8 +257,9 @@ public class OrganizationsController : Controller
UpdateOrganization(organization, model); UpdateOrganization(organization, model);
if (organization.UseSecretsManager && var plan = await _pricingClient.GetPlanOrThrow(organization.PlanType);
!StaticStore.GetPlan(organization.PlanType).SupportsSecretsManager)
if (organization.UseSecretsManager && !plan.SupportsSecretsManager)
{ {
TempData["Error"] = "Plan does not support Secrets Manager"; TempData["Error"] = "Plan does not support Secrets Manager";
return RedirectToAction("Edit", new { id }); return RedirectToAction("Edit", new { id });
@ -309,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)
@ -421,6 +426,11 @@ public class OrganizationsController : Controller
private void UpdateOrganization(Organization organization, OrganizationEditModel model) private void UpdateOrganization(Organization organization, OrganizationEditModel model)
{ {
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))
{ {
organization.Enabled = model.Enabled; organization.Enabled = model.Enabled;

View File

@ -3,7 +3,6 @@ using System.Net;
using Bit.Admin.AdminConsole.Models; using Bit.Admin.AdminConsole.Models;
using Bit.Admin.Enums; using Bit.Admin.Enums;
using Bit.Admin.Utilities; using Bit.Admin.Utilities;
using Bit.Core;
using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Providers.Interfaces;
@ -133,11 +132,6 @@ public class ProvidersController : Controller
[HttpGet("providers/create/multi-organization-enterprise")] [HttpGet("providers/create/multi-organization-enterprise")]
public IActionResult CreateMultiOrganizationEnterprise(int enterpriseMinimumSeats, string ownerEmail = null) public IActionResult CreateMultiOrganizationEnterprise(int enterpriseMinimumSeats, string ownerEmail = null)
{ {
if (!_featureService.IsEnabled(FeatureFlagKeys.PM12275_MultiOrganizationEnterprises))
{
return RedirectToAction("Create");
}
return View(new CreateMultiOrganizationEnterpriseProviderModel return View(new CreateMultiOrganizationEnterpriseProviderModel
{ {
OwnerEmail = ownerEmail, OwnerEmail = ownerEmail,
@ -211,10 +205,6 @@ public class ProvidersController : Controller
} }
var provider = model.ToProvider(); var provider = model.ToProvider();
if (!_featureService.IsEnabled(FeatureFlagKeys.PM12275_MultiOrganizationEnterprises))
{
return RedirectToAction("Create");
}
await _createProviderCommand.CreateMultiOrganizationEnterpriseAsync( await _createProviderCommand.CreateMultiOrganizationEnterpriseAsync(
provider, provider,
model.OwnerEmail, model.OwnerEmail,
@ -235,7 +225,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)]
@ -250,6 +241,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)]

View File

@ -8,6 +8,7 @@ using Bit.Core.Billing.Models;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Models.StaticStore;
using Bit.Core.Settings; using Bit.Core.Settings;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Bit.Core.Vault.Entities; using Bit.Core.Vault.Entities;
@ -17,6 +18,8 @@ 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)
@ -40,6 +43,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,
@ -96,6 +100,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; }
@ -183,7 +189,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
.Select(p => .Select(p =>
{ {
var plan = new var plan = new

View File

@ -19,7 +19,7 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject
IEnumerable<ProviderOrganizationOrganizationDetails> organizations, IEnumerable<ProviderOrganizationOrganizationDetails> organizations,
IReadOnlyCollection<ProviderPlan> providerPlans, IReadOnlyCollection<ProviderPlan> providerPlans,
string gatewayCustomerUrl = null, string gatewayCustomerUrl = null,
string gatewaySubscriptionUrl = null) : base(provider, providerUsers, organizations) string gatewaySubscriptionUrl = null) : base(provider, providerUsers, organizations, providerPlans)
{ {
Name = provider.DisplayName(); Name = provider.DisplayName();
BusinessName = provider.DisplayBusinessName(); BusinessName = provider.DisplayBusinessName();

View File

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

View File

@ -12,11 +12,6 @@
var providerTypes = Enum.GetValues<ProviderType>() var providerTypes = Enum.GetValues<ProviderType>()
.OrderBy(x => x.GetDisplayAttribute().Order) .OrderBy(x => x.GetDisplayAttribute().Order)
.ToList(); .ToList();
if (!FeatureService.IsEnabled(FeatureFlagKeys.PM12275_MultiOrganizationEnterprises))
{
providerTypes.Remove(ProviderType.MultiOrganizationEnterprise);
}
} }
<h1>Create Provider</h1> <h1>Create Provider</h1>

View File

@ -19,7 +19,7 @@
<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="ms-auto d-flex"> <div class="ms-auto d-flex">
<form asp-controller="Providers" asp-action="Edit" asp-route-id="@Model.Provider.Id" <form asp-controller="Providers" asp-action="Cancel" asp-route-id="@Model.Provider.Id"
onsubmit="return confirm('Are you sure you want to cancel?')"> onsubmit="return confirm('Are you sure you want to cancel?')">
<button class="btn btn-outline-secondary" type="submit">Cancel</button> <button class="btn btn-outline-secondary" type="submit">Cancel</button>
</form> </form>

View File

@ -17,6 +17,10 @@
<h2>Provider Information</h2> <h2>Provider Information</h2>
@await Html.PartialAsync("_ViewInformation", Model) @await Html.PartialAsync("_ViewInformation", Model)
@if (Model.ProviderPlanViewModels.Any())
{
@await Html.PartialAsync("~/Billing/Views/Providers/ProviderPlans.cshtml", Model.ProviderPlanViewModels)
}
@await Html.PartialAsync("Admins", Model) @await Html.PartialAsync("Admins", Model)
<form method="post" id="edit-form"> <form method="post" id="edit-form">
<div asp-validation-summary="All" class="alert alert-danger"></div> <div asp-validation-summary="All" class="alert alert-danger"></div>
@ -71,8 +75,6 @@
break; break;
} }
case ProviderType.MultiOrganizationEnterprise: case ProviderType.MultiOrganizationEnterprise:
{
@if (FeatureService.IsEnabled(FeatureFlagKeys.PM12275_MultiOrganizationEnterprises) && Model.Provider.Type == ProviderType.MultiOrganizationEnterprise)
{ {
<div class="row"> <div class="row">
<div class="col-sm"> <div class="col-sm">
@ -97,7 +99,6 @@
</div> </div>
</div> </div>
</div> </div>
}
break; break;
} }
} }

View File

@ -7,5 +7,9 @@
<h2>Information</h2> <h2>Information</h2>
@await Html.PartialAsync("_ViewInformation", Model) @await Html.PartialAsync("_ViewInformation", Model)
@if (Model.ProviderPlanViewModels.Any())
{
@await Html.PartialAsync("ProviderPlans", Model.ProviderPlanViewModels)
}
@await Html.PartialAsync("Admins", Model) @await Html.PartialAsync("Admins", Model)
@await Html.PartialAsync("Organizations", Model) @await Html.PartialAsync("Organizations", Model)

View File

@ -12,6 +12,7 @@
var canViewBilling = AccessControlService.UserHasPermission(Permission.Org_Billing_View); var canViewBilling = AccessControlService.UserHasPermission(Permission.Org_Billing_View);
var canViewPlan = AccessControlService.UserHasPermission(Permission.Org_Plan_View); var canViewPlan = AccessControlService.UserHasPermission(Permission.Org_Plan_View);
var canViewLicensing = AccessControlService.UserHasPermission(Permission.Org_Licensing_View); var canViewLicensing = AccessControlService.UserHasPermission(Permission.Org_Licensing_View);
var canEditName = AccessControlService.UserHasPermission(Permission.Org_Name_Edit);
var canCheckEnabled = AccessControlService.UserHasPermission(Permission.Org_CheckEnabledBox); var canCheckEnabled = AccessControlService.UserHasPermission(Permission.Org_CheckEnabledBox);
var canEditPlan = AccessControlService.UserHasPermission(Permission.Org_Plan_Edit); var canEditPlan = AccessControlService.UserHasPermission(Permission.Org_Plan_Edit);
var canEditLicensing = AccessControlService.UserHasPermission(Permission.Org_Licensing_Edit); var canEditLicensing = AccessControlService.UserHasPermission(Permission.Org_Licensing_Edit);
@ -28,7 +29,7 @@
<div class="col-sm"> <div class="col-sm">
<div class="mb-3"> <div class="mb-3">
<label class="form-label" asp-for="Name"></label> <label class="form-label" asp-for="Name"></label>
<input type="text" class="form-control" asp-for="Name" value="@Model.Name" required> <input type="text" class="form-control" asp-for="Name" value="@Model.Name" required disabled="@(canEditName ? null : "disabled")">
</div> </div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,26 @@
using Bit.Core.Billing.Entities;
namespace Bit.Admin.Billing.Models;
public class ProviderPlanViewModel
{
public string Name { get; set; }
public int PurchasedSeats { get; set; }
public int AssignedSeats { get; set; }
public int UsedSeats { get; set; }
public int RemainingSeats { get; set; }
public ProviderPlanViewModel(
string name,
ProviderPlan providerPlan,
int usedSeats)
{
var purchasedSeats = (providerPlan.SeatMinimum ?? 0) + (providerPlan.PurchasedSeats ?? 0);
Name = name;
PurchasedSeats = purchasedSeats;
AssignedSeats = providerPlan.AllocatedSeats ?? 0;
UsedSeats = usedSeats;
RemainingSeats = purchasedSeats - AssignedSeats;
}
}

View File

@ -0,0 +1,18 @@
@model List<Bit.Admin.Billing.Models.ProviderPlanViewModel>
@foreach (var plan in Model)
{
<h2>@plan.Name</h2>
<dl class="row">
<dt class="col-sm-4 col-lg-3">Purchased Seats</dt>
<dd class="col-sm-8 col-lg-9">@plan.PurchasedSeats</dd>
<dt class="col-sm-4 col-lg-3">Assigned Seats</dt>
<dd class="col-sm-8 col-lg-9">@plan.AssignedSeats</dd>
<dt class="col-sm-4 col-lg-3">Used Seats</dt>
<dd class="col-sm-8 col-lg-9">@plan.UsedSeats</dd>
<dt class="col-sm-4 col-lg-3">Remaining Seats</dt>
<dd class="col-sm-8 col-lg-9">@plan.RemainingSeats</dd>
</dl>
}

View File

@ -102,12 +102,13 @@ public class UsersController : Controller
return RedirectToAction("Index"); return RedirectToAction("Index");
} }
var ciphers = await _cipherRepository.GetManyByUserIdAsync(id); var ciphers = await _cipherRepository.GetManyByUserIdAsync(id, withOrganizations: false);
var billingInfo = await _paymentService.GetBillingAsync(user); var billingInfo = await _paymentService.GetBillingAsync(user);
var billingHistoryInfo = await _paymentService.GetBillingHistoryAsync(user); var billingHistoryInfo = await _paymentService.GetBillingHistoryAsync(user);
var isTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user); var isTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user);
var verifiedDomain = await AccountDeprovisioningEnabled(user.Id); var verifiedDomain = await AccountDeprovisioningEnabled(user.Id);
var deviceVerificationRequired = await _userService.ActiveNewDeviceVerificationException(user.Id); var deviceVerificationRequired = await _userService.ActiveNewDeviceVerificationException(user.Id);
return View(new UserEditModel(user, isTwoFactorEnabled, ciphers, billingInfo, billingHistoryInfo, _globalSettings, verifiedDomain, deviceVerificationRequired)); return View(new UserEditModel(user, isTwoFactorEnabled, ciphers, billingInfo, billingHistoryInfo, _globalSettings, verifiedDomain, deviceVerificationRequired));
} }

View File

@ -22,6 +22,7 @@ public enum Permission
Org_List_View, Org_List_View,
Org_OrgInformation_View, Org_OrgInformation_View,
Org_GeneralDetails_View, Org_GeneralDetails_View,
Org_Name_Edit,
Org_CheckEnabledBox, Org_CheckEnabledBox,
Org_BusinessInformation_View, Org_BusinessInformation_View,
Org_InitiateTrial, Org_InitiateTrial,

View File

@ -24,6 +24,7 @@ public static class RolePermissionMapping
Permission.User_Billing_Edit, Permission.User_Billing_Edit,
Permission.User_Billing_LaunchGateway, Permission.User_Billing_LaunchGateway,
Permission.User_NewDeviceException_Edit, Permission.User_NewDeviceException_Edit,
Permission.Org_Name_Edit,
Permission.Org_CheckEnabledBox, Permission.Org_CheckEnabledBox,
Permission.Org_List_View, Permission.Org_List_View,
Permission.Org_OrgInformation_View, Permission.Org_OrgInformation_View,
@ -71,6 +72,7 @@ public static class RolePermissionMapping
Permission.User_Billing_Edit, Permission.User_Billing_Edit,
Permission.User_Billing_LaunchGateway, Permission.User_Billing_LaunchGateway,
Permission.User_NewDeviceException_Edit, Permission.User_NewDeviceException_Edit,
Permission.Org_Name_Edit,
Permission.Org_CheckEnabledBox, Permission.Org_CheckEnabledBox,
Permission.Org_List_View, Permission.Org_List_View,
Permission.Org_OrgInformation_View, Permission.Org_OrgInformation_View,
@ -116,6 +118,7 @@ public static class RolePermissionMapping
Permission.User_Billing_View, Permission.User_Billing_View,
Permission.User_Billing_LaunchGateway, Permission.User_Billing_LaunchGateway,
Permission.User_NewDeviceException_Edit, Permission.User_NewDeviceException_Edit,
Permission.Org_Name_Edit,
Permission.Org_CheckEnabledBox, Permission.Org_CheckEnabledBox,
Permission.Org_List_View, Permission.Org_List_View,
Permission.Org_OrgInformation_View, Permission.Org_OrgInformation_View,
@ -148,6 +151,7 @@ public static class RolePermissionMapping
Permission.User_Billing_View, Permission.User_Billing_View,
Permission.User_Billing_Edit, Permission.User_Billing_Edit,
Permission.User_Billing_LaunchGateway, Permission.User_Billing_LaunchGateway,
Permission.Org_Name_Edit,
Permission.Org_CheckEnabledBox, Permission.Org_CheckEnabledBox,
Permission.Org_List_View, Permission.Org_List_View,
Permission.Org_OrgInformation_View, Permission.Org_OrgInformation_View,
@ -185,6 +189,7 @@ public static class RolePermissionMapping
Permission.User_Premium_View, Permission.User_Premium_View,
Permission.User_Licensing_View, Permission.User_Licensing_View,
Permission.User_Licensing_Edit, Permission.User_Licensing_Edit,
Permission.Org_Name_Edit,
Permission.Org_CheckEnabledBox, Permission.Org_CheckEnabledBox,
Permission.Org_List_View, Permission.Org_List_View,
Permission.Org_OrgInformation_View, Permission.Org_OrgInformation_View,

View File

@ -9,8 +9,7 @@
var canViewUserInformation = AccessControlService.UserHasPermission(Permission.User_UserInformation_View); var canViewUserInformation = AccessControlService.UserHasPermission(Permission.User_UserInformation_View);
var canViewNewDeviceException = AccessControlService.UserHasPermission(Permission.User_NewDeviceException_Edit) && var canViewNewDeviceException = AccessControlService.UserHasPermission(Permission.User_NewDeviceException_Edit) &&
GlobalSettings.EnableNewDeviceVerification && GlobalSettings.EnableNewDeviceVerification;
FeatureService.IsEnabled(Bit.Core.FeatureFlagKeys.NewDeviceVerification);
var canViewBillingInformation = AccessControlService.UserHasPermission(Permission.User_BillingInformation_View); var canViewBillingInformation = AccessControlService.UserHasPermission(Permission.User_BillingInformation_View);
var canViewGeneral = AccessControlService.UserHasPermission(Permission.User_GeneralDetails_View); var canViewGeneral = AccessControlService.UserHasPermission(Permission.User_GeneralDetails_View);
var canViewPremium = AccessControlService.UserHasPermission(Permission.User_Premium_View); var canViewPremium = AccessControlService.UserHasPermission(Permission.User_Premium_View);

View File

@ -18,7 +18,7 @@
"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.2", "mini-css-extract-plugin": "2.9.2",
"sass": "1.79.5", "sass": "1.85.0",
"sass-loader": "16.0.4", "sass-loader": "16.0.4",
"webpack": "5.97.1", "webpack": "5.97.1",
"webpack-cli": "5.1.4" "webpack-cli": "5.1.4"
@ -99,12 +99,13 @@
} }
}, },
"node_modules/@parcel/watcher": { "node_modules/@parcel/watcher": {
"version": "2.5.0", "version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.0.tgz", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz",
"integrity": "sha512-i0GV1yJnm2n3Yq1qw6QrUrd/LI9bE8WEBOTtOkpCXHHdyN3TAGgqAK/DAT05z4fq2x04cARXt2pDmjWjL92iTQ==", "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==",
"dev": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"optional": true,
"dependencies": { "dependencies": {
"detect-libc": "^1.0.3", "detect-libc": "^1.0.3",
"is-glob": "^4.0.3", "is-glob": "^4.0.3",
@ -119,25 +120,25 @@
"url": "https://opencollective.com/parcel" "url": "https://opencollective.com/parcel"
}, },
"optionalDependencies": { "optionalDependencies": {
"@parcel/watcher-android-arm64": "2.5.0", "@parcel/watcher-android-arm64": "2.5.1",
"@parcel/watcher-darwin-arm64": "2.5.0", "@parcel/watcher-darwin-arm64": "2.5.1",
"@parcel/watcher-darwin-x64": "2.5.0", "@parcel/watcher-darwin-x64": "2.5.1",
"@parcel/watcher-freebsd-x64": "2.5.0", "@parcel/watcher-freebsd-x64": "2.5.1",
"@parcel/watcher-linux-arm-glibc": "2.5.0", "@parcel/watcher-linux-arm-glibc": "2.5.1",
"@parcel/watcher-linux-arm-musl": "2.5.0", "@parcel/watcher-linux-arm-musl": "2.5.1",
"@parcel/watcher-linux-arm64-glibc": "2.5.0", "@parcel/watcher-linux-arm64-glibc": "2.5.1",
"@parcel/watcher-linux-arm64-musl": "2.5.0", "@parcel/watcher-linux-arm64-musl": "2.5.1",
"@parcel/watcher-linux-x64-glibc": "2.5.0", "@parcel/watcher-linux-x64-glibc": "2.5.1",
"@parcel/watcher-linux-x64-musl": "2.5.0", "@parcel/watcher-linux-x64-musl": "2.5.1",
"@parcel/watcher-win32-arm64": "2.5.0", "@parcel/watcher-win32-arm64": "2.5.1",
"@parcel/watcher-win32-ia32": "2.5.0", "@parcel/watcher-win32-ia32": "2.5.1",
"@parcel/watcher-win32-x64": "2.5.0" "@parcel/watcher-win32-x64": "2.5.1"
} }
}, },
"node_modules/@parcel/watcher-android-arm64": { "node_modules/@parcel/watcher-android-arm64": {
"version": "2.5.0", "version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.0.tgz", "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz",
"integrity": "sha512-qlX4eS28bUcQCdribHkg/herLe+0A9RyYC+mm2PXpncit8z5b3nSqGVzMNR3CmtAOgRutiZ02eIJJgP/b1iEFQ==", "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -156,9 +157,9 @@
} }
}, },
"node_modules/@parcel/watcher-darwin-arm64": { "node_modules/@parcel/watcher-darwin-arm64": {
"version": "2.5.0", "version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.0.tgz", "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz",
"integrity": "sha512-hyZ3TANnzGfLpRA2s/4U1kbw2ZI4qGxaRJbBH2DCSREFfubMswheh8TeiC1sGZ3z2jUf3s37P0BBlrD3sjVTUw==", "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -177,9 +178,9 @@
} }
}, },
"node_modules/@parcel/watcher-darwin-x64": { "node_modules/@parcel/watcher-darwin-x64": {
"version": "2.5.0", "version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.0.tgz", "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz",
"integrity": "sha512-9rhlwd78saKf18fT869/poydQK8YqlU26TMiNg7AIu7eBp9adqbJZqmdFOsbZ5cnLp5XvRo9wcFmNHgHdWaGYA==", "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -198,9 +199,9 @@
} }
}, },
"node_modules/@parcel/watcher-freebsd-x64": { "node_modules/@parcel/watcher-freebsd-x64": {
"version": "2.5.0", "version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.0.tgz", "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz",
"integrity": "sha512-syvfhZzyM8kErg3VF0xpV8dixJ+RzbUaaGaeb7uDuz0D3FK97/mZ5AJQ3XNnDsXX7KkFNtyQyFrXZzQIcN49Tw==", "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -219,9 +220,9 @@
} }
}, },
"node_modules/@parcel/watcher-linux-arm-glibc": { "node_modules/@parcel/watcher-linux-arm-glibc": {
"version": "2.5.0", "version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.0.tgz", "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz",
"integrity": "sha512-0VQY1K35DQET3dVYWpOaPFecqOT9dbuCfzjxoQyif1Wc574t3kOSkKevULddcR9znz1TcklCE7Ht6NIxjvTqLA==", "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -240,9 +241,9 @@
} }
}, },
"node_modules/@parcel/watcher-linux-arm-musl": { "node_modules/@parcel/watcher-linux-arm-musl": {
"version": "2.5.0", "version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.0.tgz", "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz",
"integrity": "sha512-6uHywSIzz8+vi2lAzFeltnYbdHsDm3iIB57d4g5oaB9vKwjb6N6dRIgZMujw4nm5r6v9/BQH0noq6DzHrqr2pA==", "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -261,9 +262,9 @@
} }
}, },
"node_modules/@parcel/watcher-linux-arm64-glibc": { "node_modules/@parcel/watcher-linux-arm64-glibc": {
"version": "2.5.0", "version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.0.tgz", "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz",
"integrity": "sha512-BfNjXwZKxBy4WibDb/LDCriWSKLz+jJRL3cM/DllnHH5QUyoiUNEp3GmL80ZqxeumoADfCCP19+qiYiC8gUBjA==", "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -282,9 +283,9 @@
} }
}, },
"node_modules/@parcel/watcher-linux-arm64-musl": { "node_modules/@parcel/watcher-linux-arm64-musl": {
"version": "2.5.0", "version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.0.tgz", "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz",
"integrity": "sha512-S1qARKOphxfiBEkwLUbHjCY9BWPdWnW9j7f7Hb2jPplu8UZ3nes7zpPOW9bkLbHRvWM0WDTsjdOTUgW0xLBN1Q==", "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -303,9 +304,9 @@
} }
}, },
"node_modules/@parcel/watcher-linux-x64-glibc": { "node_modules/@parcel/watcher-linux-x64-glibc": {
"version": "2.5.0", "version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.0.tgz", "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz",
"integrity": "sha512-d9AOkusyXARkFD66S6zlGXyzx5RvY+chTP9Jp0ypSTC9d4lzyRs9ovGf/80VCxjKddcUvnsGwCHWuF2EoPgWjw==", "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -324,9 +325,9 @@
} }
}, },
"node_modules/@parcel/watcher-linux-x64-musl": { "node_modules/@parcel/watcher-linux-x64-musl": {
"version": "2.5.0", "version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.0.tgz", "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz",
"integrity": "sha512-iqOC+GoTDoFyk/VYSFHwjHhYrk8bljW6zOhPuhi5t9ulqiYq1togGJB5e3PwYVFFfeVgc6pbz3JdQyDoBszVaA==", "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -345,9 +346,9 @@
} }
}, },
"node_modules/@parcel/watcher-win32-arm64": { "node_modules/@parcel/watcher-win32-arm64": {
"version": "2.5.0", "version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.0.tgz", "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz",
"integrity": "sha512-twtft1d+JRNkM5YbmexfcH/N4znDtjgysFaV9zvZmmJezQsKpkfLYJ+JFV3uygugK6AtIM2oADPkB2AdhBrNig==", "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -366,9 +367,9 @@
} }
}, },
"node_modules/@parcel/watcher-win32-ia32": { "node_modules/@parcel/watcher-win32-ia32": {
"version": "2.5.0", "version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.0.tgz", "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz",
"integrity": "sha512-+rgpsNRKwo8A53elqbbHXdOMtY/tAtTzManTWShB5Kk54N8Q9mzNWV7tV+IbGueCbcj826MfWGU3mprWtuf1TA==", "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@ -387,9 +388,9 @@
} }
}, },
"node_modules/@parcel/watcher-win32-x64": { "node_modules/@parcel/watcher-win32-x64": {
"version": "2.5.0", "version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.0.tgz", "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz",
"integrity": "sha512-lPrxve92zEHdgeff3aiu4gDOIt4u7sJYha6wbdEZDCDUhtjTsOMiaJzG5lMY4GkWH8p0fMmO2Ppq5G5XXG+DQw==", "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -455,9 +456,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.10.2", "version": "22.13.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.2.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.5.tgz",
"integrity": "sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ==", "integrity": "sha512-+lTU0PxZXn0Dr1NBtC7Y8cR21AJr87dLLU953CWA6pMxxv/UDc7jYAY90upcrie1nRcD6XNG5HOYEDtgW5TxAg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -772,6 +773,7 @@
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true,
"dependencies": { "dependencies": {
"fill-range": "^7.1.1" "fill-range": "^7.1.1"
}, },
@ -780,9 +782,9 @@
} }
}, },
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.24.3", "version": "4.24.4",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.3.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz",
"integrity": "sha512-1CPmv8iobE2fyRMV97dAcMVegvvWKxmq94hkLiAkUGwKVTyDLw33K+ZxiFrREKmmps4rIw6grcCFCnTMSZ/YiA==", "integrity": "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -820,9 +822,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001690", "version": "1.0.30001700",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001690.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001700.tgz",
"integrity": "sha512-5ExiE3qQN6oF8Clf8ifIDcMRCRE/dMGcETG/XGMD8/XiXm6HXQgQTh1yZYLXXpSOsEUlJm1Xr7kGULZTuGtP/w==", "integrity": "sha512-2S6XIXwaE7K7erT8dY+kLQcpa5ms63XlRkMkReXjle+kf6c5g38vyMl+Z5y8dSxOFDhcFe+nxnn261PLxBSQsQ==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -965,6 +967,7 @@
"integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true,
"bin": { "bin": {
"detect-libc": "bin/detect-libc.js" "detect-libc": "bin/detect-libc.js"
}, },
@ -973,16 +976,16 @@
} }
}, },
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.75", "version": "1.5.103",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.75.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.103.tgz",
"integrity": "sha512-Lf3++DumRE/QmweGjU+ZcKqQ+3bKkU/qjaKYhIJKEOhgIO9Xs6IiAQFkfFoj+RhgDk4LUeNsLo6plExHqSyu6Q==", "integrity": "sha512-P6+XzIkfndgsrjROJWfSvVEgNHtPgbhVyTkwLjUM2HU/h7pZRORgaTlHqfAikqxKmdJMLW8fftrdGWbd/Ds0FA==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/enhanced-resolve": { "node_modules/enhanced-resolve": {
"version": "5.18.0", "version": "5.18.1",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.0.tgz", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz",
"integrity": "sha512-0/r0MySGYG8YqlayBZ6MuCfECmHFdJ5qyPh8s8wa5Hnm6SaFLSK1VYCbj+NKp090Nm1caZhD+QTnmxO7esYGyQ==", "integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -1007,9 +1010,9 @@
} }
}, },
"node_modules/es-module-lexer": { "node_modules/es-module-lexer": {
"version": "1.5.4", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.5.4.tgz", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.6.0.tgz",
"integrity": "sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw==", "integrity": "sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@ -1112,10 +1115,20 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-uri": { "node_modules/fast-uri": {
"version": "3.0.3", "version": "3.0.6",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.3.tgz", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz",
"integrity": "sha512-aLrHthzCjH5He4Z2H9YZ+v6Ujb9ocRuW6ZzkJQOrTxleEijANq4v1TsaPaVG1PZcuurEzrLcWRyYBYXD5cEiaw==", "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==",
"dev": true, "dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/fastest-levenshtein": { "node_modules/fastest-levenshtein": {
@ -1134,6 +1147,7 @@
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true,
"dependencies": { "dependencies": {
"to-regex-range": "^5.0.1" "to-regex-range": "^5.0.1"
}, },
@ -1235,9 +1249,9 @@
} }
}, },
"node_modules/immutable": { "node_modules/immutable": {
"version": "4.3.7", "version": "5.0.3",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.7.tgz", "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.0.3.tgz",
"integrity": "sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==", "integrity": "sha512-P8IdPQHq3lA1xVeBRi5VPqUm5HDgKnx0Ru51wZz5mjxHr5n3RWhjIpOFU7ybkUxfB+5IToy+OLaHYDBIWsv+uw==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@ -1293,6 +1307,7 @@
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@ -1303,6 +1318,7 @@
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true,
"dependencies": { "dependencies": {
"is-extglob": "^2.1.1" "is-extglob": "^2.1.1"
}, },
@ -1316,6 +1332,7 @@
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true,
"engines": { "engines": {
"node": ">=0.12.0" "node": ">=0.12.0"
} }
@ -1431,6 +1448,7 @@
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true,
"dependencies": { "dependencies": {
"braces": "^3.0.3", "braces": "^3.0.3",
"picomatch": "^2.3.1" "picomatch": "^2.3.1"
@ -1514,7 +1532,8 @@
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"optional": true
}, },
"node_modules/node-releases": { "node_modules/node-releases": {
"version": "2.0.19", "version": "2.0.19",
@ -1602,6 +1621,7 @@
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true,
"engines": { "engines": {
"node": ">=8.6" "node": ">=8.6"
}, },
@ -1623,9 +1643,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.4.49", "version": "8.5.3",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz",
"integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==", "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -1643,7 +1663,7 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"nanoid": "^3.3.7", "nanoid": "^3.3.8",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
"source-map-js": "^1.2.1" "source-map-js": "^1.2.1"
}, },
@ -1715,9 +1735,9 @@
} }
}, },
"node_modules/postcss-selector-parser": { "node_modules/postcss-selector-parser": {
"version": "7.0.0", "version": "7.1.0",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.0.0.tgz", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz",
"integrity": "sha512-9RbEr1Y7FFfptd/1eEdntyjMwLeghW1bHX9GWjXo19vx4ytPQhANltvVxDggzJl7mnWM+dX28kb6cyS/4iQjlQ==", "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -1756,13 +1776,13 @@
} }
}, },
"node_modules/readdirp": { "node_modules/readdirp": {
"version": "4.0.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==", "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 14.16.0" "node": ">= 14.18.0"
}, },
"funding": { "funding": {
"type": "individual", "type": "individual",
@ -1858,15 +1878,14 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/sass": { "node_modules/sass": {
"version": "1.79.5", "version": "1.85.0",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.79.5.tgz", "resolved": "https://registry.npmjs.org/sass/-/sass-1.85.0.tgz",
"integrity": "sha512-W1h5kp6bdhqFh2tk3DsI771MoEJjvrSY/2ihJRJS4pjIyfJCw0nTsxqhnrUzaLMOJjFchj8rOvraI/YUVjtx5g==", "integrity": "sha512-3ToiC1xZ1Y8aU7+CkgCI/tqyuPXEmYGJXO7H4uqp0xkLXUqp88rQQ4j1HmP37xSJLbCJPaIiv+cT1y+grssrww==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@parcel/watcher": "^2.4.1",
"chokidar": "^4.0.0", "chokidar": "^4.0.0",
"immutable": "^4.0.0", "immutable": "^5.0.2",
"source-map-js": ">=0.6.2 <2.0.0" "source-map-js": ">=0.6.2 <2.0.0"
}, },
"bin": { "bin": {
@ -1874,6 +1893,9 @@
}, },
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"
},
"optionalDependencies": {
"@parcel/watcher": "^2.4.1"
} }
}, },
"node_modules/sass-loader": { "node_modules/sass-loader": {
@ -1938,9 +1960,9 @@
} }
}, },
"node_modules/semver": { "node_modules/semver": {
"version": "7.6.3", "version": "7.7.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
"integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"bin": { "bin": {
@ -2067,9 +2089,9 @@
} }
}, },
"node_modules/terser": { "node_modules/terser": {
"version": "5.37.0", "version": "5.39.0",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.37.0.tgz", "resolved": "https://registry.npmjs.org/terser/-/terser-5.39.0.tgz",
"integrity": "sha512-B8wRRkmre4ERucLM/uXx4MOV5cbnOlVAqUst+1+iLKPI0dOgFO28f84ptoQt9HEI537PMzfYa/d+GEPKTRXmYA==", "integrity": "sha512-LBAhFyLho16harJoWMg/nZsQYgTrg5jXOn2nCYjRUcZZEdE3qa2zb8QEDRUGVZBW4rlazf2fxkg8tztybTaqWw==",
"dev": true, "dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"dependencies": { "dependencies": {
@ -2126,6 +2148,7 @@
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true,
"dependencies": { "dependencies": {
"is-number": "^7.0.0" "is-number": "^7.0.0"
}, },
@ -2149,9 +2172,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/update-browserslist-db": { "node_modules/update-browserslist-db": {
"version": "1.1.1", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.2.tgz",
"integrity": "sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==", "integrity": "sha512-PPypAm5qvlD7XMZC3BujecnaOxwhrtoFR+Dqkk5Aa/6DssiH0ibKoketaj9w8LP7Bont1rYeoV5plxD7RTEPRg==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -2170,7 +2193,7 @@
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"escalade": "^3.2.0", "escalade": "^3.2.0",
"picocolors": "^1.1.0" "picocolors": "^1.1.1"
}, },
"bin": { "bin": {
"update-browserslist-db": "cli.js" "update-browserslist-db": "cli.js"

View File

@ -17,7 +17,7 @@
"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.2", "mini-css-extract-plugin": "2.9.2",
"sass": "1.79.5", "sass": "1.85.0",
"sass-loader": "16.0.4", "sass-loader": "16.0.4",
"webpack": "5.97.1", "webpack": "5.97.1",
"webpack-cli": "5.1.4" "webpack-cli": "5.1.4"

View File

@ -13,6 +13,7 @@ using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.Enums; using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Repositories; using Bit.Core.Auth.Repositories;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Billing.Pricing;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
@ -55,6 +56,7 @@ public class OrganizationUsersController : Controller
private readonly IDeleteManagedOrganizationUserAccountCommand _deleteManagedOrganizationUserAccountCommand; private readonly IDeleteManagedOrganizationUserAccountCommand _deleteManagedOrganizationUserAccountCommand;
private readonly IGetOrganizationUsersManagementStatusQuery _getOrganizationUsersManagementStatusQuery; private readonly IGetOrganizationUsersManagementStatusQuery _getOrganizationUsersManagementStatusQuery;
private readonly IFeatureService _featureService; private readonly IFeatureService _featureService;
private readonly IPricingClient _pricingClient;
public OrganizationUsersController( public OrganizationUsersController(
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
@ -77,7 +79,8 @@ public class OrganizationUsersController : Controller
IRemoveOrganizationUserCommand removeOrganizationUserCommand, IRemoveOrganizationUserCommand removeOrganizationUserCommand,
IDeleteManagedOrganizationUserAccountCommand deleteManagedOrganizationUserAccountCommand, IDeleteManagedOrganizationUserAccountCommand deleteManagedOrganizationUserAccountCommand,
IGetOrganizationUsersManagementStatusQuery getOrganizationUsersManagementStatusQuery, IGetOrganizationUsersManagementStatusQuery getOrganizationUsersManagementStatusQuery,
IFeatureService featureService) IFeatureService featureService,
IPricingClient pricingClient)
{ {
_organizationRepository = organizationRepository; _organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository; _organizationUserRepository = organizationUserRepository;
@ -100,6 +103,7 @@ public class OrganizationUsersController : Controller
_deleteManagedOrganizationUserAccountCommand = deleteManagedOrganizationUserAccountCommand; _deleteManagedOrganizationUserAccountCommand = deleteManagedOrganizationUserAccountCommand;
_getOrganizationUsersManagementStatusQuery = getOrganizationUsersManagementStatusQuery; _getOrganizationUsersManagementStatusQuery = getOrganizationUsersManagementStatusQuery;
_featureService = featureService; _featureService = featureService;
_pricingClient = pricingClient;
} }
[HttpGet("{id}")] [HttpGet("{id}")]
@ -648,7 +652,9 @@ public class OrganizationUsersController : Controller
if (additionalSmSeatsRequired > 0) if (additionalSmSeatsRequired > 0)
{ {
var organization = await _organizationRepository.GetByIdAsync(orgId); var organization = await _organizationRepository.GetByIdAsync(orgId);
var update = new SecretsManagerSubscriptionUpdate(organization, true) // TODO: https://bitwarden.atlassian.net/browse/PM-17000
var plan = await _pricingClient.GetPlanOrThrow(organization!.PlanType);
var update = new SecretsManagerSubscriptionUpdate(organization, plan, true)
.AdjustSeats(additionalSmSeatsRequired); .AdjustSeats(additionalSmSeatsRequired);
await _updateSecretsManagerSubscriptionCommand.UpdateSubscriptionAsync(update); await _updateSecretsManagerSubscriptionCommand.UpdateSubscriptionAsync(update);
} }

View File

@ -22,6 +22,7 @@ using Bit.Core.Auth.Repositories;
using Bit.Core.Auth.Services; using Bit.Core.Auth.Services;
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Services;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Enums; using Bit.Core.Enums;
@ -60,6 +61,7 @@ public class OrganizationsController : Controller
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
private readonly ICloudOrganizationSignUpCommand _cloudOrganizationSignUpCommand; private readonly ICloudOrganizationSignUpCommand _cloudOrganizationSignUpCommand;
private readonly IOrganizationDeleteCommand _organizationDeleteCommand; private readonly IOrganizationDeleteCommand _organizationDeleteCommand;
private readonly IPricingClient _pricingClient;
public OrganizationsController( public OrganizationsController(
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
@ -81,7 +83,8 @@ public class OrganizationsController : Controller
IDataProtectorTokenFactory<OrgDeleteTokenable> orgDeleteTokenDataFactory, IDataProtectorTokenFactory<OrgDeleteTokenable> orgDeleteTokenDataFactory,
IRemoveOrganizationUserCommand removeOrganizationUserCommand, IRemoveOrganizationUserCommand removeOrganizationUserCommand,
ICloudOrganizationSignUpCommand cloudOrganizationSignUpCommand, ICloudOrganizationSignUpCommand cloudOrganizationSignUpCommand,
IOrganizationDeleteCommand organizationDeleteCommand) IOrganizationDeleteCommand organizationDeleteCommand,
IPricingClient pricingClient)
{ {
_organizationRepository = organizationRepository; _organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository; _organizationUserRepository = organizationUserRepository;
@ -103,6 +106,7 @@ public class OrganizationsController : Controller
_removeOrganizationUserCommand = removeOrganizationUserCommand; _removeOrganizationUserCommand = removeOrganizationUserCommand;
_cloudOrganizationSignUpCommand = cloudOrganizationSignUpCommand; _cloudOrganizationSignUpCommand = cloudOrganizationSignUpCommand;
_organizationDeleteCommand = organizationDeleteCommand; _organizationDeleteCommand = organizationDeleteCommand;
_pricingClient = pricingClient;
} }
[HttpGet("{id}")] [HttpGet("{id}")]
@ -120,7 +124,8 @@ public class OrganizationsController : Controller
throw new NotFoundException(); throw new NotFoundException();
} }
return new OrganizationResponseModel(organization); var plan = await _pricingClient.GetPlan(organization.PlanType);
return new OrganizationResponseModel(organization, plan);
} }
[HttpGet("")] [HttpGet("")]
@ -181,7 +186,8 @@ public class OrganizationsController : Controller
var organizationSignup = model.ToOrganizationSignup(user); var organizationSignup = model.ToOrganizationSignup(user);
var result = await _cloudOrganizationSignUpCommand.SignUpOrganizationAsync(organizationSignup); var result = await _cloudOrganizationSignUpCommand.SignUpOrganizationAsync(organizationSignup);
return new OrganizationResponseModel(result.Organization); var plan = await _pricingClient.GetPlanOrThrow(result.Organization.PlanType);
return new OrganizationResponseModel(result.Organization, plan);
} }
[HttpPost("create-without-payment")] [HttpPost("create-without-payment")]
@ -196,7 +202,8 @@ public class OrganizationsController : Controller
var organizationSignup = model.ToOrganizationSignup(user); var organizationSignup = model.ToOrganizationSignup(user);
var result = await _cloudOrganizationSignUpCommand.SignUpOrganizationAsync(organizationSignup); var result = await _cloudOrganizationSignUpCommand.SignUpOrganizationAsync(organizationSignup);
return new OrganizationResponseModel(result.Organization); var plan = await _pricingClient.GetPlanOrThrow(result.Organization.PlanType);
return new OrganizationResponseModel(result.Organization, plan);
} }
[HttpPut("{id}")] [HttpPut("{id}")]
@ -224,7 +231,8 @@ public class OrganizationsController : Controller
} }
await _organizationService.UpdateAsync(model.ToOrganization(organization, _globalSettings), updateBilling); await _organizationService.UpdateAsync(model.ToOrganization(organization, _globalSettings), updateBilling);
return new OrganizationResponseModel(organization); var plan = await _pricingClient.GetPlan(organization.PlanType);
return new OrganizationResponseModel(organization, plan);
} }
[HttpPost("{id}/storage")] [HttpPost("{id}/storage")]
@ -358,8 +366,8 @@ public class OrganizationsController : Controller
if (model.Type == OrganizationApiKeyType.BillingSync || model.Type == OrganizationApiKeyType.Scim) if (model.Type == OrganizationApiKeyType.BillingSync || model.Type == OrganizationApiKeyType.Scim)
{ {
// Non-enterprise orgs should not be able to create or view an apikey of billing sync/scim key types // Non-enterprise orgs should not be able to create or view an apikey of billing sync/scim key types
var plan = StaticStore.GetPlan(organization.PlanType); var productTier = organization.PlanType.GetProductTier();
if (plan.ProductTier is not ProductTierType.Enterprise and not ProductTierType.Teams) if (productTier is not ProductTierType.Enterprise and not ProductTierType.Teams)
{ {
throw new NotFoundException(); throw new NotFoundException();
} }
@ -542,7 +550,8 @@ public class OrganizationsController : Controller
} }
await _organizationService.UpdateAsync(model.ToOrganization(organization, _featureService), eventType: EventType.Organization_CollectionManagement_Updated); await _organizationService.UpdateAsync(model.ToOrganization(organization, _featureService), eventType: EventType.Organization_CollectionManagement_Updated);
return new OrganizationResponseModel(organization); var plan = await _pricingClient.GetPlan(organization.PlanType);
return new OrganizationResponseModel(organization, plan);
} }
[HttpGet("{id}/plan-type")] [HttpGet("{id}/plan-type")]

View File

@ -1,4 +1,6 @@
using Bit.Api.Billing.Models.Requests; using Bit.Api.Billing.Controllers;
using Bit.Api.Billing.Models.Requests;
using Bit.Core;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Services;
@ -7,13 +9,15 @@ using Bit.Core.Enums;
using Bit.Core.Models.Business; using Bit.Core.Models.Business;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.Billing.Controllers; namespace Bit.Api.AdminConsole.Controllers;
[Route("providers/{providerId:guid}/clients")] [Route("providers/{providerId:guid}/clients")]
public class ProviderClientsController( public class ProviderClientsController(
ICurrentContext currentContext, ICurrentContext currentContext,
IFeatureService featureService,
ILogger<BaseProviderController> logger, ILogger<BaseProviderController> logger,
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
IProviderBillingService providerBillingService, IProviderBillingService providerBillingService,
@ -22,7 +26,10 @@ public class ProviderClientsController(
IProviderService providerService, IProviderService providerService,
IUserService userService) : BaseProviderController(currentContext, logger, providerRepository, userService) IUserService userService) : BaseProviderController(currentContext, logger, providerRepository, userService)
{ {
private readonly ICurrentContext _currentContext = currentContext;
[HttpPost] [HttpPost]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<IResult> CreateAsync( public async Task<IResult> CreateAsync(
[FromRoute] Guid providerId, [FromRoute] Guid providerId,
[FromBody] CreateClientOrganizationRequestBody requestBody) [FromBody] CreateClientOrganizationRequestBody requestBody)
@ -80,6 +87,7 @@ public class ProviderClientsController(
} }
[HttpPut("{providerOrganizationId:guid}")] [HttpPut("{providerOrganizationId:guid}")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<IResult> UpdateAsync( public async Task<IResult> UpdateAsync(
[FromRoute] Guid providerId, [FromRoute] Guid providerId,
[FromRoute] Guid providerOrganizationId, [FromRoute] Guid providerOrganizationId,
@ -113,7 +121,7 @@ public class ProviderClientsController(
clientOrganization.PlanType, clientOrganization.PlanType,
seatAdjustment); seatAdjustment);
if (seatAdjustmentResultsInPurchase && !currentContext.ProviderProviderAdmin(provider.Id)) if (seatAdjustmentResultsInPurchase && !_currentContext.ProviderProviderAdmin(provider.Id))
{ {
return Error.Unauthorized("Service users cannot purchase additional seats."); return Error.Unauthorized("Service users cannot purchase additional seats.");
} }
@ -127,4 +135,58 @@ public class ProviderClientsController(
return TypedResults.Ok(); return TypedResults.Ok();
} }
[HttpGet("addable")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<IResult> GetAddableOrganizationsAsync([FromRoute] Guid providerId)
{
if (!featureService.IsEnabled(FeatureFlagKeys.P15179_AddExistingOrgsFromProviderPortal))
{
return Error.NotFound();
}
var (provider, result) = await TryGetBillableProviderForServiceUserOperation(providerId);
if (provider == null)
{
return result;
}
var userId = _currentContext.UserId;
if (!userId.HasValue)
{
return Error.Unauthorized();
}
var addable =
await providerBillingService.GetAddableOrganizations(provider, userId.Value);
return TypedResults.Ok(addable);
}
[HttpPost("existing")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<IResult> AddExistingOrganizationAsync(
[FromRoute] Guid providerId,
[FromBody] AddExistingOrganizationRequestBody requestBody)
{
var (provider, result) = await TryGetBillableProviderForServiceUserOperation(providerId);
if (provider == null)
{
return result;
}
var organization = await organizationRepository.GetByIdAsync(requestBody.OrganizationId);
if (organization == null)
{
return Error.BadRequest("The organization being added to the provider does not exist.");
}
await providerBillingService.AddExistingOrganization(provider, organization, requestBody.Key);
return TypedResults.Ok();
}
} }

View File

@ -4,6 +4,7 @@ using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
using Bit.Core.Models.Api; using Bit.Core.Models.Api;
using Bit.Core.Models.Business; using Bit.Core.Models.Business;
using Bit.Core.Models.StaticStore;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Constants = Bit.Core.Constants; using Constants = Bit.Core.Constants;
@ -11,8 +12,10 @@ namespace Bit.Api.AdminConsole.Models.Response.Organizations;
public class OrganizationResponseModel : ResponseModel public class OrganizationResponseModel : ResponseModel
{ {
public OrganizationResponseModel(Organization organization, string obj = "organization") public OrganizationResponseModel(
: base(obj) Organization organization,
Plan plan,
string obj = "organization") : base(obj)
{ {
if (organization == null) if (organization == null)
{ {
@ -28,7 +31,8 @@ public class OrganizationResponseModel : ResponseModel
BusinessCountry = organization.BusinessCountry; BusinessCountry = organization.BusinessCountry;
BusinessTaxNumber = organization.BusinessTaxNumber; BusinessTaxNumber = organization.BusinessTaxNumber;
BillingEmail = organization.BillingEmail; BillingEmail = organization.BillingEmail;
Plan = new PlanResponseModel(StaticStore.GetPlan(organization.PlanType)); // Self-Host instances only require plan information that can be derived from the Organization record.
Plan = plan != null ? new PlanResponseModel(plan) : new PlanResponseModel(organization);
PlanType = organization.PlanType; PlanType = organization.PlanType;
Seats = organization.Seats; Seats = organization.Seats;
MaxAutoscaleSeats = organization.MaxAutoscaleSeats; MaxAutoscaleSeats = organization.MaxAutoscaleSeats;
@ -110,7 +114,9 @@ public class OrganizationResponseModel : ResponseModel
public class OrganizationSubscriptionResponseModel : OrganizationResponseModel public class OrganizationSubscriptionResponseModel : OrganizationResponseModel
{ {
public OrganizationSubscriptionResponseModel(Organization organization) : base(organization, "organizationSubscription") public OrganizationSubscriptionResponseModel(
Organization organization,
Plan plan) : base(organization, plan, "organizationSubscription")
{ {
Expiration = organization.ExpirationDate; Expiration = organization.ExpirationDate;
StorageName = organization.Storage.HasValue ? StorageName = organization.Storage.HasValue ?
@ -119,8 +125,11 @@ public class OrganizationSubscriptionResponseModel : OrganizationResponseModel
Math.Round(organization.Storage.Value / 1073741824D, 2) : 0; // 1 GB Math.Round(organization.Storage.Value / 1073741824D, 2) : 0; // 1 GB
} }
public OrganizationSubscriptionResponseModel(Organization organization, SubscriptionInfo subscription, bool hideSensitiveData) public OrganizationSubscriptionResponseModel(
: this(organization) Organization organization,
SubscriptionInfo subscription,
Plan plan,
bool hideSensitiveData) : this(organization, plan)
{ {
Subscription = subscription.Subscription != null ? new BillingSubscription(subscription.Subscription) : null; Subscription = subscription.Subscription != null ? new BillingSubscription(subscription.Subscription) : null;
UpcomingInvoice = subscription.UpcomingInvoice != null ? new BillingSubscriptionUpcomingInvoice(subscription.UpcomingInvoice) : null; UpcomingInvoice = subscription.UpcomingInvoice != null ? new BillingSubscriptionUpcomingInvoice(subscription.UpcomingInvoice) : null;
@ -142,7 +151,7 @@ public class OrganizationSubscriptionResponseModel : OrganizationResponseModel
} }
public OrganizationSubscriptionResponseModel(Organization organization, OrganizationLicense license) : public OrganizationSubscriptionResponseModel(Organization organization, OrganizationLicense license) :
this(organization) this(organization, (Plan)null)
{ {
if (license != null) if (license != null)
{ {

View File

@ -3,6 +3,7 @@ using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.Auth.Enums; using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Models.Data;
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Models.Api; using Bit.Core.Models.Api;
using Bit.Core.Models.Data; using Bit.Core.Models.Data;
@ -37,7 +38,7 @@ public class ProfileOrganizationResponseModel : ResponseModel
UsePasswordManager = organization.UsePasswordManager; UsePasswordManager = organization.UsePasswordManager;
UsersGetPremium = organization.UsersGetPremium; UsersGetPremium = organization.UsersGetPremium;
UseCustomPermissions = organization.UseCustomPermissions; UseCustomPermissions = organization.UseCustomPermissions;
UseActivateAutofillPolicy = StaticStore.GetPlan(organization.PlanType).ProductTier == ProductTierType.Enterprise; UseActivateAutofillPolicy = organization.PlanType.GetProductTier() == ProductTierType.Enterprise;
SelfHost = organization.SelfHost; SelfHost = organization.SelfHost;
Seats = organization.Seats; Seats = organization.Seats;
MaxCollections = organization.MaxCollections; MaxCollections = organization.MaxCollections;
@ -60,7 +61,7 @@ public class ProfileOrganizationResponseModel : ResponseModel
FamilySponsorshipAvailable = FamilySponsorshipFriendlyName == null && FamilySponsorshipAvailable = FamilySponsorshipFriendlyName == null &&
StaticStore.GetSponsoredPlan(PlanSponsorshipType.FamiliesForEnterprise) StaticStore.GetSponsoredPlan(PlanSponsorshipType.FamiliesForEnterprise)
.UsersCanSponsor(organization); .UsersCanSponsor(organization);
ProductTierType = StaticStore.GetPlan(organization.PlanType).ProductTier; ProductTierType = organization.PlanType.GetProductTier();
FamilySponsorshipLastSyncDate = organization.FamilySponsorshipLastSyncDate; FamilySponsorshipLastSyncDate = organization.FamilySponsorshipLastSyncDate;
FamilySponsorshipToDelete = organization.FamilySponsorshipToDelete; FamilySponsorshipToDelete = organization.FamilySponsorshipToDelete;
FamilySponsorshipValidUntil = organization.FamilySponsorshipValidUntil; FamilySponsorshipValidUntil = organization.FamilySponsorshipValidUntil;

View File

@ -1,8 +1,8 @@
using Bit.Core.AdminConsole.Models.Data.Provider; using Bit.Core.AdminConsole.Models.Data.Provider;
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Models.Data; using Bit.Core.Models.Data;
using Bit.Core.Utilities;
namespace Bit.Api.AdminConsole.Models.Response; namespace Bit.Api.AdminConsole.Models.Response;
@ -26,7 +26,7 @@ public class ProfileProviderOrganizationResponseModel : ProfileOrganizationRespo
UseResetPassword = organization.UseResetPassword; UseResetPassword = organization.UseResetPassword;
UsersGetPremium = organization.UsersGetPremium; UsersGetPremium = organization.UsersGetPremium;
UseCustomPermissions = organization.UseCustomPermissions; UseCustomPermissions = organization.UseCustomPermissions;
UseActivateAutofillPolicy = StaticStore.GetPlan(organization.PlanType).ProductTier == ProductTierType.Enterprise; UseActivateAutofillPolicy = organization.PlanType.GetProductTier() == ProductTierType.Enterprise;
SelfHost = organization.SelfHost; SelfHost = organization.SelfHost;
Seats = organization.Seats; Seats = organization.Seats;
MaxCollections = organization.MaxCollections; MaxCollections = organization.MaxCollections;
@ -44,7 +44,7 @@ public class ProfileProviderOrganizationResponseModel : ProfileOrganizationRespo
ProviderId = organization.ProviderId; ProviderId = organization.ProviderId;
ProviderName = organization.ProviderName; ProviderName = organization.ProviderName;
ProviderType = organization.ProviderType; ProviderType = organization.ProviderType;
ProductTierType = StaticStore.GetPlan(organization.PlanType).ProductTier; ProductTierType = organization.PlanType.GetProductTier();
LimitCollectionCreation = organization.LimitCollectionCreation; LimitCollectionCreation = organization.LimitCollectionCreation;
LimitCollectionDeletion = organization.LimitCollectionDeletion; LimitCollectionDeletion = organization.LimitCollectionDeletion;
LimitItemDeletion = organization.LimitItemDeletion; LimitItemDeletion = organization.LimitItemDeletion;

View File

@ -36,7 +36,7 @@ public class EventsController : Controller
/// If no filters are provided, it will return the last 30 days of event for the organization. /// If no filters are provided, it will return the last 30 days of event for the organization.
/// </remarks> /// </remarks>
[HttpGet] [HttpGet]
[ProducesResponseType(typeof(ListResponseModel<EventResponseModel>), (int)HttpStatusCode.OK)] [ProducesResponseType(typeof(PagedListResponseModel<EventResponseModel>), (int)HttpStatusCode.OK)]
public async Task<IActionResult> List([FromQuery] EventFilterRequestModel request) public async Task<IActionResult> List([FromQuery] EventFilterRequestModel request)
{ {
var dateRange = request.ToDateRange(); var dateRange = request.ToDateRange();
@ -65,7 +65,7 @@ public class EventsController : Controller
} }
var eventResponses = result.Data.Select(e => new EventResponseModel(e)); var eventResponses = result.Data.Select(e => new EventResponseModel(e));
var response = new ListResponseModel<EventResponseModel>(eventResponses, result.ContinuationToken); var response = new PagedListResponseModel<EventResponseModel>(eventResponses, result.ContinuationToken);
return new JsonResult(response); return new JsonResult(response);
} }
} }

View File

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

View File

@ -149,11 +149,11 @@ public class AccountsController : Controller
throw new BadRequestException("MasterPasswordHash", "Invalid password."); throw new BadRequestException("MasterPasswordHash", "Invalid password.");
} }
// If Account Deprovisioning is enabled, we need to check if the user is managed by any organization. var managedUserValidationResult = await _userService.ValidateManagedUserDomainAsync(user, model.NewEmail);
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
&& await _userService.IsManagedByAnyOrganizationAsync(user.Id)) if (!managedUserValidationResult.Succeeded)
{ {
throw new BadRequestException("Cannot change emails for accounts owned by an organization. Contact your organization administrator for additional details."); throw new BadRequestException(managedUserValidationResult.Errors);
} }
await _userService.InitiateEmailChangeAsync(user, model.NewEmail); await _userService.InitiateEmailChangeAsync(user, model.NewEmail);
@ -173,13 +173,6 @@ public class AccountsController : Controller
throw new BadRequestException("You cannot change your email when using Key Connector."); throw new BadRequestException("You cannot change your email when using Key Connector.");
} }
// If Account Deprovisioning is enabled, we need to check if the user is managed by any organization.
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
&& await _userService.IsManagedByAnyOrganizationAsync(user.Id))
{
throw new BadRequestException("Cannot change emails for accounts owned by an organization. Contact your organization administrator for additional details.");
}
var result = await _userService.ChangeEmailAsync(user, model.MasterPasswordHash, model.NewEmail, var result = await _userService.ChangeEmailAsync(user, model.MasterPasswordHash, model.NewEmail,
model.NewMasterPasswordHash, model.Token, model.Key); model.NewMasterPasswordHash, model.Token, model.Key);
if (result.Succeeded) if (result.Succeeded)
@ -266,8 +259,18 @@ public class AccountsController : Controller
throw new UnauthorizedAccessException(); throw new UnauthorizedAccessException();
} }
try
{
user = model.ToUser(user);
}
catch (Exception e)
{
ModelState.AddModelError(string.Empty, e.Message);
throw new BadRequestException(ModelState);
}
var result = await _setInitialMasterPasswordCommand.SetInitialMasterPasswordAsync( var result = await _setInitialMasterPasswordCommand.SetInitialMasterPasswordAsync(
model.ToUser(user), user,
model.MasterPasswordHash, model.MasterPasswordHash,
model.Key, model.Key,
model.OrgIdentifier); model.OrgIdentifier);
@ -974,7 +977,6 @@ public class AccountsController : Controller
await _userService.ResendNewDeviceVerificationEmail(request.Email, request.Secret); await _userService.ResendNewDeviceVerificationEmail(request.Email, request.Secret);
} }
[RequireFeature(FeatureFlagKeys.NewDeviceVerification)]
[HttpPost("verify-devices")] [HttpPost("verify-devices")]
[HttpPut("verify-devices")] [HttpPut("verify-devices")]
public async Task SetUserVerifyDevicesAsync([FromBody] SetVerifyDevicesRequestModel request) public async Task SetUserVerifyDevicesAsync([FromBody] SetVerifyDevicesRequestModel request)

View File

@ -288,12 +288,17 @@ public class TwoFactorController : Controller
return response; return response;
} }
/// <summary>
/// This endpoint is only used to set-up email two factor authentication.
/// </summary>
/// <param name="model">secret verification model</param>
/// <returns>void</returns>
[HttpPost("send-email")] [HttpPost("send-email")]
public async Task SendEmail([FromBody] TwoFactorEmailRequestModel model) public async Task SendEmail([FromBody] TwoFactorEmailRequestModel model)
{ {
var user = await CheckAsync(model, false, true); var user = await CheckAsync(model, false, true);
model.ToUser(user); model.ToUser(user);
await _userService.SendTwoFactorEmailAsync(user); await _userService.SendTwoFactorEmailAsync(user, false);
} }
[AllowAnonymous] [AllowAnonymous]
@ -304,7 +309,7 @@ public class TwoFactorController : Controller
if (user != null) if (user != null)
{ {
// check if 2FA email is from passwordless // Check if 2FA email is from Passwordless.
if (!string.IsNullOrEmpty(requestModel.AuthRequestAccessCode)) if (!string.IsNullOrEmpty(requestModel.AuthRequestAccessCode))
{ {
if (await _verifyAuthRequestCommand if (await _verifyAuthRequestCommand
@ -317,17 +322,14 @@ public class TwoFactorController : Controller
} }
else if (!string.IsNullOrEmpty(requestModel.SsoEmail2FaSessionToken)) else if (!string.IsNullOrEmpty(requestModel.SsoEmail2FaSessionToken))
{ {
if (this.ValidateSsoEmail2FaToken(requestModel.SsoEmail2FaSessionToken, user)) if (ValidateSsoEmail2FaToken(requestModel.SsoEmail2FaSessionToken, user))
{ {
await _userService.SendTwoFactorEmailAsync(user); await _userService.SendTwoFactorEmailAsync(user);
return; return;
} }
else
{ await ThrowDelayedBadRequestExceptionAsync(
await this.ThrowDelayedBadRequestExceptionAsync( "Cannot send two-factor email: a valid, non-expired SSO Email 2FA Session token is required to send 2FA emails.");
"Cannot send two-factor email: a valid, non-expired SSO Email 2FA Session token is required to send 2FA emails.",
2000);
}
} }
else if (await _userService.VerifySecretAsync(user, requestModel.Secret)) else if (await _userService.VerifySecretAsync(user, requestModel.Secret))
{ {
@ -336,8 +338,7 @@ public class TwoFactorController : Controller
} }
} }
await this.ThrowDelayedBadRequestExceptionAsync( await ThrowDelayedBadRequestExceptionAsync("Cannot send two-factor email.");
"Cannot send two-factor email.", 2000);
} }
[HttpPut("email")] [HttpPut("email")]
@ -374,7 +375,7 @@ public class TwoFactorController : Controller
public async Task<TwoFactorProviderResponseModel> PutOrganizationDisable(string id, public async Task<TwoFactorProviderResponseModel> PutOrganizationDisable(string id,
[FromBody] TwoFactorProviderRequestModel model) [FromBody] TwoFactorProviderRequestModel model)
{ {
var user = await CheckAsync(model, false); await CheckAsync(model, false);
var orgIdGuid = new Guid(id); var orgIdGuid = new Guid(id);
if (!await _currentContext.ManagePolicies(orgIdGuid)) if (!await _currentContext.ManagePolicies(orgIdGuid))
@ -401,6 +402,10 @@ public class TwoFactorController : Controller
return response; return response;
} }
/// <summary>
/// To be removed when the feature flag pm-17128-recovery-code-login is removed PM-18175.
/// </summary>
[Obsolete("Two Factor recovery is handled in the TwoFactorAuthenticationValidator.")]
[HttpPost("recover")] [HttpPost("recover")]
[AllowAnonymous] [AllowAnonymous]
public async Task PostRecover([FromBody] TwoFactorRecoveryRequestModel model) public async Task PostRecover([FromBody] TwoFactorRecoveryRequestModel model)
@ -463,11 +468,9 @@ public class TwoFactorController : Controller
await Task.Delay(2000); await Task.Delay(2000);
throw new BadRequestException(name, $"{name} is invalid."); throw new BadRequestException(name, $"{name} is invalid.");
} }
else
{
await Task.Delay(500); await Task.Delay(500);
} }
}
private bool ValidateSsoEmail2FaToken(string ssoEmail2FaSessionToken, User user) private bool ValidateSsoEmail2FaToken(string ssoEmail2FaSessionToken, User user)
{ {

View File

@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Reflection; using System.Reflection;
using Bit.Core.Auth.Entities; using Bit.Core.Auth.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Api; using Bit.Core.Models.Api;
namespace Bit.Api.Auth.Models.Response; namespace Bit.Api.Auth.Models.Response;
@ -17,6 +18,8 @@ public class AuthRequestResponseModel : ResponseModel
Id = authRequest.Id; Id = authRequest.Id;
PublicKey = authRequest.PublicKey; PublicKey = authRequest.PublicKey;
RequestDeviceIdentifier = authRequest.RequestDeviceIdentifier;
RequestDeviceTypeValue = authRequest.RequestDeviceType;
RequestDeviceType = authRequest.RequestDeviceType.GetType().GetMember(authRequest.RequestDeviceType.ToString()) RequestDeviceType = authRequest.RequestDeviceType.GetType().GetMember(authRequest.RequestDeviceType.ToString())
.FirstOrDefault()?.GetCustomAttribute<DisplayAttribute>()?.GetName(); .FirstOrDefault()?.GetCustomAttribute<DisplayAttribute>()?.GetName();
RequestIpAddress = authRequest.RequestIpAddress; RequestIpAddress = authRequest.RequestIpAddress;
@ -30,6 +33,8 @@ public class AuthRequestResponseModel : ResponseModel
public Guid Id { get; set; } public Guid Id { get; set; }
public string PublicKey { get; set; } public string PublicKey { get; set; }
public string RequestDeviceIdentifier { get; set; }
public DeviceType RequestDeviceTypeValue { get; set; }
public string RequestDeviceType { get; set; } public string RequestDeviceType { get; set; }
public string RequestIpAddress { get; set; } public string RequestIpAddress { get; set; }
public string Key { get; set; } public string Key { get; set; }

View File

@ -2,7 +2,9 @@
using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.Billing.Models.Requests; using Bit.Api.Billing.Models.Requests;
using Bit.Api.Billing.Models.Responses; using Bit.Api.Billing.Models.Responses;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Models.Sales; using Bit.Core.Billing.Models.Sales;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Services;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Repositories; using Bit.Core.Repositories;
@ -17,10 +19,10 @@ namespace Bit.Api.Billing.Controllers;
[Authorize("Application")] [Authorize("Application")]
public class OrganizationBillingController( public class OrganizationBillingController(
ICurrentContext currentContext, ICurrentContext currentContext,
IFeatureService featureService,
IOrganizationBillingService organizationBillingService, IOrganizationBillingService organizationBillingService,
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
IPaymentService paymentService, IPaymentService paymentService,
IPricingClient pricingClient,
ISubscriberService subscriberService, ISubscriberService subscriberService,
IPaymentHistoryService paymentHistoryService, IPaymentHistoryService paymentHistoryService,
IUserService userService) : BaseBillingController IUserService userService) : BaseBillingController
@ -279,10 +281,18 @@ public class OrganizationBillingController(
} }
var organizationSignup = model.ToOrganizationSignup(user); var organizationSignup = model.ToOrganizationSignup(user);
var sale = OrganizationSale.From(organization, organizationSignup); var sale = OrganizationSale.From(organization, organizationSignup);
var plan = StaticStore.GetPlan(model.PlanType); var plan = await pricingClient.GetPlanOrThrow(model.PlanType);
sale.Organization.PlanType = plan.Type; sale.Organization.PlanType = plan.Type;
sale.Organization.Plan = plan.Name; sale.Organization.Plan = plan.Name;
sale.SubscriptionSetup.SkipTrial = true;
await organizationBillingService.Finalize(sale); await organizationBillingService.Finalize(sale);
var org = await organizationRepository.GetByIdAsync(organizationId);
if (organizationSignup.PaymentMethodType != null)
{
var paymentSource = new TokenizedPaymentSource(organizationSignup.PaymentMethodType.Value, organizationSignup.PaymentToken);
var taxInformation = TaxInformation.From(organizationSignup.TaxInfo);
await organizationBillingService.UpdatePaymentMethod(org, paymentSource, taxInformation);
}
return TypedResults.Ok(); return TypedResults.Ok();
} }

View File

@ -1,6 +1,5 @@
using Bit.Api.Models.Request.Organizations; using Bit.Api.Models.Request.Organizations;
using Bit.Api.Models.Response.Organizations; using Bit.Api.Models.Response.Organizations;
using Bit.Core;
using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationConnections.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationConnections.Interfaces;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
@ -107,7 +106,7 @@ public class OrganizationSponsorshipsController : Controller
{ {
var isFreeFamilyPolicyEnabled = false; var isFreeFamilyPolicyEnabled = false;
var (isValid, sponsorship) = await _validateRedemptionTokenCommand.ValidateRedemptionTokenAsync(sponsorshipToken, (await CurrentUser).Email); var (isValid, sponsorship) = await _validateRedemptionTokenCommand.ValidateRedemptionTokenAsync(sponsorshipToken, (await CurrentUser).Email);
if (isValid && _featureService.IsEnabled(FeatureFlagKeys.DisableFreeFamiliesSponsorship) && sponsorship.SponsoringOrganizationId.HasValue) if (isValid && sponsorship.SponsoringOrganizationId.HasValue)
{ {
var policy = await _policyRepository.GetByOrganizationIdTypeAsync(sponsorship.SponsoringOrganizationId.Value, var policy = await _policyRepository.GetByOrganizationIdTypeAsync(sponsorship.SponsoringOrganizationId.Value,
PolicyType.FreeFamiliesSponsorshipPolicy); PolicyType.FreeFamiliesSponsorshipPolicy);

View File

@ -8,6 +8,7 @@ using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Constants; using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Entities; using Bit.Core.Billing.Entities;
using Bit.Core.Billing.Models; 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.Context;
@ -45,7 +46,8 @@ public class OrganizationsController(
IAddSecretsManagerSubscriptionCommand addSecretsManagerSubscriptionCommand, IAddSecretsManagerSubscriptionCommand addSecretsManagerSubscriptionCommand,
IReferenceEventService referenceEventService, IReferenceEventService referenceEventService,
ISubscriberService subscriberService, ISubscriberService subscriberService,
IOrganizationInstallationRepository organizationInstallationRepository) IOrganizationInstallationRepository organizationInstallationRepository,
IPricingClient pricingClient)
: Controller : Controller
{ {
[HttpGet("{id:guid}/subscription")] [HttpGet("{id:guid}/subscription")]
@ -62,8 +64,19 @@ public class OrganizationsController(
throw new NotFoundException(); throw new NotFoundException();
} }
if (!globalSettings.SelfHosted && organization.Gateway != null) if (globalSettings.SelfHosted)
{ {
var orgLicense = await licensingService.ReadOrganizationLicenseAsync(organization);
return new OrganizationSubscriptionResponseModel(organization, orgLicense);
}
var plan = await pricingClient.GetPlanOrThrow(organization.PlanType);
if (string.IsNullOrEmpty(organization.GatewaySubscriptionId))
{
return new OrganizationSubscriptionResponseModel(organization, plan);
}
var subscriptionInfo = await paymentService.GetSubscriptionAsync(organization); var subscriptionInfo = await paymentService.GetSubscriptionAsync(organization);
if (subscriptionInfo == null) if (subscriptionInfo == null)
{ {
@ -72,16 +85,7 @@ public class OrganizationsController(
var hideSensitiveData = !await currentContext.EditSubscription(id); var hideSensitiveData = !await currentContext.EditSubscription(id);
return new OrganizationSubscriptionResponseModel(organization, subscriptionInfo, hideSensitiveData); return new OrganizationSubscriptionResponseModel(organization, subscriptionInfo, plan, hideSensitiveData);
}
if (globalSettings.SelfHosted)
{
var orgLicense = await licensingService.ReadOrganizationLicenseAsync(organization);
return new OrganizationSubscriptionResponseModel(organization, orgLicense);
}
return new OrganizationSubscriptionResponseModel(organization);
} }
[HttpGet("{id:guid}/license")] [HttpGet("{id:guid}/license")]
@ -165,7 +169,8 @@ public class OrganizationsController(
organization = await AdjustOrganizationSeatsForSmTrialAsync(id, organization, model); organization = await AdjustOrganizationSeatsForSmTrialAsync(id, organization, model);
var organizationUpdate = model.ToSecretsManagerSubscriptionUpdate(organization); var plan = await pricingClient.GetPlanOrThrow(organization.PlanType);
var organizationUpdate = model.ToSecretsManagerSubscriptionUpdate(organization, plan);
await updateSecretsManagerSubscriptionCommand.UpdateSubscriptionAsync(organizationUpdate); await updateSecretsManagerSubscriptionCommand.UpdateSubscriptionAsync(organizationUpdate);

View File

@ -2,6 +2,7 @@
using Bit.Api.Billing.Models.Responses; using Bit.Api.Billing.Models.Responses;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Models; 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.Context;
@ -20,6 +21,7 @@ namespace Bit.Api.Billing.Controllers;
public class ProviderBillingController( public class ProviderBillingController(
ICurrentContext currentContext, ICurrentContext currentContext,
ILogger<BaseProviderController> logger, ILogger<BaseProviderController> logger,
IPricingClient pricingClient,
IProviderBillingService providerBillingService, IProviderBillingService providerBillingService,
IProviderPlanRepository providerPlanRepository, IProviderPlanRepository providerPlanRepository,
IProviderRepository providerRepository, IProviderRepository providerRepository,
@ -84,13 +86,25 @@ public class ProviderBillingController(
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id); var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
var configuredProviderPlans = await Task.WhenAll(providerPlans.Select(async providerPlan =>
{
var plan = await pricingClient.GetPlanOrThrow(providerPlan.PlanType);
return new ConfiguredProviderPlan(
providerPlan.Id,
providerPlan.ProviderId,
plan,
providerPlan.SeatMinimum ?? 0,
providerPlan.PurchasedSeats ?? 0,
providerPlan.AllocatedSeats ?? 0);
}));
var taxInformation = GetTaxInformation(subscription.Customer); var taxInformation = GetTaxInformation(subscription.Customer);
var subscriptionSuspension = await GetSubscriptionSuspensionAsync(stripeAdapter, subscription); var subscriptionSuspension = await GetSubscriptionSuspensionAsync(stripeAdapter, subscription);
var response = ProviderSubscriptionResponse.From( var response = ProviderSubscriptionResponse.From(
subscription, subscription,
providerPlans, configuredProviderPlans,
taxInformation, taxInformation,
subscriptionSuspension, subscriptionSuspension,
provider); provider);

View File

@ -0,0 +1,12 @@
using System.ComponentModel.DataAnnotations;
namespace Bit.Api.Billing.Models.Requests;
public class AddExistingOrganizationRequestBody
{
[Required(ErrorMessage = "'key' must be provided")]
public string Key { get; set; }
[Required(ErrorMessage = "'organizationId' must be provided")]
public Guid OrganizationId { get; set; }
}

View File

@ -1,9 +1,7 @@
using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.Billing.Entities;
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Models; using Bit.Core.Billing.Models;
using Bit.Core.Utilities;
using Stripe; using Stripe;
namespace Bit.Api.Billing.Models.Responses; namespace Bit.Api.Billing.Models.Responses;
@ -25,26 +23,24 @@ public record ProviderSubscriptionResponse(
public static ProviderSubscriptionResponse From( public static ProviderSubscriptionResponse From(
Subscription subscription, Subscription subscription,
ICollection<ProviderPlan> providerPlans, ICollection<ConfiguredProviderPlan> providerPlans,
TaxInformation taxInformation, TaxInformation taxInformation,
SubscriptionSuspension subscriptionSuspension, SubscriptionSuspension subscriptionSuspension,
Provider provider) Provider provider)
{ {
var providerPlanResponses = providerPlans var providerPlanResponses = providerPlans
.Where(providerPlan => providerPlan.IsConfigured()) .Select(providerPlan =>
.Select(ConfiguredProviderPlan.From)
.Select(configuredProviderPlan =>
{ {
var plan = StaticStore.GetPlan(configuredProviderPlan.PlanType); var plan = providerPlan.Plan;
var cost = (configuredProviderPlan.SeatMinimum + configuredProviderPlan.PurchasedSeats) * plan.PasswordManager.ProviderPortalSeatPrice; var cost = (providerPlan.SeatMinimum + providerPlan.PurchasedSeats) * plan.PasswordManager.ProviderPortalSeatPrice;
var cadence = plan.IsAnnual ? _annualCadence : _monthlyCadence; var cadence = plan.IsAnnual ? _annualCadence : _monthlyCadence;
return new ProviderPlanResponse( return new ProviderPlanResponse(
plan.Name, plan.Name,
plan.Type, plan.Type,
plan.ProductTier, plan.ProductTier,
configuredProviderPlan.SeatMinimum, providerPlan.SeatMinimum,
configuredProviderPlan.PurchasedSeats, providerPlan.PurchasedSeats,
configuredProviderPlan.AssignedSeats, providerPlan.AssignedSeats,
cost, cost,
cadence); cadence);
}); });

View File

@ -1,6 +1,7 @@
using System.Net; using System.Net;
using Bit.Api.Billing.Public.Models; using Bit.Api.Billing.Public.Models;
using Bit.Api.Models.Public.Response; using Bit.Api.Models.Public.Response;
using Bit.Core.Billing.Pricing;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
using Bit.Core.Repositories; using Bit.Core.Repositories;
@ -21,19 +22,22 @@ public class OrganizationController : Controller
private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationRepository _organizationRepository;
private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand; private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand;
private readonly ILogger<OrganizationController> _logger; private readonly ILogger<OrganizationController> _logger;
private readonly IPricingClient _pricingClient;
public OrganizationController( public OrganizationController(
IOrganizationService organizationService, IOrganizationService organizationService,
ICurrentContext currentContext, ICurrentContext currentContext,
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand, IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand,
ILogger<OrganizationController> logger) ILogger<OrganizationController> logger,
IPricingClient pricingClient)
{ {
_organizationService = organizationService; _organizationService = organizationService;
_currentContext = currentContext; _currentContext = currentContext;
_organizationRepository = organizationRepository; _organizationRepository = organizationRepository;
_updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand; _updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand;
_logger = logger; _logger = logger;
_pricingClient = pricingClient;
} }
/// <summary> /// <summary>
@ -140,7 +144,8 @@ public class OrganizationController : Controller
return "Organization has no access to Secrets Manager."; return "Organization has no access to Secrets Manager.";
} }
var secretsManagerUpdate = model.SecretsManager.ToSecretsManagerSubscriptionUpdate(organization); var plan = await _pricingClient.GetPlanOrThrow(organization.PlanType);
var secretsManagerUpdate = model.SecretsManager.ToSecretsManagerSubscriptionUpdate(organization, plan);
await _updateSecretsManagerSubscriptionCommand.UpdateSubscriptionAsync(secretsManagerUpdate); await _updateSecretsManagerSubscriptionCommand.UpdateSubscriptionAsync(secretsManagerUpdate);
return string.Empty; return string.Empty;

View File

@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.Models.Business; using Bit.Core.Models.Business;
using Bit.Core.Models.StaticStore;
namespace Bit.Api.Billing.Public.Models; namespace Bit.Api.Billing.Public.Models;
@ -93,17 +94,17 @@ public class SecretsManagerSubscriptionUpdateModel
set { _maxAutoScaleServiceAccounts = value < 0 ? null : value; } set { _maxAutoScaleServiceAccounts = value < 0 ? null : value; }
} }
public virtual SecretsManagerSubscriptionUpdate ToSecretsManagerSubscriptionUpdate(Organization organization) public virtual SecretsManagerSubscriptionUpdate ToSecretsManagerSubscriptionUpdate(Organization organization, Plan plan)
{ {
var update = UpdateUpdateMaxAutoScale(organization); var update = UpdateUpdateMaxAutoScale(organization, plan);
UpdateSeats(organization, update); UpdateSeats(organization, update);
UpdateServiceAccounts(organization, update); UpdateServiceAccounts(organization, update);
return update; return update;
} }
private SecretsManagerSubscriptionUpdate UpdateUpdateMaxAutoScale(Organization organization) private SecretsManagerSubscriptionUpdate UpdateUpdateMaxAutoScale(Organization organization, Plan plan)
{ {
var update = new SecretsManagerSubscriptionUpdate(organization, false) var update = new SecretsManagerSubscriptionUpdate(organization, plan, false)
{ {
MaxAutoscaleSmSeats = MaxAutoScaleSeats ?? organization.MaxAutoscaleSmSeats, MaxAutoscaleSmSeats = MaxAutoScaleSeats ?? organization.MaxAutoscaleSmSeats,
MaxAutoscaleSmServiceAccounts = MaxAutoScaleServiceAccounts ?? organization.MaxAutoscaleSmServiceAccounts MaxAutoscaleSmServiceAccounts = MaxAutoScaleServiceAccounts ?? organization.MaxAutoscaleSmServiceAccounts

View File

@ -23,6 +23,6 @@ public class ConfigController : Controller
[HttpGet("")] [HttpGet("")]
public ConfigResponseModel GetConfigs() public ConfigResponseModel GetConfigs()
{ {
return new ConfigResponseModel(_globalSettings, _featureService.GetAll()); return new ConfigResponseModel(_featureService, _globalSettings);
} }
} }

View File

@ -186,6 +186,19 @@ public class DevicesController : Controller
await _deviceService.SaveAsync(model.ToDevice(device)); await _deviceService.SaveAsync(model.ToDevice(device));
} }
[HttpPut("identifier/{identifier}/web-push-auth")]
[HttpPost("identifier/{identifier}/web-push-auth")]
public async Task PutWebPushAuth(string identifier, [FromBody] WebPushAuthRequestModel model)
{
var device = await _deviceRepository.GetByIdentifierAsync(identifier, _userService.GetProperUserId(User).Value);
if (device == null)
{
throw new NotFoundException();
}
await _deviceService.SaveAsync(model.ToData(), device);
}
[AllowAnonymous] [AllowAnonymous]
[HttpPut("identifier/{identifier}/clear-token")] [HttpPut("identifier/{identifier}/clear-token")]
[HttpPost("identifier/{identifier}/clear-token")] [HttpPost("identifier/{identifier}/clear-token")]

View File

@ -1,5 +1,5 @@
using Bit.Api.Models.Response; using Bit.Api.Models.Response;
using Bit.Core.Utilities; using Bit.Core.Billing.Pricing;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@ -7,13 +7,15 @@ namespace Bit.Api.Controllers;
[Route("plans")] [Route("plans")]
[Authorize("Web")] [Authorize("Web")]
public class PlansController : Controller public class PlansController(
IPricingClient pricingClient) : Controller
{ {
[HttpGet("")] [HttpGet("")]
[AllowAnonymous] [AllowAnonymous]
public ListResponseModel<PlanResponseModel> Get() public async Task<ListResponseModel<PlanResponseModel>> Get()
{ {
var responses = StaticStore.Plans.Select(plan => new PlanResponseModel(plan)); var plans = await pricingClient.ListPlans();
var responses = plans.Select(plan => new PlanResponseModel(plan));
return new ListResponseModel<PlanResponseModel>(responses); return new ListResponseModel<PlanResponseModel>(responses);
} }
} }

View File

@ -64,7 +64,8 @@ public class SelfHostedOrganizationLicensesController : Controller
var result = await _organizationService.SignUpAsync(license, user, model.Key, var result = await _organizationService.SignUpAsync(license, user, model.Key,
model.CollectionName, model.Keys?.PublicKey, model.Keys?.EncryptedPrivateKey); model.CollectionName, model.Keys?.PublicKey, model.Keys?.EncryptedPrivateKey);
return new OrganizationResponseModel(result.Item1);
return new OrganizationResponseModel(result.Item1, null);
} }
[HttpPost("{id}")] [HttpPost("{id}")]

View File

@ -4,10 +4,9 @@ namespace Bit.Api.Models.Public.Response;
public class ListResponseModel<T> : IResponseModel where T : IResponseModel public class ListResponseModel<T> : IResponseModel where T : IResponseModel
{ {
public ListResponseModel(IEnumerable<T> data, string continuationToken = null) public ListResponseModel(IEnumerable<T> data)
{ {
Data = data; Data = data;
ContinuationToken = continuationToken;
} }
/// <summary> /// <summary>
@ -21,8 +20,4 @@ public class ListResponseModel<T> : IResponseModel where T : IResponseModel
/// </summary> /// </summary>
[Required] [Required]
public IEnumerable<T> Data { get; set; } public IEnumerable<T> Data { get; set; }
/// <summary>
/// A cursor for use in pagination.
/// </summary>
public string ContinuationToken { get; set; }
} }

View File

@ -0,0 +1,10 @@
namespace Bit.Api.Models.Public.Response;
public class PagedListResponseModel<T>(IEnumerable<T> data, string continuationToken) : ListResponseModel<T>(data)
where T : IResponseModel
{
/// <summary>
/// A cursor for use in pagination.
/// </summary>
public string ContinuationToken { get; set; } = continuationToken;
}

View File

@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.NotificationHub;
using Bit.Core.Utilities; using Bit.Core.Utilities;
namespace Bit.Api.Models.Request; namespace Bit.Api.Models.Request;
@ -37,6 +38,26 @@ public class DeviceRequestModel
} }
} }
public class WebPushAuthRequestModel
{
[Required]
public string Endpoint { get; set; }
[Required]
public string P256dh { get; set; }
[Required]
public string Auth { get; set; }
public WebPushRegistrationData ToData()
{
return new WebPushRegistrationData
{
Endpoint = Endpoint,
P256dh = P256dh,
Auth = Auth
};
}
}
public class DeviceTokenRequestModel public class DeviceTokenRequestModel
{ {
[StringLength(255)] [StringLength(255)]

View File

@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.Models.Business; using Bit.Core.Models.Business;
using Bit.Core.Models.StaticStore;
namespace Bit.Api.Models.Request.Organizations; namespace Bit.Api.Models.Request.Organizations;
@ -12,9 +13,9 @@ public class SecretsManagerSubscriptionUpdateRequestModel
public int ServiceAccountAdjustment { get; set; } public int ServiceAccountAdjustment { get; set; }
public int? MaxAutoscaleServiceAccounts { get; set; } public int? MaxAutoscaleServiceAccounts { get; set; }
public virtual SecretsManagerSubscriptionUpdate ToSecretsManagerSubscriptionUpdate(Organization organization) public virtual SecretsManagerSubscriptionUpdate ToSecretsManagerSubscriptionUpdate(Organization organization, Plan plan)
{ {
return new SecretsManagerSubscriptionUpdate(organization, false) return new SecretsManagerSubscriptionUpdate(organization, plan, false)
{ {
MaxAutoscaleSmSeats = MaxAutoscaleSeats, MaxAutoscaleSmSeats = MaxAutoscaleSeats,
MaxAutoscaleSmServiceAccounts = MaxAutoscaleServiceAccounts MaxAutoscaleSmServiceAccounts = MaxAutoscaleServiceAccounts

View File

@ -1,4 +1,7 @@
using Bit.Core.Models.Api; using Bit.Core;
using Bit.Core.Enums;
using Bit.Core.Models.Api;
using Bit.Core.Services;
using Bit.Core.Settings; using Bit.Core.Settings;
using Bit.Core.Utilities; using Bit.Core.Utilities;
@ -11,6 +14,7 @@ public class ConfigResponseModel : ResponseModel
public ServerConfigResponseModel Server { get; set; } public ServerConfigResponseModel Server { get; set; }
public EnvironmentConfigResponseModel Environment { get; set; } public EnvironmentConfigResponseModel Environment { get; set; }
public IDictionary<string, object> FeatureStates { get; set; } public IDictionary<string, object> FeatureStates { get; set; }
public PushSettings Push { get; set; }
public ServerSettingsResponseModel Settings { get; set; } public ServerSettingsResponseModel Settings { get; set; }
public ConfigResponseModel() : base("config") public ConfigResponseModel() : base("config")
@ -23,8 +27,9 @@ public class ConfigResponseModel : ResponseModel
} }
public ConfigResponseModel( public ConfigResponseModel(
IGlobalSettings globalSettings, IFeatureService featureService,
IDictionary<string, object> featureStates) : base("config") IGlobalSettings globalSettings
) : base("config")
{ {
Version = AssemblyHelpers.GetVersion(); Version = AssemblyHelpers.GetVersion();
GitHash = AssemblyHelpers.GetGitHash(); GitHash = AssemblyHelpers.GetGitHash();
@ -37,7 +42,9 @@ public class ConfigResponseModel : ResponseModel
Notifications = globalSettings.BaseServiceUri.Notifications, Notifications = globalSettings.BaseServiceUri.Notifications,
Sso = globalSettings.BaseServiceUri.Sso Sso = globalSettings.BaseServiceUri.Sso
}; };
FeatureStates = featureStates; FeatureStates = featureService.GetAll();
var webPushEnabled = FeatureStates.TryGetValue(FeatureFlagKeys.WebPush, out var webPushEnabledValue) ? (bool)webPushEnabledValue : false;
Push = PushSettings.Build(webPushEnabled, globalSettings);
Settings = new ServerSettingsResponseModel Settings = new ServerSettingsResponseModel
{ {
DisableUserRegistration = globalSettings.DisableUserRegistration DisableUserRegistration = globalSettings.DisableUserRegistration
@ -61,6 +68,23 @@ public class EnvironmentConfigResponseModel
public string Sso { get; set; } public string Sso { get; set; }
} }
public class PushSettings
{
public PushTechnologyType PushTechnology { get; private init; }
public string VapidPublicKey { get; private init; }
public static PushSettings Build(bool webPushEnabled, IGlobalSettings globalSettings)
{
var vapidPublicKey = webPushEnabled ? globalSettings.WebPush.VapidPublicKey : null;
var pushTechnology = vapidPublicKey != null ? PushTechnologyType.WebPush : PushTechnologyType.SignalR;
return new()
{
VapidPublicKey = vapidPublicKey,
PushTechnology = pushTechnology
};
}
}
public class ServerSettingsResponseModel public class ServerSettingsResponseModel
{ {
public bool DisableUserRegistration { get; set; } public bool DisableUserRegistration { get; set; }

View File

@ -1,4 +1,6 @@
using Bit.Core.Billing.Enums; using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Models.Api; using Bit.Core.Models.Api;
using Bit.Core.Models.StaticStore; using Bit.Core.Models.StaticStore;
@ -44,6 +46,13 @@ public class PlanResponseModel : ResponseModel
PasswordManager = new PasswordManagerPlanFeaturesResponseModel(plan.PasswordManager); PasswordManager = new PasswordManagerPlanFeaturesResponseModel(plan.PasswordManager);
} }
public PlanResponseModel(Organization organization, string obj = "plan") : base(obj)
{
Type = organization.PlanType;
ProductTier = organization.PlanType.GetProductTier();
Name = organization.Plan;
}
public PlanType Type { get; set; } public PlanType Type { get; set; }
public ProductTierType ProductTier { get; set; } public ProductTierType ProductTier { get; set; }
public string Name { get; set; } public string Name { get; set; }

View File

@ -1,6 +1,7 @@
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Models.Api; using Bit.Core.Models.Api;
using Bit.Core.NotificationHub;
using Bit.Core.Platform.Push; using Bit.Core.Platform.Push;
using Bit.Core.Settings; using Bit.Core.Settings;
using Bit.Core.Utilities; using Bit.Core.Utilities;
@ -22,14 +23,14 @@ public class PushController : Controller
private readonly IPushNotificationService _pushNotificationService; private readonly IPushNotificationService _pushNotificationService;
private readonly IWebHostEnvironment _environment; private readonly IWebHostEnvironment _environment;
private readonly ICurrentContext _currentContext; private readonly ICurrentContext _currentContext;
private readonly GlobalSettings _globalSettings; private readonly IGlobalSettings _globalSettings;
public PushController( public PushController(
IPushRegistrationService pushRegistrationService, IPushRegistrationService pushRegistrationService,
IPushNotificationService pushNotificationService, IPushNotificationService pushNotificationService,
IWebHostEnvironment environment, IWebHostEnvironment environment,
ICurrentContext currentContext, ICurrentContext currentContext,
GlobalSettings globalSettings) IGlobalSettings globalSettings)
{ {
_currentContext = currentContext; _currentContext = currentContext;
_environment = environment; _environment = environment;
@ -39,22 +40,22 @@ public class PushController : Controller
} }
[HttpPost("register")] [HttpPost("register")]
public async Task PostRegister([FromBody] PushRegistrationRequestModel model) public async Task RegisterAsync([FromBody] PushRegistrationRequestModel model)
{ {
CheckUsage(); CheckUsage();
await _pushRegistrationService.CreateOrUpdateRegistrationAsync(model.PushToken, Prefix(model.DeviceId), await _pushRegistrationService.CreateOrUpdateRegistrationAsync(new PushRegistrationData(model.PushToken), Prefix(model.DeviceId),
Prefix(model.UserId), Prefix(model.Identifier), model.Type); Prefix(model.UserId), Prefix(model.Identifier), model.Type, model.OrganizationIds.Select(Prefix), model.InstallationId);
} }
[HttpPost("delete")] [HttpPost("delete")]
public async Task PostDelete([FromBody] PushDeviceRequestModel model) public async Task DeleteAsync([FromBody] PushDeviceRequestModel model)
{ {
CheckUsage(); CheckUsage();
await _pushRegistrationService.DeleteRegistrationAsync(Prefix(model.Id)); await _pushRegistrationService.DeleteRegistrationAsync(Prefix(model.Id));
} }
[HttpPut("add-organization")] [HttpPut("add-organization")]
public async Task PutAddOrganization([FromBody] PushUpdateRequestModel model) public async Task AddOrganizationAsync([FromBody] PushUpdateRequestModel model)
{ {
CheckUsage(); CheckUsage();
await _pushRegistrationService.AddUserRegistrationOrganizationAsync( await _pushRegistrationService.AddUserRegistrationOrganizationAsync(
@ -63,7 +64,7 @@ public class PushController : Controller
} }
[HttpPut("delete-organization")] [HttpPut("delete-organization")]
public async Task PutDeleteOrganization([FromBody] PushUpdateRequestModel model) public async Task DeleteOrganizationAsync([FromBody] PushUpdateRequestModel model)
{ {
CheckUsage(); CheckUsage();
await _pushRegistrationService.DeleteUserRegistrationOrganizationAsync( await _pushRegistrationService.DeleteUserRegistrationOrganizationAsync(
@ -72,19 +73,30 @@ public class PushController : Controller
} }
[HttpPost("send")] [HttpPost("send")]
public async Task PostSend([FromBody] PushSendRequestModel model) public async Task SendAsync([FromBody] PushSendRequestModel model)
{ {
CheckUsage(); CheckUsage();
if (!string.IsNullOrWhiteSpace(model.UserId)) if (!string.IsNullOrWhiteSpace(model.InstallationId))
{
if (_currentContext.InstallationId!.Value.ToString() != model.InstallationId!)
{
throw new BadRequestException("InstallationId does not match current context.");
}
await _pushNotificationService.SendPayloadToInstallationAsync(
_currentContext.InstallationId.Value.ToString(), model.Type, model.Payload, Prefix(model.Identifier),
Prefix(model.DeviceId), model.ClientType);
}
else if (!string.IsNullOrWhiteSpace(model.UserId))
{ {
await _pushNotificationService.SendPayloadToUserAsync(Prefix(model.UserId), await _pushNotificationService.SendPayloadToUserAsync(Prefix(model.UserId),
model.Type.Value, model.Payload, Prefix(model.Identifier), Prefix(model.DeviceId)); model.Type, model.Payload, Prefix(model.Identifier), Prefix(model.DeviceId), model.ClientType);
} }
else if (!string.IsNullOrWhiteSpace(model.OrganizationId)) else if (!string.IsNullOrWhiteSpace(model.OrganizationId))
{ {
await _pushNotificationService.SendPayloadToOrganizationAsync(Prefix(model.OrganizationId), await _pushNotificationService.SendPayloadToOrganizationAsync(Prefix(model.OrganizationId),
model.Type.Value, model.Payload, Prefix(model.Identifier), Prefix(model.DeviceId)); model.Type, model.Payload, Prefix(model.Identifier), Prefix(model.DeviceId), model.ClientType);
} }
} }
@ -95,7 +107,7 @@ public class PushController : Controller
return null; return null;
} }
return $"{_currentContext.InstallationId.Value}_{value}"; return $"{_currentContext.InstallationId!.Value}_{value}";
} }
private void CheckUsage() private void CheckUsage()

View File

@ -0,0 +1,11 @@
using System.ComponentModel.DataAnnotations;
namespace Bit.Core.Enums;
public enum PushTechnologyType
{
[Display(Name = "SignalR")]
SignalR = 0,
[Display(Name = "WebPush")]
WebPush = 1,
}

View File

@ -1,6 +1,7 @@
using Bit.Api.Models.Response; using Bit.Api.Models.Response;
using Bit.Api.SecretsManager.Models.Request; using Bit.Api.SecretsManager.Models.Request;
using Bit.Api.SecretsManager.Models.Response; using Bit.Api.SecretsManager.Models.Response;
using Bit.Core.Billing.Pricing;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
@ -37,6 +38,7 @@ public class ServiceAccountsController : Controller
private readonly IUpdateServiceAccountCommand _updateServiceAccountCommand; private readonly IUpdateServiceAccountCommand _updateServiceAccountCommand;
private readonly IDeleteServiceAccountsCommand _deleteServiceAccountsCommand; private readonly IDeleteServiceAccountsCommand _deleteServiceAccountsCommand;
private readonly IRevokeAccessTokensCommand _revokeAccessTokensCommand; private readonly IRevokeAccessTokensCommand _revokeAccessTokensCommand;
private readonly IPricingClient _pricingClient;
public ServiceAccountsController( public ServiceAccountsController(
ICurrentContext currentContext, ICurrentContext currentContext,
@ -52,7 +54,8 @@ public class ServiceAccountsController : Controller
ICreateServiceAccountCommand createServiceAccountCommand, ICreateServiceAccountCommand createServiceAccountCommand,
IUpdateServiceAccountCommand updateServiceAccountCommand, IUpdateServiceAccountCommand updateServiceAccountCommand,
IDeleteServiceAccountsCommand deleteServiceAccountsCommand, IDeleteServiceAccountsCommand deleteServiceAccountsCommand,
IRevokeAccessTokensCommand revokeAccessTokensCommand) IRevokeAccessTokensCommand revokeAccessTokensCommand,
IPricingClient pricingClient)
{ {
_currentContext = currentContext; _currentContext = currentContext;
_userService = userService; _userService = userService;
@ -66,6 +69,7 @@ public class ServiceAccountsController : Controller
_updateServiceAccountCommand = updateServiceAccountCommand; _updateServiceAccountCommand = updateServiceAccountCommand;
_deleteServiceAccountsCommand = deleteServiceAccountsCommand; _deleteServiceAccountsCommand = deleteServiceAccountsCommand;
_revokeAccessTokensCommand = revokeAccessTokensCommand; _revokeAccessTokensCommand = revokeAccessTokensCommand;
_pricingClient = pricingClient;
_createAccessTokenCommand = createAccessTokenCommand; _createAccessTokenCommand = createAccessTokenCommand;
_updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand; _updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand;
} }
@ -124,7 +128,9 @@ public class ServiceAccountsController : Controller
if (newServiceAccountSlotsRequired > 0) if (newServiceAccountSlotsRequired > 0)
{ {
var org = await _organizationRepository.GetByIdAsync(organizationId); var org = await _organizationRepository.GetByIdAsync(organizationId);
var update = new SecretsManagerSubscriptionUpdate(org, true) // TODO: https://bitwarden.atlassian.net/browse/PM-17002
var plan = await _pricingClient.GetPlanOrThrow(org!.PlanType);
var update = new SecretsManagerSubscriptionUpdate(org, plan, true)
.AdjustServiceAccounts(newServiceAccountSlotsRequired); .AdjustServiceAccounts(newServiceAccountSlotsRequired);
await _updateSecretsManagerSubscriptionCommand.UpdateSubscriptionAsync(update); await _updateSecretsManagerSubscriptionCommand.UpdateSubscriptionAsync(update);
} }

View File

@ -5,7 +5,7 @@ using Bit.Core.Settings;
using AspNetCoreRateLimit; using AspNetCoreRateLimit;
using Stripe; using Stripe;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using IdentityModel; using Duende.IdentityModel;
using System.Globalization; using System.Globalization;
using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.Auth.Models.Request; using Bit.Api.Auth.Models.Request;

View File

@ -64,8 +64,9 @@ public class ImportCiphersController : Controller
[FromBody] ImportOrganizationCiphersRequestModel model) [FromBody] ImportOrganizationCiphersRequestModel model)
{ {
if (!_globalSettings.SelfHosted && if (!_globalSettings.SelfHosted &&
(model.Ciphers.Count() > 7000 || model.CollectionRelationships.Count() > 14000 || (model.Ciphers.Count() > _globalSettings.ImportCiphersLimitation.CiphersLimit ||
model.Collections.Count() > 2000)) model.CollectionRelationships.Count() > _globalSettings.ImportCiphersLimitation.CollectionRelationshipsLimit ||
model.Collections.Count() > _globalSettings.ImportCiphersLimitation.CollectionsLimit))
{ {
throw new BadRequestException("You cannot import this much data at once."); throw new BadRequestException("You cannot import this much data at once.");
} }
@ -95,12 +96,6 @@ public class ImportCiphersController : Controller
return true; return true;
} }
//Users allowed to import if they CanCreate Collections
if (!(await _authorizationService.AuthorizeAsync(User, collections, BulkCollectionOperations.Create)).Succeeded)
{
return false;
}
//Calling Repository instead of Service as we want to get all the collections, regardless of permission //Calling Repository instead of Service as we want to get all the collections, regardless of permission
//Permissions check will be done later on AuthorizationService //Permissions check will be done later on AuthorizationService
var orgCollectionIds = var orgCollectionIds =
@ -117,6 +112,12 @@ public class ImportCiphersController : Controller
return false; return false;
}; };
//Users allowed to import if they CanCreate Collections
if (!(await _authorizationService.AuthorizeAsync(User, collections, BulkCollectionOperations.Create)).Succeeded)
{
return false;
}
return true; return true;
} }
} }

View File

@ -36,8 +36,6 @@ public static class ServiceCollectionExtensions
} }
}); });
config.CustomSchemaIds(type => type.FullName);
config.SwaggerDoc("internal", new OpenApiInfo { Title = "Bitwarden Internal API", Version = "latest" }); config.SwaggerDoc("internal", new OpenApiInfo { Title = "Bitwarden Internal API", Version = "latest" });
config.AddSecurityDefinition("oauth2-client-credentials", new OpenApiSecurityScheme config.AddSecurityDefinition("oauth2-client-credentials", new OpenApiSecurityScheme

View File

@ -424,6 +424,59 @@ public class CiphersController : Controller
return false; return false;
} }
/// <summary>
/// TODO: Move this to its own authorization handler or equivalent service - AC-2062
/// </summary>
private async Task<bool> CanModifyCipherCollectionsAsync(Guid organizationId, IEnumerable<Guid> cipherIds)
{
// If the user can edit all ciphers for the organization, just check they all belong to the org
if (await CanEditAllCiphersAsync(organizationId))
{
// TODO: This can likely be optimized to only query the requested ciphers and then checking they belong to the org
var orgCiphers = (await _cipherRepository.GetManyByOrganizationIdAsync(organizationId)).ToDictionary(c => c.Id);
// Ensure all requested ciphers are in orgCiphers
if (cipherIds.Any(c => !orgCiphers.ContainsKey(c)))
{
return false;
}
return true;
}
// The user cannot access any ciphers for the organization, we're done
if (!await CanAccessOrganizationCiphersAsync(organizationId))
{
return false;
}
var userId = _userService.GetProperUserId(User).Value;
// Select all editable ciphers for this user belonging to the organization
var editableOrgCipherList = (await _cipherRepository.GetManyByUserIdAsync(userId, true))
.Where(c => c.OrganizationId == organizationId && c.UserId == null && c.Edit && c.ViewPassword).ToList();
// Special case for unassigned ciphers
if (await CanAccessUnassignedCiphersAsync(organizationId))
{
var unassignedCiphers =
(await _cipherRepository.GetManyUnassignedOrganizationDetailsByOrganizationIdAsync(
organizationId));
// Users that can access unassigned ciphers can also edit them
editableOrgCipherList.AddRange(unassignedCiphers.Select(c => new CipherDetails(c) { Edit = true }));
}
var editableOrgCiphers = editableOrgCipherList
.ToDictionary(c => c.Id);
if (cipherIds.Any(c => !editableOrgCiphers.ContainsKey(c)))
{
return false;
}
return true;
}
/// <summary> /// <summary>
/// TODO: Move this to its own authorization handler or equivalent service - AC-2062 /// TODO: Move this to its own authorization handler or equivalent service - AC-2062
/// </summary> /// </summary>
@ -579,7 +632,7 @@ public class CiphersController : Controller
var userId = _userService.GetProperUserId(User).Value; var userId = _userService.GetProperUserId(User).Value;
var cipher = await GetByIdAsync(id, userId); var cipher = await GetByIdAsync(id, userId);
if (cipher == null || !cipher.OrganizationId.HasValue || if (cipher == null || !cipher.OrganizationId.HasValue ||
!await _currentContext.OrganizationUser(cipher.OrganizationId.Value)) !await _currentContext.OrganizationUser(cipher.OrganizationId.Value) || !cipher.ViewPassword)
{ {
throw new NotFoundException(); throw new NotFoundException();
} }
@ -634,7 +687,7 @@ public class CiphersController : Controller
[HttpPost("bulk-collections")] [HttpPost("bulk-collections")]
public async Task PostBulkCollections([FromBody] CipherBulkUpdateCollectionsRequestModel model) public async Task PostBulkCollections([FromBody] CipherBulkUpdateCollectionsRequestModel model)
{ {
if (!await CanEditCiphersAsync(model.OrganizationId, model.CipherIds) || if (!await CanModifyCipherCollectionsAsync(model.OrganizationId, model.CipherIds) ||
!await CanEditItemsInCollections(model.OrganizationId, model.CollectionIds)) !await CanEditItemsInCollections(model.OrganizationId, model.CollectionIds))
{ {
throw new NotFoundException(); throw new NotFoundException();

View File

@ -1,4 +1,5 @@
using Bit.Api.Models.Response; using Bit.Api.Models.Response;
using Bit.Api.Vault.Models.Request;
using Bit.Api.Vault.Models.Response; using Bit.Api.Vault.Models.Response;
using Bit.Core; using Bit.Core;
using Bit.Core.Services; using Bit.Core.Services;
@ -20,17 +21,23 @@ public class SecurityTaskController : Controller
private readonly IGetTaskDetailsForUserQuery _getTaskDetailsForUserQuery; private readonly IGetTaskDetailsForUserQuery _getTaskDetailsForUserQuery;
private readonly IMarkTaskAsCompleteCommand _markTaskAsCompleteCommand; private readonly IMarkTaskAsCompleteCommand _markTaskAsCompleteCommand;
private readonly IGetTasksForOrganizationQuery _getTasksForOrganizationQuery; private readonly IGetTasksForOrganizationQuery _getTasksForOrganizationQuery;
private readonly ICreateManyTasksCommand _createManyTasksCommand;
private readonly ICreateManyTaskNotificationsCommand _createManyTaskNotificationsCommand;
public SecurityTaskController( public SecurityTaskController(
IUserService userService, IUserService userService,
IGetTaskDetailsForUserQuery getTaskDetailsForUserQuery, IGetTaskDetailsForUserQuery getTaskDetailsForUserQuery,
IMarkTaskAsCompleteCommand markTaskAsCompleteCommand, IMarkTaskAsCompleteCommand markTaskAsCompleteCommand,
IGetTasksForOrganizationQuery getTasksForOrganizationQuery) IGetTasksForOrganizationQuery getTasksForOrganizationQuery,
ICreateManyTasksCommand createManyTasksCommand,
ICreateManyTaskNotificationsCommand createManyTaskNotificationsCommand)
{ {
_userService = userService; _userService = userService;
_getTaskDetailsForUserQuery = getTaskDetailsForUserQuery; _getTaskDetailsForUserQuery = getTaskDetailsForUserQuery;
_markTaskAsCompleteCommand = markTaskAsCompleteCommand; _markTaskAsCompleteCommand = markTaskAsCompleteCommand;
_getTasksForOrganizationQuery = getTasksForOrganizationQuery; _getTasksForOrganizationQuery = getTasksForOrganizationQuery;
_createManyTasksCommand = createManyTasksCommand;
_createManyTaskNotificationsCommand = createManyTaskNotificationsCommand;
} }
/// <summary> /// <summary>
@ -71,4 +78,22 @@ public class SecurityTaskController : Controller
var response = securityTasks.Select(x => new SecurityTasksResponseModel(x)).ToList(); var response = securityTasks.Select(x => new SecurityTasksResponseModel(x)).ToList();
return new ListResponseModel<SecurityTasksResponseModel>(response); return new ListResponseModel<SecurityTasksResponseModel>(response);
} }
/// <summary>
/// Bulk create security tasks for an organization.
/// </summary>
/// <param name="orgId"></param>
/// <param name="model"></param>
/// <returns>A list response model containing the security tasks created for the organization.</returns>
[HttpPost("{orgId:guid}/bulk-create")]
public async Task<ListResponseModel<SecurityTasksResponseModel>> BulkCreateTasks(Guid orgId,
[FromBody] BulkCreateSecurityTasksRequestModel model)
{
var securityTasks = await _createManyTasksCommand.CreateAsync(orgId, model.Tasks);
await _createManyTaskNotificationsCommand.CreateAsync(orgId, securityTasks);
var response = securityTasks.Select(x => new SecurityTasksResponseModel(x)).ToList();
return new ListResponseModel<SecurityTasksResponseModel>(response);
}
} }

View File

@ -0,0 +1,8 @@
using Bit.Core.Vault.Models.Api;
namespace Bit.Api.Vault.Models.Request;
public class BulkCreateSecurityTasksRequestModel
{
public IEnumerable<SecurityTaskCreateRequest> Tasks { get; set; }
}

View File

@ -56,6 +56,11 @@
"publicKey": "SECRET", "publicKey": "SECRET",
"privateKey": "SECRET" "privateKey": "SECRET"
}, },
"importCiphersLimitation": {
"ciphersLimit": 40000,
"collectionRelationshipsLimit": 80000,
"collectionsLimit": 2000
},
"bitPay": { "bitPay": {
"production": false, "production": false,
"token": "SECRET", "token": "SECRET",

View File

@ -3,6 +3,8 @@
<PropertyGroup> <PropertyGroup>
<UserSecretsId>bitwarden-Billing</UserSecretsId> <UserSecretsId>bitwarden-Billing</UserSecretsId>
<MvcRazorCompileOnPublish>false</MvcRazorCompileOnPublish> <MvcRazorCompileOnPublish>false</MvcRazorCompileOnPublish>
<!-- Temp exclusions until warnings are fixed -->
<WarningsNotAsErrors>$(WarningsNotAsErrors);CS9113</WarningsNotAsErrors>
</PropertyGroup> </PropertyGroup>
<PropertyGroup Condition=" '$(RunConfiguration)' == 'Billing' " /> <PropertyGroup Condition=" '$(RunConfiguration)' == 'Billing' " />
@ -10,5 +12,8 @@
<ProjectReference Include="..\SharedWeb\SharedWeb.csproj" /> <ProjectReference Include="..\SharedWeb\SharedWeb.csproj" />
<ProjectReference Include="..\Core\Core.csproj" /> <ProjectReference Include="..\Core\Core.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.2.0" />
</ItemGroup>
</Project> </Project>

View File

@ -12,6 +12,7 @@ public class BillingSettings
public virtual FreshDeskSettings FreshDesk { get; set; } = new FreshDeskSettings(); public virtual FreshDeskSettings FreshDesk { get; set; } = new FreshDeskSettings();
public virtual string FreshsalesApiKey { get; set; } public virtual string FreshsalesApiKey { get; set; }
public virtual PayPalSettings PayPal { get; set; } = new PayPalSettings(); public virtual PayPalSettings PayPal { get; set; } = new PayPalSettings();
public virtual OnyxSettings Onyx { get; set; } = new OnyxSettings();
public class PayPalSettings public class PayPalSettings
{ {
@ -31,4 +32,10 @@ public class BillingSettings
public virtual string UserFieldName { get; set; } public virtual string UserFieldName { get; set; }
public virtual string OrgFieldName { get; set; } public virtual string OrgFieldName { get; set; }
} }
public class OnyxSettings
{
public virtual string ApiKey { get; set; }
public virtual string BaseUrl { get; set; }
}
} }

View File

@ -1,6 +1,7 @@
using System.Globalization; using System.Globalization;
using Bit.Billing.Models; using Bit.Billing.Models;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Services;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Repositories; using Bit.Core.Repositories;
@ -13,6 +14,7 @@ using Microsoft.Extensions.Options;
namespace Bit.Billing.Controllers; namespace Bit.Billing.Controllers;
[Route("bitpay")] [Route("bitpay")]
[ApiExplorerSettings(IgnoreApi = true)]
public class BitPayController : Controller public class BitPayController : Controller
{ {
private readonly BillingSettings _billingSettings; private readonly BillingSettings _billingSettings;
@ -24,6 +26,7 @@ public class BitPayController : Controller
private readonly IMailService _mailService; private readonly IMailService _mailService;
private readonly IPaymentService _paymentService; private readonly IPaymentService _paymentService;
private readonly ILogger<BitPayController> _logger; private readonly ILogger<BitPayController> _logger;
private readonly IPremiumUserBillingService _premiumUserBillingService;
public BitPayController( public BitPayController(
IOptions<BillingSettings> billingSettings, IOptions<BillingSettings> billingSettings,
@ -34,7 +37,8 @@ public class BitPayController : Controller
IProviderRepository providerRepository, IProviderRepository providerRepository,
IMailService mailService, IMailService mailService,
IPaymentService paymentService, IPaymentService paymentService,
ILogger<BitPayController> logger) ILogger<BitPayController> logger,
IPremiumUserBillingService premiumUserBillingService)
{ {
_billingSettings = billingSettings?.Value; _billingSettings = billingSettings?.Value;
_bitPayClient = bitPayClient; _bitPayClient = bitPayClient;
@ -45,6 +49,7 @@ public class BitPayController : Controller
_mailService = mailService; _mailService = mailService;
_paymentService = paymentService; _paymentService = paymentService;
_logger = logger; _logger = logger;
_premiumUserBillingService = premiumUserBillingService;
} }
[HttpPost("ipn")] [HttpPost("ipn")]
@ -144,10 +149,7 @@ public class BitPayController : Controller
if (user != null) if (user != null)
{ {
billingEmail = user.BillingEmailAddress(); billingEmail = user.BillingEmailAddress();
if (await _paymentService.CreditAccountAsync(user, tx.Amount)) await _premiumUserBillingService.Credit(user, tx.Amount);
{
await _userRepository.ReplaceAsync(user);
}
} }
} }
else if (tx.ProviderId.HasValue) else if (tx.ProviderId.HasValue)

View File

@ -1,6 +1,8 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Net.Http.Headers;
using System.Reflection; using System.Reflection;
using System.Text; using System.Text;
using System.Text.Json;
using System.Web; using System.Web;
using Bit.Billing.Models; using Bit.Billing.Models;
using Bit.Core.Repositories; using Bit.Core.Repositories;
@ -142,6 +144,121 @@ public class FreshdeskController : Controller
} }
} }
[HttpPost("webhook-onyx-ai")]
public async Task<IActionResult> PostWebhookOnyxAi([FromQuery, Required] string key,
[FromBody, Required] FreshdeskWebhookModel model)
{
// ensure that the key is from Freshdesk
if (!IsValidRequestFromFreshdesk(key))
{
return new BadRequestResult();
}
// get ticket info from Freshdesk
var getTicketRequest = new HttpRequestMessage(HttpMethod.Get,
string.Format("https://bitwarden.freshdesk.com/api/v2/tickets/{0}", model.TicketId));
var getTicketResponse = await CallFreshdeskApiAsync(getTicketRequest);
// check if we have a valid response from freshdesk
if (getTicketResponse.StatusCode != System.Net.HttpStatusCode.OK)
{
_logger.LogError("Error getting ticket info from Freshdesk. Ticket Id: {0}. Status code: {1}",
model.TicketId, getTicketResponse.StatusCode);
return BadRequest("Failed to retrieve ticket info from Freshdesk");
}
// extract info from the response
var ticketInfo = await ExtractTicketInfoFromResponse(getTicketResponse);
if (ticketInfo == null)
{
return BadRequest("Failed to extract ticket info from Freshdesk response");
}
// create the onyx `answer-with-citation` request
var onyxRequestModel = new OnyxAnswerWithCitationRequestModel(ticketInfo.DescriptionText);
var onyxRequest = new HttpRequestMessage(HttpMethod.Post,
string.Format("{0}/query/answer-with-citation", _billingSettings.Onyx.BaseUrl))
{
Content = JsonContent.Create(onyxRequestModel, mediaType: new MediaTypeHeaderValue("application/json")),
};
var (_, onyxJsonResponse) = await CallOnyxApi<OnyxAnswerWithCitationResponseModel>(onyxRequest);
// the CallOnyxApi will return a null if we have an error response
if (onyxJsonResponse?.Answer == null || !string.IsNullOrEmpty(onyxJsonResponse?.ErrorMsg))
{
return BadRequest(
string.Format("Failed to get a valid response from Onyx API. Response: {0}",
JsonSerializer.Serialize(onyxJsonResponse ?? new OnyxAnswerWithCitationResponseModel())));
}
// add the answer as a note to the ticket
await AddAnswerNoteToTicketAsync(onyxJsonResponse.Answer, model.TicketId);
return Ok();
}
private bool IsValidRequestFromFreshdesk(string key)
{
if (string.IsNullOrWhiteSpace(key)
|| !CoreHelpers.FixedTimeEquals(key, _billingSettings.FreshDesk.WebhookKey))
{
return false;
}
return true;
}
private async Task AddAnswerNoteToTicketAsync(string note, string ticketId)
{
// if there is no content, then we don't need to add a note
if (string.IsNullOrWhiteSpace(note))
{
return;
}
var noteBody = new Dictionary<string, object>
{
{ "body", $"<b>Onyx AI:</b><ul>{note}</ul>" },
{ "private", true }
};
var noteRequest = new HttpRequestMessage(HttpMethod.Post,
string.Format("https://bitwarden.freshdesk.com/api/v2/tickets/{0}/notes", ticketId))
{
Content = JsonContent.Create(noteBody),
};
var addNoteResponse = await CallFreshdeskApiAsync(noteRequest);
if (addNoteResponse.StatusCode != System.Net.HttpStatusCode.Created)
{
_logger.LogError("Error adding note to Freshdesk ticket. Ticket Id: {0}. Status: {1}",
ticketId, addNoteResponse.ToString());
}
}
private async Task<FreshdeskViewTicketModel> ExtractTicketInfoFromResponse(HttpResponseMessage getTicketResponse)
{
var responseString = string.Empty;
try
{
responseString = await getTicketResponse.Content.ReadAsStringAsync();
var ticketInfo = JsonSerializer.Deserialize<FreshdeskViewTicketModel>(responseString,
options: new System.Text.Json.JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
});
return ticketInfo;
}
catch (System.Exception ex)
{
_logger.LogError("Error deserializing ticket info from Freshdesk response. Response: {0}. Exception {1}",
responseString, ex.ToString());
}
return null;
}
private async Task<HttpResponseMessage> CallFreshdeskApiAsync(HttpRequestMessage request, int retriedCount = 0) private async Task<HttpResponseMessage> CallFreshdeskApiAsync(HttpRequestMessage request, int retriedCount = 0)
{ {
try try
@ -166,6 +283,26 @@ public class FreshdeskController : Controller
return await CallFreshdeskApiAsync(request, retriedCount++); return await CallFreshdeskApiAsync(request, retriedCount++);
} }
private async Task<(HttpResponseMessage, T)> CallOnyxApi<T>(HttpRequestMessage request)
{
var httpClient = _httpClientFactory.CreateClient("OnyxApi");
var response = await httpClient.SendAsync(request);
if (response.StatusCode != System.Net.HttpStatusCode.OK)
{
_logger.LogError("Error calling Onyx AI API. Status code: {0}. Response {1}",
response.StatusCode, JsonSerializer.Serialize(response));
return (null, default);
}
var responseStr = await response.Content.ReadAsStringAsync();
var responseJson = JsonSerializer.Deserialize<T>(responseStr, options: new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
});
return (response, responseJson);
}
private TAttribute GetAttribute<TAttribute>(Enum enumValue) where TAttribute : Attribute private TAttribute GetAttribute<TAttribute>(Enum enumValue) where TAttribute : Attribute
{ {
return enumValue.GetType().GetMember(enumValue.ToString()).First().GetCustomAttribute<TAttribute>(); return enumValue.GetType().GetMember(enumValue.ToString()).First().GetCustomAttribute<TAttribute>();

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