1
0
mirror of https://github.com/bitwarden/server.git synced 2025-05-27 22:34:54 -05:00

Merge branch 'main' into PM-16921

This commit is contained in:
Jonas Hendrickx 2025-02-20 12:13:03 +01:00 committed by GitHub
commit 507b72caac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
406 changed files with 48274 additions and 3624 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", "patch"]
},
{
"groupName": "docker-compose minor",
"matchManagers": ["docker-compose"],
"matchUpdateTypes": ["minor", "patch"]
},
{
"groupName": "gh minor",
"matchManagers": ["github-actions"],
"matchUpdateTypes": ["minor", "patch"]
},
{
"matchManagers": ["github-actions", "dockerfile", "docker-compose"],
"commitMessagePrefix": "[deps] 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

@ -120,7 +120,7 @@ jobs:
ls -atlh ../../../
- name: Upload project artifact
uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: ${{ matrix.project_name }}.zip
path: ${{ matrix.base_path }}/${{ matrix.project_name }}/${{ matrix.project_name }}.zip
@ -278,7 +278,7 @@ jobs:
- name: Build Docker image
id: build-docker
uses: docker/build-push-action@b32b51a8eda65d6793cd0494a773d4f6bcef32dc # v6.11.0
uses: docker/build-push-action@67a2d409c0a876cbe6b11854e3e25193efe4e62d # v6.12.0
with:
context: ${{ matrix.base_path }}/${{ matrix.project_name }}
file: ${{ matrix.base_path }}/${{ matrix.project_name }}/Dockerfile
@ -314,7 +314,7 @@ jobs:
output-format: sarif
- 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:
sarif_file: ${{ steps.container-scan.outputs.sarif }}
@ -393,7 +393,7 @@ jobs:
if: |
github.event_name != 'pull_request_target'
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: docker-stub-US.zip
path: docker-stub-US.zip
@ -403,7 +403,7 @@ jobs:
if: |
github.event_name != 'pull_request_target'
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: docker-stub-EU.zip
path: docker-stub-EU.zip
@ -413,7 +413,7 @@ jobs:
if: |
github.event_name != 'pull_request_target'
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: docker-stub-US-sha256.txt
path: docker-stub-US-sha256.txt
@ -423,7 +423,7 @@ jobs:
if: |
github.event_name != 'pull_request_target'
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: docker-stub-EU-sha256.txt
path: docker-stub-EU-sha256.txt
@ -447,7 +447,7 @@ jobs:
GLOBALSETTINGS__SQLSERVER__CONNECTIONSTRING: "placeholder"
- name: Upload Public API Swagger artifact
uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: swagger.json
path: swagger.json
@ -481,14 +481,14 @@ jobs:
GLOBALSETTINGS__SQLSERVER__CONNECTIONSTRING: "placeholder"
- name: Upload Internal API Swagger artifact
uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: internal.json
path: internal.json
if-no-files-found: error
- name: Upload Identity Swagger artifact
uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: identity.json
path: identity.json
@ -533,7 +533,7 @@ jobs:
- name: Upload project artifact for Windows
if: ${{ contains(matrix.target, 'win') == true }}
uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: MsSqlMigratorUtility-${{ matrix.target }}
path: util/MsSqlMigratorUtility/obj/build-output/publish/MsSqlMigratorUtility.exe
@ -541,7 +541,7 @@ jobs:
- name: Upload project artifact
if: ${{ contains(matrix.target, 'win') == false }}
uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: MsSqlMigratorUtility-${{ matrix.target }}
path: util/MsSqlMigratorUtility/obj/build-output/publish/MsSqlMigratorUtility

View File

@ -37,7 +37,7 @@ jobs:
- name: Collect
id: collect
uses: launchdarkly/find-code-references-in-pull-request@b2d44bb453e13c11fd1a6ada7b1e5f9fb0ace629 # v2.0.1
uses: launchdarkly/find-code-references-in-pull-request@30f4c4ab2949bbf258b797ced2fbf6dea34df9ce # v2.1.0
with:
project-key: default
environment-key: dev

View File

@ -85,7 +85,7 @@ jobs:
- name: Create release
if: ${{ inputs.release_type != 'Dry Run' }}
uses: ncipollo/release-action@2c591bcc8ecdcd2db72b97d6147f871fcd833ba5 # v1.14.0
uses: ncipollo/release-action@cdcc88a9acf3ca41c16c37bb7d21b9ad48560d87 # v1.15.0
with:
artifacts: "docker-stub-US.zip,
docker-stub-US-sha256.txt,

View File

@ -46,7 +46,7 @@ jobs:
--output-path . ${{ env.INCREMENTAL }}
- 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:
sarif_file: cx_result.sarif
@ -80,12 +80,11 @@ jobs:
- name: Scan with SonarCloud
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
dotnet-sonarscanner begin /k:"${{ github.repository_owner }}_${{ github.event.repository.name }}" \
/d:sonar.test.inclusions=test/,bitwarden_license/test/ \
/d:sonar.exclusions=test/,bitwarden_license/test/ \
/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-sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}"

View File

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

View File

@ -17,6 +17,7 @@ on:
- "src/Infrastructure.Dapper/**" # Changes to SQL Server Dapper Repository Layer
- "src/Infrastructure.EntityFramework/**" # Changes to Entity Framework Repository Layer
- "test/Infrastructure.IntegrationTest/**" # Any changes to the tests
- "src/**/Entities/**/*.cs" # Database entity definitions
pull_request:
paths:
- ".github/workflows/test-database.yml" # This file
@ -28,6 +29,7 @@ on:
- "src/Infrastructure.Dapper/**" # Changes to SQL Server Dapper Repository Layer
- "src/Infrastructure.EntityFramework/**" # Changes to Entity Framework Repository Layer
- "test/Infrastructure.IntegrationTest/**" # Any changes to the tests
- "src/**/Entities/**/*.cs" # Database entity definitions
jobs:
check-test-secrets:
@ -200,7 +202,7 @@ jobs:
shell: pwsh
- name: Upload DACPAC
uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: sql.dacpac
path: Sql.dacpac
@ -226,7 +228,7 @@ jobs:
shell: pwsh
- name: Report validation results
uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: report.xml
path: |

View File

@ -78,6 +78,3 @@ jobs:
- name: Upload to codecov.io
uses: codecov/codecov-action@1e68e06f1dbfde0e4cefc87efeba9e4643565303 # v5.1.2
if: ${{ needs.check-test-secrets.outputs.available == 'true' }}
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}

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

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

View File

@ -3,11 +3,17 @@
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Version>2025.1.2</Version>
<Version>2025.2.0</Version>
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<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>
<!--

View File

@ -125,6 +125,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Notifications.Test", "test\
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Infrastructure.Dapper.Test", "test\Infrastructure.Dapper.Test\Infrastructure.Dapper.Test.csproj", "{4A725DB3-BE4F-4C23-9087-82D0610D67AF}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Events.IntegrationTest", "test\Events.IntegrationTest\Events.IntegrationTest.csproj", "{4F4C63A9-AEE2-48C4-AB86-A5BCD665E401}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
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}.Release|Any CPU.ActiveCfg = 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
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@ -363,6 +369,7 @@ Global
{81673EFB-7134-4B4B-A32F-1EA05F0EF3CE} = {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}
{4F4C63A9-AEE2-48C4-AB86-A5BCD665E401} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {E01CBF68-2E20-425F-9EDB-E0A6510CA92F}

View File

@ -1,12 +1,15 @@
using System.Globalization;
using Bit.Commercial.Core.Billing.Models;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Entities;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Repositories;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Services.Contracts;
@ -24,6 +27,7 @@ using Stripe;
namespace Bit.Commercial.Core.Billing;
public class ProviderBillingService(
IEventService eventService,
IGlobalSettings globalSettings,
ILogger<ProviderBillingService> logger,
IOrganizationRepository organizationRepository,
@ -31,10 +35,111 @@ public class ProviderBillingService(
IProviderInvoiceItemRepository providerInvoiceItemRepository,
IProviderOrganizationRepository providerOrganizationRepository,
IProviderPlanRepository providerPlanRepository,
IProviderUserRepository providerUserRepository,
IStripeAdapter stripeAdapter,
ISubscriberService subscriberService,
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);
// TODO: Replace with PricingClient
var plan = StaticStore.GetPlan(managedPlanType);
organization.Plan = plan.Name;
organization.PlanType = plan.Type;
organization.MaxCollections = plan.PasswordManager.MaxCollections;
organization.MaxStorageGb = plan.PasswordManager.BaseStorageGb;
organization.UsePolicies = plan.HasPolicies;
organization.UseSso = plan.HasSso;
organization.UseGroups = plan.HasGroups;
organization.UseEvents = plan.HasEvents;
organization.UseDirectory = plan.HasDirectory;
organization.UseTotp = plan.HasTotp;
organization.Use2fa = plan.Has2fa;
organization.UseApi = plan.HasApi;
organization.UseResetPassword = plan.HasResetPassword;
organization.SelfHost = plan.HasSelfHost;
organization.UsersGetPremium = plan.UsersGetPremium;
organization.UseCustomPermissions = plan.HasCustomPermissions;
organization.UseScim = plan.HasScim;
organization.UseKeyConnector = plan.HasKeyConnector;
organization.MaxStorageGb = plan.PasswordManager.BaseStorageGb;
organization.BillingEmail = provider.BillingEmail!;
organization.GatewaySubscriptionId = null;
organization.ExpirationDate = null;
organization.MaxAutoscaleSeats = null;
organization.Status = OrganizationStatusType.Managed;
var providerOrganization = new ProviderOrganization
{
ProviderId = provider.Id,
OrganizationId = organization.Id,
Key = key
};
/*
* We have to scale the provider's seats before the ProviderOrganization
* row is inserted so the added organization's seats don't get double counted.
*/
await ScaleSeats(provider, organization.PlanType, organization.Seats!.Value);
await Task.WhenAll(
organizationRepository.ReplaceAsync(organization),
providerOrganizationRepository.CreateAsync(providerOrganization)
);
var clientCustomer = await subscriberService.GetCustomer(organization);
if (clientCustomer.Balance != 0)
{
await stripeAdapter.CustomerBalanceTransactionCreate(provider.GatewayCustomerId,
new CustomerBalanceTransactionCreateOptions
{
Amount = clientCustomer.Balance,
Currency = "USD",
Description = $"Unused, prorated time for client organization with ID {organization.Id}."
});
}
await eventService.LogProviderOrganizationEventAsync(
providerOrganization,
EventType.ProviderOrganization_Added);
}
public async Task ChangePlan(ChangeProviderPlanCommand command)
{
var plan = await providerPlanRepository.GetByIdAsync(command.ProviderPlanId);
@ -206,6 +311,81 @@ public class ProviderBillingService(
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 = 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 };
}));
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()
};
}
// TODO: Replace with PricingClient
var plan = StaticStore.GetPlan(localOrganization.PlanType);
return plan.Name;
}
}
public async Task ScaleSeats(
Provider provider,
PlanType planType,
@ -352,12 +532,10 @@ public class ProviderBillingService(
throw new BadRequestException("billingTaxIdTypeInferenceError");
}
customerCreateOptions.TaxIdData = taxInfo.HasTaxId
?
[
new CustomerTaxIdDataOptions { Type = taxIdType, Value = taxInfo.TaxIdNumber }
]
: null;
customerCreateOptions.TaxIdData =
[
new CustomerTaxIdDataOptions { Type = taxIdType, Value = taxInfo.TaxIdNumber }
];
}
try
@ -584,4 +762,21 @@ public class ProviderBillingService(
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

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

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.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Scim.Groups.Interfaces;
using Bit.Scim.Models;
using Bit.Scim.Utilities;
@ -22,9 +24,10 @@ public class GroupsController : Controller
private readonly IGetGroupsListQuery _getGroupsListQuery;
private readonly IDeleteGroupCommand _deleteGroupCommand;
private readonly IPatchGroupCommand _patchGroupCommand;
private readonly IPatchGroupCommandvNext _patchGroupCommandvNext;
private readonly IPostGroupCommand _postGroupCommand;
private readonly IPutGroupCommand _putGroupCommand;
private readonly ILogger<GroupsController> _logger;
private readonly IFeatureService _featureService;
public GroupsController(
IGroupRepository groupRepository,
@ -32,18 +35,21 @@ public class GroupsController : Controller
IGetGroupsListQuery getGroupsListQuery,
IDeleteGroupCommand deleteGroupCommand,
IPatchGroupCommand patchGroupCommand,
IPatchGroupCommandvNext patchGroupCommandvNext,
IPostGroupCommand postGroupCommand,
IPutGroupCommand putGroupCommand,
ILogger<GroupsController> logger)
IFeatureService featureService
)
{
_groupRepository = groupRepository;
_organizationRepository = organizationRepository;
_getGroupsListQuery = getGroupsListQuery;
_deleteGroupCommand = deleteGroupCommand;
_patchGroupCommand = patchGroupCommand;
_patchGroupCommandvNext = patchGroupCommandvNext;
_postGroupCommand = postGroupCommand;
_putGroupCommand = putGroupCommand;
_logger = logger;
_featureService = featureService;
}
[HttpGet("{id}")]
@ -97,8 +103,21 @@ public class GroupsController : Controller
[HttpPatch("{id}")]
public async Task<IActionResult> Patch(Guid organizationId, Guid id, [FromBody] ScimPatchModel model)
{
if (_featureService.IsEnabled(FeatureFlagKeys.ShortcutDuplicatePatchRequests))
{
var group = await _groupRepository.GetByIdAsync(id);
if (group == null || group.OrganizationId != organizationId)
{
throw new NotFoundException("Group not found.");
}
await _patchGroupCommandvNext.PatchGroupAsync(group, model);
return new NoContentResult();
}
var organization = await _organizationRepository.GetByIdAsync(organizationId);
await _patchGroupCommand.PatchGroupAsync(organization, id, model);
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.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Scim.Context;
using Bit.Scim.Groups.Interfaces;
using Bit.Scim.Models;
@ -14,17 +11,13 @@ namespace Bit.Scim.Groups;
public class PostGroupCommand : IPostGroupCommand
{
private readonly IGroupRepository _groupRepository;
private readonly IScimContext _scimContext;
private readonly ICreateGroupCommand _createGroupCommand;
public PostGroupCommand(
IGroupRepository groupRepository,
IOrganizationRepository organizationRepository,
IScimContext scimContext,
ICreateGroupCommand createGroupCommand)
{
_groupRepository = groupRepository;
_scimContext = scimContext;
_createGroupCommand = createGroupCommand;
}
@ -50,11 +43,6 @@ public class PostGroupCommand : IPostGroupCommand
private async Task UpdateGroupMembersAsync(Group group, ScimGroupRequestModel model)
{
if (_scimContext.RequestScimProvider != ScimProviderType.Okta)
{
return;
}
if (model.Members == null)
{
return;

View File

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

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 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)
{
services.AddScoped<IPatchGroupCommand, PatchGroupCommand>();
services.AddScoped<IPatchGroupCommandvNext, PatchGroupCommandvNext>();
services.AddScoped<IPostGroupCommand, PostGroupCommand>();
services.AddScoped<IPutGroupCommand, PutGroupCommand>();
}

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
{
private const int _initialGroupCount = 3;
private const int _initialGroupUsersCount = 2;
private readonly ScimApplicationFactory _factory;
public GroupsControllerTests(ScimApplicationFactory factory)
@ -237,10 +234,10 @@ public class GroupsControllerTests : IClassFixture<ScimApplicationFactory>, IAsy
AssertHelper.AssertPropertyEqual(expectedResponse, responseModel, "Id");
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.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));
}
@ -248,7 +245,7 @@ public class GroupsControllerTests : IClassFixture<ScimApplicationFactory>, IAsy
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public async Task Post_InvalidDisplayName_BadRequest(string displayName)
public async Task Post_InvalidDisplayName_BadRequest(string? displayName)
{
var organizationId = ScimApplicationFactory.TestOrganizationId1;
var model = new ScimGroupRequestModel
@ -281,7 +278,7 @@ public class GroupsControllerTests : IClassFixture<ScimApplicationFactory>, IAsy
Assert.Equal(StatusCodes.Status409Conflict, context.Response.StatusCode);
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"));
}
@ -354,216 +351,6 @@ public class GroupsControllerTests : IClassFixture<ScimApplicationFactory>, IAsy
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]
public async Task Delete_Success()
{
@ -575,7 +362,7 @@ public class GroupsControllerTests : IClassFixture<ScimApplicationFactory>, IAsy
Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode);
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);
}

View File

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

View File

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

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.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Scim.Context;
using Bit.Scim.Groups;
using Bit.Scim.Models;
using Bit.Scim.Utilities;
@ -73,10 +71,6 @@ public class PostGroupCommandTests
.GetManyByOrganizationIdAsync(organization.Id)
.Returns(groups);
sutProvider.GetDependency<IScimContext>()
.RequestScimProvider
.Returns(ScimProviderType.Okta);
var group = await sutProvider.Sut.PostGroupAsync(organization, scimGroupRequestModel);
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.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Scim.Context;
using Bit.Scim.Groups;
using Bit.Scim.Models;
using Bit.Scim.Utilities;
@ -62,10 +60,6 @@ public class PutGroupCommandTests
.GetByIdAsync(group.Id)
.Returns(group);
sutProvider.GetDependency<IScimContext>()
.RequestScimProvider
.Returns(ScimProviderType.Okta);
var inputModel = new ScimGroupRequestModel
{
DisplayName = displayName,

View File

@ -20,4 +20,8 @@ IDP_SP_ACS_URL=http://localhost:51822/saml2/yourOrgIdHere/Acs
# Optional reverse proxy configuration
# Should match server listen ports in reverse-proxy.conf
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:
- 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:
image: nginx:alpine
container_name: reverse-proxy
@ -95,7 +109,23 @@ services:
profiles:
- 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:
mssql_dev_data:
postgres_dev_data:
mysql_dev_data:
rabbitmq_data:

View File

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

View File

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

View File

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

View File

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

View File

@ -5,6 +5,7 @@ using Bit.Admin.Services;
using Bit.Admin.Utilities;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
using Bit.Core.AdminConsole.Providers.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Enums;
@ -56,6 +57,7 @@ public class OrganizationsController : Controller
private readonly IRemoveOrganizationFromProviderCommand _removeOrganizationFromProviderCommand;
private readonly IProviderBillingService _providerBillingService;
private readonly IFeatureService _featureService;
private readonly IOrganizationInitiateDeleteCommand _organizationInitiateDeleteCommand;
public OrganizationsController(
IOrganizationService organizationService,
@ -82,7 +84,8 @@ public class OrganizationsController : Controller
IProviderOrganizationRepository providerOrganizationRepository,
IRemoveOrganizationFromProviderCommand removeOrganizationFromProviderCommand,
IProviderBillingService providerBillingService,
IFeatureService featureService)
IFeatureService featureService,
IOrganizationInitiateDeleteCommand organizationInitiateDeleteCommand)
{
_organizationService = organizationService;
_organizationRepository = organizationRepository;
@ -109,6 +112,7 @@ public class OrganizationsController : Controller
_removeOrganizationFromProviderCommand = removeOrganizationFromProviderCommand;
_providerBillingService = providerBillingService;
_featureService = featureService;
_organizationInitiateDeleteCommand = organizationInitiateDeleteCommand;
}
[RequirePermission(Permission.Org_List_View)]
@ -305,7 +309,7 @@ public class OrganizationsController : Controller
[HttpPost]
[ValidateAntiForgeryToken]
[RequirePermission(Permission.Org_Delete)]
[RequirePermission(Permission.Org_RequestDelete)]
public async Task<IActionResult> DeleteInitiation(Guid id, OrganizationInitiateDeleteModel model)
{
if (!ModelState.IsValid)
@ -319,7 +323,7 @@ public class OrganizationsController : Controller
var organization = await _organizationRepository.GetByIdAsync(id);
if (organization != null)
{
await _organizationService.InitiateDeleteAsync(organization, model.AdminEmail);
await _organizationInitiateDeleteCommand.InitiateDeleteAsync(organization, model.AdminEmail);
TempData["Success"] = "The request to initiate deletion of the organization has been sent.";
}
}
@ -417,6 +421,11 @@ public class OrganizationsController : Controller
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))
{
organization.Enabled = model.Enabled;

View File

@ -235,7 +235,8 @@ public class ProvidersController : Controller
var users = await _providerUserRepository.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)]
@ -250,6 +251,18 @@ public class ProvidersController : Controller
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]
[ValidateAntiForgeryToken]
[SelfHosted(NotSelfHostedOnly = true)]

View File

@ -19,7 +19,7 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject
IEnumerable<ProviderOrganizationOrganizationDetails> organizations,
IReadOnlyCollection<ProviderPlan> providerPlans,
string gatewayCustomerUrl = null,
string gatewaySubscriptionUrl = null) : base(provider, providerUsers, organizations)
string gatewaySubscriptionUrl = null) : base(provider, providerUsers, organizations, providerPlans)
{
Name = provider.DisplayName();
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.Models.Data.Provider;
using Bit.Core.Billing.Entities;
using Bit.Core.Billing.Enums;
namespace Bit.Admin.AdminConsole.Models;
@ -8,17 +11,57 @@ public class 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;
UserCount = providerUsers.Count();
ProviderAdmins = providerUsers.Where(u => u.Type == ProviderUserType.ProviderAdmin);
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 Provider Provider { get; set; }
public IEnumerable<ProviderUserUserDetails> ProviderAdmins { get; set; }
public IEnumerable<ProviderOrganizationOrganizationDetails> ProviderOrganizations { get; set; }
public List<ProviderPlanViewModel> ProviderPlanViewModels { get; set; } = [];
}

View File

@ -10,6 +10,7 @@
var canViewOrganizationInformation = AccessControlService.UserHasPermission(Permission.Org_OrgInformation_View);
var canViewBillingInformation = AccessControlService.UserHasPermission(Permission.Org_BillingInformation_View);
var canInitiateTrial = AccessControlService.UserHasPermission(Permission.Org_InitiateTrial);
var canRequestDelete = AccessControlService.UserHasPermission(Permission.Org_RequestDelete);
var canDelete = AccessControlService.UserHasPermission(Permission.Org_Delete);
var canUnlinkFromProvider = AccessControlService.UserHasPermission(Permission.Provider_Edit);
}
@ -120,12 +121,15 @@
Unlink provider
</button>
}
@if (canDelete)
@if (canRequestDelete)
{
<form asp-action="DeleteInitiation" asp-route-id="@Model.Organization.Id" id="initiate-delete-form">
<input type="hidden" name="AdminEmail" id="AdminEmail" />
<button class="btn btn-danger me-2" type="submit">Request Delete</button>
</form>
}
@if (canDelete)
{
<form asp-action="Delete" asp-route-id="@Model.Organization.Id"
onsubmit="return confirm('Are you sure you want to hard delete this organization?')">
<button class="btn btn-outline-danger" type="submit">Delete</button>

View File

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

View File

@ -17,6 +17,10 @@
<h2>Provider Information</h2>
@await Html.PartialAsync("_ViewInformation", Model)
@if (Model.ProviderPlanViewModels.Any())
{
@await Html.PartialAsync("~/Billing/Views/Providers/ProviderPlans.cshtml", Model.ProviderPlanViewModels)
}
@await Html.PartialAsync("Admins", Model)
<form method="post" id="edit-form">
<div asp-validation-summary="All" class="alert alert-danger"></div>

View File

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

View File

@ -12,6 +12,7 @@
var canViewBilling = AccessControlService.UserHasPermission(Permission.Org_Billing_View);
var canViewPlan = AccessControlService.UserHasPermission(Permission.Org_Plan_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 canEditPlan = AccessControlService.UserHasPermission(Permission.Org_Plan_Edit);
var canEditLicensing = AccessControlService.UserHasPermission(Permission.Org_Licensing_Edit);
@ -28,7 +29,7 @@
<div class="col-sm">
<div class="mb-3">
<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>

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

@ -3,7 +3,6 @@ using System.Text.Json;
using Bit.Admin.Enums;
using Bit.Admin.Models;
using Bit.Admin.Utilities;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Entities;
@ -16,7 +15,6 @@ using Bit.Core.Settings;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using TaxRate = Bit.Core.Entities.TaxRate;
namespace Bit.Admin.Controllers;
@ -33,7 +31,6 @@ public class ToolsController : Controller
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IProviderUserRepository _providerUserRepository;
private readonly IPaymentService _paymentService;
private readonly ITaxRateRepository _taxRateRepository;
private readonly IStripeAdapter _stripeAdapter;
private readonly IWebHostEnvironment _environment;
@ -46,7 +43,6 @@ public class ToolsController : Controller
IInstallationRepository installationRepository,
IOrganizationUserRepository organizationUserRepository,
IProviderUserRepository providerUserRepository,
ITaxRateRepository taxRateRepository,
IPaymentService paymentService,
IStripeAdapter stripeAdapter,
IWebHostEnvironment environment)
@ -59,7 +55,6 @@ public class ToolsController : Controller
_installationRepository = installationRepository;
_organizationUserRepository = organizationUserRepository;
_providerUserRepository = providerUserRepository;
_taxRateRepository = taxRateRepository;
_paymentService = paymentService;
_stripeAdapter = stripeAdapter;
_environment = environment;
@ -226,7 +221,6 @@ public class ToolsController : Controller
return RedirectToAction("Edit", "Organizations", new { id = model.OrganizationId.Value });
}
[RequireFeature(FeatureFlagKeys.PromoteProviderServiceUserTool)]
[RequirePermission(Permission.Tools_PromoteProviderServiceUser)]
public IActionResult PromoteProviderServiceUser()
{
@ -235,7 +229,6 @@ public class ToolsController : Controller
[HttpPost]
[ValidateAntiForgeryToken]
[RequireFeature(FeatureFlagKeys.PromoteProviderServiceUserTool)]
[RequirePermission(Permission.Tools_PromoteProviderServiceUser)]
public async Task<IActionResult> PromoteProviderServiceUser(PromoteProviderServiceUserModel model)
{
@ -346,165 +339,6 @@ public class ToolsController : Controller
}
}
[RequirePermission(Permission.Tools_ManageTaxRates)]
public async Task<IActionResult> TaxRate(int page = 1, int count = 25)
{
if (page < 1)
{
page = 1;
}
if (count < 1)
{
count = 1;
}
var skip = (page - 1) * count;
var rates = await _taxRateRepository.SearchAsync(skip, count);
return View(new TaxRatesModel
{
Items = rates.ToList(),
Page = page,
Count = count
});
}
[RequirePermission(Permission.Tools_ManageTaxRates)]
public async Task<IActionResult> TaxRateAddEdit(string stripeTaxRateId = null)
{
if (string.IsNullOrWhiteSpace(stripeTaxRateId))
{
return View(new TaxRateAddEditModel());
}
var rate = await _taxRateRepository.GetByIdAsync(stripeTaxRateId);
var model = new TaxRateAddEditModel()
{
StripeTaxRateId = stripeTaxRateId,
Country = rate.Country,
State = rate.State,
PostalCode = rate.PostalCode,
Rate = rate.Rate
};
return View(model);
}
[ValidateAntiForgeryToken]
[RequirePermission(Permission.Tools_ManageTaxRates)]
public async Task<IActionResult> TaxRateUpload(IFormFile file)
{
if (file == null || file.Length == 0)
{
throw new ArgumentNullException(nameof(file));
}
// Build rates and validate them first before updating DB & Stripe
var taxRateUpdates = new List<TaxRate>();
var currentTaxRates = await _taxRateRepository.GetAllActiveAsync();
using var reader = new StreamReader(file.OpenReadStream());
while (!reader.EndOfStream)
{
var line = await reader.ReadLineAsync();
if (string.IsNullOrWhiteSpace(line))
{
continue;
}
var taxParts = line.Split(',');
if (taxParts.Length < 2)
{
throw new Exception($"This line is not in the format of <postal code>,<rate>,<state code>,<country code>: {line}");
}
var postalCode = taxParts[0].Trim();
if (string.IsNullOrWhiteSpace(postalCode))
{
throw new Exception($"'{line}' is not valid, the first element must contain a postal code.");
}
if (!decimal.TryParse(taxParts[1], out var rate) || rate <= 0M || rate > 100)
{
throw new Exception($"{taxParts[1]} is not a valid rate/decimal for {postalCode}");
}
var state = taxParts.Length > 2 ? taxParts[2] : null;
var country = (taxParts.Length > 3 ? taxParts[3] : null);
if (string.IsNullOrWhiteSpace(country))
{
country = "US";
}
var taxRate = currentTaxRates.FirstOrDefault(r => r.Country == country && r.PostalCode == postalCode) ??
new TaxRate
{
Country = country,
PostalCode = postalCode,
Active = true,
};
taxRate.Rate = rate;
taxRate.State = state ?? taxRate.State;
taxRateUpdates.Add(taxRate);
}
foreach (var taxRate in taxRateUpdates)
{
if (!string.IsNullOrWhiteSpace(taxRate.Id))
{
await _paymentService.UpdateTaxRateAsync(taxRate);
}
else
{
await _paymentService.CreateTaxRateAsync(taxRate);
}
}
return RedirectToAction("TaxRate");
}
[HttpPost]
[ValidateAntiForgeryToken]
[RequirePermission(Permission.Tools_ManageTaxRates)]
public async Task<IActionResult> TaxRateAddEdit(TaxRateAddEditModel model)
{
var existingRateCheck = await _taxRateRepository.GetByLocationAsync(new TaxRate() { Country = model.Country, PostalCode = model.PostalCode });
if (existingRateCheck.Any())
{
ModelState.AddModelError(nameof(model.PostalCode), "A tax rate already exists for this Country/Postal Code combination.");
}
if (!ModelState.IsValid)
{
return View(model);
}
var taxRate = new TaxRate()
{
Id = model.StripeTaxRateId,
Country = model.Country,
State = model.State,
PostalCode = model.PostalCode,
Rate = model.Rate
};
if (!string.IsNullOrWhiteSpace(model.StripeTaxRateId))
{
await _paymentService.UpdateTaxRateAsync(taxRate);
}
else
{
await _paymentService.CreateTaxRateAsync(taxRate);
}
return RedirectToAction("TaxRate");
}
[RequirePermission(Permission.Tools_ManageTaxRates)]
public async Task<IActionResult> TaxRateArchive(string stripeTaxRateId)
{
if (!string.IsNullOrWhiteSpace(stripeTaxRateId))
{
await _paymentService.ArchiveTaxRateAsync(new TaxRate() { Id = stripeTaxRateId });
}
return RedirectToAction("TaxRate");
}
[RequirePermission(Permission.Tools_ManageStripeSubscriptions)]
public async Task<IActionResult> StripeSubscriptions(StripeSubscriptionListOptions options)
{

View File

@ -165,7 +165,7 @@ public class UsersController : Controller
[HttpPost]
[ValidateAntiForgeryToken]
[RequirePermission(Permission.User_GeneralDetails_View)]
[RequirePermission(Permission.User_NewDeviceException_Edit)]
[RequireFeature(FeatureFlagKeys.NewDeviceVerification)]
public async Task<IActionResult> ToggleNewDeviceVerification(Guid id)
{

View File

@ -17,13 +17,16 @@ public enum Permission
User_Billing_View,
User_Billing_Edit,
User_Billing_LaunchGateway,
User_NewDeviceException_Edit,
Org_List_View,
Org_OrgInformation_View,
Org_GeneralDetails_View,
Org_Name_Edit,
Org_CheckEnabledBox,
Org_BusinessInformation_View,
Org_InitiateTrial,
Org_RequestDelete,
Org_Delete,
Org_BillingInformation_View,
Org_BillingInformation_DownloadInvoice,

View File

@ -1,10 +0,0 @@
namespace Bit.Admin.Models;
public class TaxRateAddEditModel
{
public string StripeTaxRateId { get; set; }
public string Country { get; set; }
public string State { get; set; }
public string PostalCode { get; set; }
public decimal Rate { get; set; }
}

View File

@ -1,8 +0,0 @@
using Bit.Core.Entities;
namespace Bit.Admin.Models;
public class TaxRatesModel : PagedModel<TaxRate>
{
public string Message { get; set; }
}

View File

@ -12,7 +12,6 @@ public static class RolePermissionMapping
Permission.User_List_View,
Permission.User_UserInformation_View,
Permission.User_GeneralDetails_View,
Permission.Org_CheckEnabledBox,
Permission.User_Delete,
Permission.User_UpgradePremium,
Permission.User_BillingInformation_View,
@ -24,12 +23,16 @@ public static class RolePermissionMapping
Permission.User_Billing_View,
Permission.User_Billing_Edit,
Permission.User_Billing_LaunchGateway,
Permission.User_NewDeviceException_Edit,
Permission.Org_Name_Edit,
Permission.Org_CheckEnabledBox,
Permission.Org_List_View,
Permission.Org_OrgInformation_View,
Permission.Org_GeneralDetails_View,
Permission.Org_BusinessInformation_View,
Permission.Org_InitiateTrial,
Permission.Org_Delete,
Permission.Org_RequestDelete,
Permission.Org_BillingInformation_View,
Permission.Org_BillingInformation_DownloadInvoice,
Permission.Org_Plan_View,
@ -56,7 +59,6 @@ public static class RolePermissionMapping
Permission.User_List_View,
Permission.User_UserInformation_View,
Permission.User_GeneralDetails_View,
Permission.Org_CheckEnabledBox,
Permission.User_Delete,
Permission.User_UpgradePremium,
Permission.User_BillingInformation_View,
@ -69,11 +71,15 @@ public static class RolePermissionMapping
Permission.User_Billing_View,
Permission.User_Billing_Edit,
Permission.User_Billing_LaunchGateway,
Permission.User_NewDeviceException_Edit,
Permission.Org_Name_Edit,
Permission.Org_CheckEnabledBox,
Permission.Org_List_View,
Permission.Org_OrgInformation_View,
Permission.Org_GeneralDetails_View,
Permission.Org_BusinessInformation_View,
Permission.Org_Delete,
Permission.Org_RequestDelete,
Permission.Org_BillingInformation_View,
Permission.Org_BillingInformation_DownloadInvoice,
Permission.Org_BillingInformation_CreateEditTransaction,
@ -104,7 +110,6 @@ public static class RolePermissionMapping
Permission.User_List_View,
Permission.User_UserInformation_View,
Permission.User_GeneralDetails_View,
Permission.Org_CheckEnabledBox,
Permission.User_UpgradePremium,
Permission.User_BillingInformation_View,
Permission.User_BillingInformation_DownloadInvoice,
@ -112,9 +117,11 @@ public static class RolePermissionMapping
Permission.User_Licensing_View,
Permission.User_Billing_View,
Permission.User_Billing_LaunchGateway,
Permission.User_NewDeviceException_Edit,
Permission.Org_Name_Edit,
Permission.Org_CheckEnabledBox,
Permission.Org_List_View,
Permission.Org_OrgInformation_View,
Permission.Org_Delete,
Permission.Org_GeneralDetails_View,
Permission.Org_BusinessInformation_View,
Permission.Org_BillingInformation_View,
@ -124,6 +131,7 @@ public static class RolePermissionMapping
Permission.Org_Licensing_View,
Permission.Org_Billing_View,
Permission.Org_Billing_LaunchGateway,
Permission.Org_RequestDelete,
Permission.Provider_List_View,
Permission.Provider_View
}
@ -133,7 +141,6 @@ public static class RolePermissionMapping
Permission.User_List_View,
Permission.User_UserInformation_View,
Permission.User_GeneralDetails_View,
Permission.Org_CheckEnabledBox,
Permission.User_UpgradePremium,
Permission.User_BillingInformation_View,
Permission.User_BillingInformation_DownloadInvoice,
@ -144,6 +151,8 @@ public static class RolePermissionMapping
Permission.User_Billing_View,
Permission.User_Billing_Edit,
Permission.User_Billing_LaunchGateway,
Permission.Org_Name_Edit,
Permission.Org_CheckEnabledBox,
Permission.Org_List_View,
Permission.Org_OrgInformation_View,
Permission.Org_GeneralDetails_View,
@ -157,7 +166,7 @@ public static class RolePermissionMapping
Permission.Org_Billing_View,
Permission.Org_Billing_Edit,
Permission.Org_Billing_LaunchGateway,
Permission.Org_Delete,
Permission.Org_RequestDelete,
Permission.Provider_Edit,
Permission.Provider_View,
Permission.Provider_List_View,
@ -175,12 +184,13 @@ public static class RolePermissionMapping
Permission.User_List_View,
Permission.User_UserInformation_View,
Permission.User_GeneralDetails_View,
Permission.Org_CheckEnabledBox,
Permission.User_BillingInformation_View,
Permission.User_BillingInformation_DownloadInvoice,
Permission.User_Premium_View,
Permission.User_Licensing_View,
Permission.User_Licensing_Edit,
Permission.Org_Name_Edit,
Permission.Org_CheckEnabledBox,
Permission.Org_List_View,
Permission.Org_OrgInformation_View,
Permission.Org_GeneralDetails_View,

View File

@ -1,10 +1,8 @@
@using Bit.Admin.Enums;
@using Bit.Core
@inject SignInManager<IdentityUser> SignInManager
@inject Bit.Core.Settings.GlobalSettings GlobalSettings
@inject Bit.Admin.Services.IAccessControlService AccessControlService
@inject Bit.Core.Services.IFeatureService FeatureService
@{
var canViewUsers = AccessControlService.UserHasPermission(Permission.User_List_View);
@ -13,16 +11,14 @@
var canChargeBraintree = AccessControlService.UserHasPermission(Permission.Tools_ChargeBrainTreeCustomer);
var canCreateTransaction = AccessControlService.UserHasPermission(Permission.Tools_CreateEditTransaction);
var canPromoteAdmin = AccessControlService.UserHasPermission(Permission.Tools_PromoteAdmin);
var canPromoteProviderServiceUser = FeatureService.IsEnabled(FeatureFlagKeys.PromoteProviderServiceUserTool) &&
AccessControlService.UserHasPermission(Permission.Tools_PromoteProviderServiceUser);
var canPromoteProviderServiceUser = AccessControlService.UserHasPermission(Permission.Tools_PromoteProviderServiceUser);
var canGenerateLicense = AccessControlService.UserHasPermission(Permission.Tools_GenerateLicenseFile);
var canManageTaxRates = AccessControlService.UserHasPermission(Permission.Tools_ManageTaxRates);
var canManageStripeSubscriptions = AccessControlService.UserHasPermission(Permission.Tools_ManageStripeSubscriptions);
var canProcessStripeEvents = AccessControlService.UserHasPermission(Permission.Tools_ProcessStripeEvents);
var canMigrateProviders = AccessControlService.UserHasPermission(Permission.Tools_MigrateProviders);
var canViewTools = canChargeBraintree || canCreateTransaction || canPromoteAdmin || canPromoteProviderServiceUser ||
canGenerateLicense || canManageTaxRates || canManageStripeSubscriptions;
canGenerateLicense || canManageStripeSubscriptions;
}
<!DOCTYPE html>
@ -107,12 +103,6 @@
Generate License
</a>
}
@if (canManageTaxRates)
{
<a class="dropdown-item" asp-controller="Tools" asp-action="TaxRate">
Manage Tax Rates
</a>
}
@if (canManageStripeSubscriptions)
{
<a class="dropdown-item" asp-controller="Tools" asp-action="StripeSubscriptions">

View File

@ -1,127 +0,0 @@
@model TaxRatesModel
@{
ViewData["Title"] = "Tax Rates";
}
<h1>Manage Tax Rates</h1>
<h2>Bulk Upload Tax Rates</h2>
<section>
<p>
Upload a CSV file containing multiple tax rates in bulk in order to update existing rates by country
and postal code OR to create new rates where a currently active rate is not found already.
</p>
<p>CSV Upload Format</p>
<ul>
<li><b>Postal Code</b> (required) - The postal code for the tax rate.</li>
<li><b>Rate</b> (required) - The effective tax rate for this postal code.</li>
<li><b>State</b> (<i>optional</i>) - The ISO-2 character code for the state. Optional but recommended.</li>
<li><b>Country</b> (<i>optional</i>) - The ISO-2 character country code, defaults to "US" if not provided.</li>
</ul>
<p>Example (white-space is ignored):</p>
<div class="card mb-2">
<div class="card-body">
<pre class="mb-0">87654,8.25,FL,US
22334,8.5,CA
11223,7</pre>
</div>
</div>
<form method="post" enctype="multipart/form-data" asp-action="TaxRateUpload">
<div class="mb-3">
<input type="file" class="form-control" name="file" />
</div>
<div class="mb-3">
<input type="submit" value="Upload" class="btn btn-primary" />
</div>
</form>
</section>
<hr class="my-4">
<h2>View &amp; Manage Tax Rates</h2>
<a class="btn btn-primary mb-3" asp-controller="Tools" asp-action="TaxRateAddEdit">Add a Rate</a>
<div class="table-responsive">
<table class="table table-striped table-hover align-middle">
<thead>
<tr>
<th style="width: 190px;">Id</th>
<th style="width: 80px;">Country</th>
<th style="width: 80px;">State</th>
<th style="width: 150px;">Postal Code</th>
<th style="width: 160px;">Tax Rate</th>
<th style="width: 80px;"></th>
</tr>
</thead>
<tbody>
@if(!Model.Items.Any())
{
<tr>
<td colspan="6">No results to list.</td>
</tr>
}
else
{
@foreach(var rate in Model.Items)
{
<tr>
<td>
@{
var taxRateToEdit = new Dictionary<string, string>
{
{ "id", rate.Id },
{ "stripeTaxRateId", rate.Id }
};
}
<a asp-controller="Tools" asp-action="TaxRateAddEdit" asp-all-route-data="taxRateToEdit">@rate.Id</a>
</td>
<td>
@rate.Country
</td>
<td>
@rate.State
</td>
<td>
@rate.PostalCode
</td>
<td>
@rate.Rate%
</td>
<td>
<a class="delete-button" data-id="@rate.Id" asp-controller="Tools" asp-action="TaxRateArchive" asp-route-stripeTaxRateId="@rate.Id">
<i class="fa fa-trash fa-lg fa-fw"></i>
</a>
</td>
</tr>
}
}
</tbody>
</table>
</div>
<nav aria-label="Tax rates pagination">
<ul class="pagination">
@if(Model.PreviousPage.HasValue)
{
<li class="page-item">
<a class="page-link" asp-controller="Tools" asp-action="TaxRate" asp-route-page="@Model.PreviousPage.Value" asp-route-count="@Model.Count">Previous</a>
</li>
}
else
{
<li class="page-item disabled">
<a class="page-link" href="#" tabindex="-1">Previous</a>
</li>
}
@if(Model.NextPage.HasValue)
{
<li class="page-item">
<a class="page-link" asp-controller="Tools" asp-action="TaxRate" asp-route-page="@Model.NextPage.Value" asp-route-count="@Model.Count">Next</a>
</li>
}
else
{
<li class="page-item disabled">
<a class="page-link" href="#" tabindex="-1">Next</a>
</li>
}
</ul>
</nav>

View File

@ -1,356 +0,0 @@
@model TaxRateAddEditModel
@{
ViewData["Title"] = "Add/Edit Tax Rate";
}
<h1>@(string.IsNullOrWhiteSpace(Model.StripeTaxRateId) ? "Create" : "Edit") Tax Rate</h1>
@if (!string.IsNullOrWhiteSpace(Model.StripeTaxRateId))
{
<p>Note: Updating a Tax Rate archives the currently selected rate and creates a new rate with a new ID. The previous data still exists in a disabled state.</p>
}
<form method="post">
<div asp-validation-summary="All" class="alert alert-danger"></div>
<input type="hidden" asp-for="StripeTaxRateId">
<div class="row">
<div class="col-md">
<div class="form-group">
<label asp-for="Country"></label>
<select asp-for="Country" class="form-control" required>
<option value="">-- Select --</option>
<option value="US">United States</option>
<option value="CN">China</option>
<option value="FR">France</option>
<option value="DE">Germany</option>
<option value="CA">Canada</option>
<option value="GB">United Kingdom</option>
<option value="AU">Australia</option>
<option value="IN">India</option>
<option value="-" disabled></option>
<option value="AF">Afghanistan</option>
<option value="AX">Åland Islands</option>
<option value="AL">Albania</option>
<option value="DZ">Algeria</option>
<option value="AS">American Samoa</option>
<option value="AD">Andorra</option>
<option value="AO">Angola</option>
<option value="AI">Anguilla</option>
<option value="AQ">Antarctica</option>
<option value="AG">Antigua and Barbuda</option>
<option value="AR">Argentina</option>
<option value="AM">Armenia</option>
<option value="AW">Aruba</option>
<option value="AT">Austria</option>
<option value="AZ">Azerbaijan</option>
<option value="BS">Bahamas</option>
<option value="BH">Bahrain</option>
<option value="BD">Bangladesh</option>
<option value="BB">Barbados</option>
<option value="BY">Belarus</option>
<option value="BE">Belgium</option>
<option value="BZ">Belize</option>
<option value="BJ">Benin</option>
<option value="BM">Bermuda</option>
<option value="BT">Bhutan</option>
<option value="BO">Bolivia, Plurinational State of</option>
<option value="BQ">Bonaire, Sint Eustatius and Saba</option>
<option value="BA">Bosnia and Herzegovina</option>
<option value="BW">Botswana</option>
<option value="BV">Bouvet Island</option>
<option value="BR">Brazil</option>
<option value="IO">British Indian Ocean Territory</option>
<option value="BN">Brunei Darussalam</option>
<option value="BG">Bulgaria</option>
<option value="BF">Burkina Faso</option>
<option value="BI">Burundi</option>
<option value="KH">Cambodia</option>
<option value="CM">Cameroon</option>
<option value="CV">Cape Verde</option>
<option value="KY">Cayman Islands</option>
<option value="CF">Central African Republic</option>
<option value="TD">Chad</option>
<option value="CL">Chile</option>
<option value="CX">Christmas Island</option>
<option value="CC">Cocos (Keeling) Islands</option>
<option value="CO">Colombia</option>
<option value="KM">Comoros</option>
<option value="CG">Congo</option>
<option value="CD">Congo, the Democratic Republic of the</option>
<option value="CK">Cook Islands</option>
<option value="CR">Costa Rica</option>
<option value="CI">Côte d'Ivoire</option>
<option value="HR">Croatia</option>
<option value="CU">Cuba</option>
<option value="CW">Curaçao</option>
<option value="CY">Cyprus</option>
<option value="CZ">Czech Republic</option>
<option value="DK">Denmark</option>
<option value="DJ">Djibouti</option>
<option value="DM">Dominica</option>
<option value="DO">Dominican Republic</option>
<option value="EC">Ecuador</option>
<option value="EG">Egypt</option>
<option value="SV">El Salvador</option>
<option value="GQ">Equatorial Guinea</option>
<option value="ER">Eritrea</option>
<option value="EE">Estonia</option>
<option value="ET">Ethiopia</option>
<option value="FK">Falkland Islands (Malvinas)</option>
<option value="FO">Faroe Islands</option>
<option value="FJ">Fiji</option>
<option value="FI">Finland</option>
<option value="GF">French Guiana</option>
<option value="PF">French Polynesia</option>
<option value="TF">French Southern Territories</option>
<option value="GA">Gabon</option>
<option value="GM">Gambia</option>
<option value="GE">Georgia</option>
<option value="GH">Ghana</option>
<option value="GI">Gibraltar</option>
<option value="GR">Greece</option>
<option value="GL">Greenland</option>
<option value="GD">Grenada</option>
<option value="GP">Guadeloupe</option>
<option value="GU">Guam</option>
<option value="GT">Guatemala</option>
<option value="GG">Guernsey</option>
<option value="GN">Guinea</option>
<option value="GW">Guinea-Bissau</option>
<option value="GY">Guyana</option>
<option value="HT">Haiti</option>
<option value="HM">Heard Island and McDonald Islands</option>
<option value="VA">Holy See (Vatican City State)</option>
<option value="HN">Honduras</option>
<option value="HK">Hong Kong</option>
<option value="HU">Hungary</option>
<option value="IS">Iceland</option>
<option value="ID">Indonesia</option>
<option value="IR">Iran, Islamic Republic of</option>
<option value="IQ">Iraq</option>
<option value="IE">Ireland</option>
<option value="IM">Isle of Man</option>
<option value="IL">Israel</option>
<option value="IT">Italy</option>
<option value="JM">Jamaica</option>
<option value="JP">Japan</option>
<option value="JE">Jersey</option>
<option value="JO">Jordan</option>
<option value="KZ">Kazakhstan</option>
<option value="KE">Kenya</option>
<option value="KI">Kiribati</option>
<option value="KP">Korea, Democratic People's Republic of</option>
<option value="KR">Korea, Republic of</option>
<option value="KW">Kuwait</option>
<option value="KG">Kyrgyzstan</option>
<option value="LA">Lao People's Democratic Republic</option>
<option value="LV">Latvia</option>
<option value="LB">Lebanon</option>
<option value="LS">Lesotho</option>
<option value="LR">Liberia</option>
<option value="LY">Libya</option>
<option value="LI">Liechtenstein</option>
<option value="LT">Lithuania</option>
<option value="LU">Luxembourg</option>
<option value="MO">Macao</option>
<option value="MK">Macedonia, the former Yugoslav Republic of</option>
<option value="MG">Madagascar</option>
<option value="MW">Malawi</option>
<option value="MY">Malaysia</option>
<option value="MV">Maldives</option>
<option value="ML">Mali</option>
<option value="MT">Malta</option>
<option value="MH">Marshall Islands</option>
<option value="MQ">Martinique</option>
<option value="MR">Mauritania</option>
<option value="MU">Mauritius</option>
<option value="YT">Mayotte</option>
<option value="MX">Mexico</option>
<option value="FM">Micronesia, Federated States of</option>
<option value="MD">Moldova, Republic of</option>
<option value="MC">Monaco</option>
<option value="MN">Mongolia</option>
<option value="ME">Montenegro</option>
<option value="MS">Montserrat</option>
<option value="MA">Morocco</option>
<option value="MZ">Mozambique</option>
<option value="MM">Myanmar</option>
<option value="NA">Namibia</option>
<option value="NR">Nauru</option>
<option value="NP">Nepal</option>
<option value="NL">Netherlands</option>
<option value="NC">New Caledonia</option>
<option value="NZ">New Zealand</option>
<option value="NI">Nicaragua</option>
<option value="NE">Niger</option>
<option value="NG">Nigeria</option>
<option value="NU">Niue</option>
<option value="NF">Norfolk Island</option>
<option value="MP">Northern Mariana Islands</option>
<option value="NO">Norway</option>
<option value="OM">Oman</option>
<option value="PK">Pakistan</option>
<option value="PW">Palau</option>
<option value="PS">Palestinian Territory, Occupied</option>
<option value="PA">Panama</option>
<option value="PG">Papua New Guinea</option>
<option value="PY">Paraguay</option>
<option value="PE">Peru</option>
<option value="PH">Philippines</option>
<option value="PN">Pitcairn</option>
<option value="PL">Poland</option>
<option value="PT">Portugal</option>
<option value="PR">Puerto Rico</option>
<option value="QA">Qatar</option>
<option value="RE">Réunion</option>
<option value="RO">Romania</option>
<option value="RU">Russian Federation</option>
<option value="RW">Rwanda</option>
<option value="BL">Saint Barthélemy</option>
<option value="SH">Saint Helena, Ascension and Tristan da Cunha</option>
<option value="KN">Saint Kitts and Nevis</option>
<option value="LC">Saint Lucia</option>
<option value="MF">Saint Martin (French part)</option>
<option value="PM">Saint Pierre and Miquelon</option>
<option value="VC">Saint Vincent and the Grenadines</option>
<option value="WS">Samoa</option>
<option value="SM">San Marino</option>
<option value="ST">Sao Tome and Principe</option>
<option value="SA">Saudi Arabia</option>
<option value="SN">Senegal</option>
<option value="RS">Serbia</option>
<option value="SC">Seychelles</option>
<option value="SL">Sierra Leone</option>
<option value="SG">Singapore</option>
<option value="SX">Sint Maarten (Dutch part)</option>
<option value="SK">Slovakia</option>
<option value="SI">Slovenia</option>
<option value="SB">Solomon Islands</option>
<option value="SO">Somalia</option>
<option value="ZA">South Africa</option>
<option value="GS">South Georgia and the South Sandwich Islands</option>
<option value="SS">South Sudan</option>
<option value="ES">Spain</option>
<option value="LK">Sri Lanka</option>
<option value="SD">Sudan</option>
<option value="SR">Suriname</option>
<option value="SJ">Svalbard and Jan Mayen</option>
<option value="SZ">Swaziland</option>
<option value="SE">Sweden</option>
<option value="CH">Switzerland</option>
<option value="SY">Syrian Arab Republic</option>
<option value="TW">Taiwan</option>
<option value="TJ">Tajikistan</option>
<option value="TZ">Tanzania, United Republic of</option>
<option value="TH">Thailand</option>
<option value="TL">Timor-Leste</option>
<option value="TG">Togo</option>
<option value="TK">Tokelau</option>
<option value="TO">Tonga</option>
<option value="TT">Trinidad and Tobago</option>
<option value="TN">Tunisia</option>
<option value="TR">Turkey</option>
<option value="TM">Turkmenistan</option>
<option value="TC">Turks and Caicos Islands</option>
<option value="TV">Tuvalu</option>
<option value="UG">Uganda</option>
<option value="UA">Ukraine</option>
<option value="AE">United Arab Emirates</option>
<option value="UM">United States Minor Outlying Islands</option>
<option value="UY">Uruguay</option>
<option value="UZ">Uzbekistan</option>
<option value="VU">Vanuatu</option>
<option value="VE">Venezuela, Bolivarian Republic of</option>
<option value="VN">Viet Nam</option>
<option value="VG">Virgin Islands, British</option>
<option value="VI">Virgin Islands, U.S.</option>
<option value="WF">Wallis and Futuna</option>
<option value="EH">Western Sahara</option>
<option value="YE">Yemen</option>
<option value="ZM">Zambia</option>
<option value="ZW">Zimbabwe</option>
</select>
</div>
</div>
<div class="col-md">
<div class="form-group">
<label asp-for="State"></label>
<select asp-for="State" class="form-control">
<option value="">-- Select --</option>
<option value="AL">Alabama</option>
<option value="AK">Alaska</option>
<option value="AZ">Arizona</option>
<option value="AR">Arkansas</option>
<option value="CA">California</option>
<option value="CO">Colorado</option>
<option value="CT">Connecticut</option>
<option value="DE">Delaware</option>
<option value="DC">District Of Columbia</option>
<option value="FL">Florida</option>
<option value="GA">Georgia</option>
<option value="HI">Hawaii</option>
<option value="ID">Idaho</option>
<option value="IL">Illinois</option>
<option value="IN">Indiana</option>
<option value="IA">Iowa</option>
<option value="KS">Kansas</option>
<option value="KY">Kentucky</option>
<option value="LA">Louisiana</option>
<option value="ME">Maine</option>
<option value="MD">Maryland</option>
<option value="MA">Massachusetts</option>
<option value="MI">Michigan</option>
<option value="MN">Minnesota</option>
<option value="MS">Mississippi</option>
<option value="MO">Missouri</option>
<option value="MT">Montana</option>
<option value="NE">Nebraska</option>
<option value="NV">Nevada</option>
<option value="NH">New Hampshire</option>
<option value="NJ">New Jersey</option>
<option value="NM">New Mexico</option>
<option value="NY">New York</option>
<option value="NC">North Carolina</option>
<option value="ND">North Dakota</option>
<option value="OH">Ohio</option>
<option value="OK">Oklahoma</option>
<option value="OR">Oregon</option>
<option value="PA">Pennsylvania</option>
<option value="RI">Rhode Island</option>
<option value="SC">South Carolina</option>
<option value="SD">South Dakota</option>
<option value="TN">Tennessee</option>
<option value="TX">Texas</option>
<option value="UT">Utah</option>
<option value="VT">Vermont</option>
<option value="VA">Virginia</option>
<option value="WA">Washington</option>
<option value="WV">West Virginia</option>
<option value="WI">Wisconsin</option>
<option value="WY">Wyoming</option>
</select>
</div>
</div>
</div>
<div class="row">
<div class="col-md">
<div class="form-group">
<label asp-for="PostalCode">Postal Code</label>
<input type="text" class="form-control" asp-for="PostalCode" required maxlength="10">
</div>
</div>
<div class="col-md">
<div class="form-group">
<label asp-for="Rate">Tax Rate</label>
<div class="input-group">
<input type="text" class="form-control" asp-for="Rate" pattern="^\d{0,3}.\d{0,3}$" required>
<div class="input-group-append">
<span class="input-group-text">%</span>
</div>
</div>
</div>
</div>
</div>
<button type="submit" class="btn btn-primary mb-2">@(string.IsNullOrWhiteSpace(Model.StripeTaxRateId) ? "Create" : "Save")</button>
</form>

View File

@ -1,13 +1,15 @@
@using Bit.Admin.Enums;
@inject Bit.Admin.Services.IAccessControlService AccessControlService
@inject Bit.Core.Services.IFeatureService FeatureService
@inject Bit.Core.Settings.GlobalSettings GlobalSettings
@inject IWebHostEnvironment HostingEnvironment
@model UserEditModel
@{
ViewData["Title"] = "User: " + Model.User.Email;
var canViewUserInformation = AccessControlService.UserHasPermission(Permission.User_UserInformation_View);
var canViewNewDeviceException = AccessControlService.UserHasPermission(Permission.User_UserInformation_View) &&
var canViewNewDeviceException = AccessControlService.UserHasPermission(Permission.User_NewDeviceException_Edit) &&
GlobalSettings.EnableNewDeviceVerification &&
FeatureService.IsEnabled(Bit.Core.FeatureFlagKeys.NewDeviceVerification);
var canViewBillingInformation = AccessControlService.UserHasPermission(Permission.User_BillingInformation_View);
var canViewGeneral = AccessControlService.UserHasPermission(Permission.User_GeneralDetails_View);
@ -35,11 +37,7 @@
// Premium
document.getElementById('@(nameof(Model.MaxStorageGb))').value = '1';
document.getElementById('@(nameof(Model.Premium))').checked = true;
using Stripe.Entitlements;
// Licensing
using Bit.Core;
using Stripe.Entitlements;
using Microsoft.Identity.Client.Extensibility;
document.getElementById('@(nameof(Model.LicenseKey))').value = '@Model.RandomLicenseKey';
document.getElementById('@(nameof(Model.PremiumExpirationDate))').value =
'@Model.OneYearExpirationDate';

View File

@ -311,10 +311,8 @@ public class OrganizationUsersController : Controller
throw new UnauthorizedAccessException();
}
var masterPasswordPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(orgId, PolicyType.ResetPassword);
var useMasterPasswordPolicy = masterPasswordPolicy != null &&
masterPasswordPolicy.Enabled &&
masterPasswordPolicy.GetDataModel<ResetPasswordDataModel>().AutoEnrollEnabled;
var useMasterPasswordPolicy = await ShouldHandleResetPasswordAsync(orgId);
if (useMasterPasswordPolicy && string.IsNullOrWhiteSpace(model.ResetPasswordKey))
{
throw new BadRequestException(string.Empty, "Master Password reset is required, but not provided.");
@ -328,6 +326,23 @@ public class OrganizationUsersController : Controller
}
}
private async Task<bool> ShouldHandleResetPasswordAsync(Guid orgId)
{
var organizationAbility = await _applicationCacheService.GetOrganizationAbilityAsync(orgId);
if (organizationAbility is not { UsePolicies: true })
{
return false;
}
var masterPasswordPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(orgId, PolicyType.ResetPassword);
var useMasterPasswordPolicy = masterPasswordPolicy != null &&
masterPasswordPolicy.Enabled &&
masterPasswordPolicy.GetDataModel<ResetPasswordDataModel>().AutoEnrollEnabled;
return useMasterPasswordPolicy;
}
[HttpPost("{id}/confirm")]
public async Task Confirm(string orgId, string id, [FromBody] OrganizationUserConfirmRequestModel model)
{

View File

@ -14,6 +14,7 @@ using Bit.Core.AdminConsole.Models.Business.Tokenables;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationApiKeys.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.Enums;
@ -58,6 +59,7 @@ public class OrganizationsController : Controller
private readonly IDataProtectorTokenFactory<OrgDeleteTokenable> _orgDeleteTokenDataFactory;
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
private readonly ICloudOrganizationSignUpCommand _cloudOrganizationSignUpCommand;
private readonly IOrganizationDeleteCommand _organizationDeleteCommand;
public OrganizationsController(
IOrganizationRepository organizationRepository,
@ -78,7 +80,8 @@ public class OrganizationsController : Controller
IProviderBillingService providerBillingService,
IDataProtectorTokenFactory<OrgDeleteTokenable> orgDeleteTokenDataFactory,
IRemoveOrganizationUserCommand removeOrganizationUserCommand,
ICloudOrganizationSignUpCommand cloudOrganizationSignUpCommand)
ICloudOrganizationSignUpCommand cloudOrganizationSignUpCommand,
IOrganizationDeleteCommand organizationDeleteCommand)
{
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
@ -99,6 +102,7 @@ public class OrganizationsController : Controller
_orgDeleteTokenDataFactory = orgDeleteTokenDataFactory;
_removeOrganizationUserCommand = removeOrganizationUserCommand;
_cloudOrganizationSignUpCommand = cloudOrganizationSignUpCommand;
_organizationDeleteCommand = organizationDeleteCommand;
}
[HttpGet("{id}")]
@ -303,7 +307,7 @@ public class OrganizationsController : Controller
}
}
await _organizationService.DeleteAsync(organization);
await _organizationDeleteCommand.DeleteAsync(organization);
}
[HttpPost("{id}/delete-recover-token")]
@ -333,7 +337,7 @@ public class OrganizationsController : Controller
}
}
await _organizationService.DeleteAsync(organization);
await _organizationDeleteCommand.DeleteAsync(organization);
}
[HttpPost("{id}/api-key")]

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.Services;
using Bit.Core.Billing.Services;
@ -7,13 +9,15 @@ using Bit.Core.Enums;
using Bit.Core.Models.Business;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.Billing.Controllers;
namespace Bit.Api.AdminConsole.Controllers;
[Route("providers/{providerId:guid}/clients")]
public class ProviderClientsController(
ICurrentContext currentContext,
IFeatureService featureService,
ILogger<BaseProviderController> logger,
IOrganizationRepository organizationRepository,
IProviderBillingService providerBillingService,
@ -22,7 +26,10 @@ public class ProviderClientsController(
IProviderService providerService,
IUserService userService) : BaseProviderController(currentContext, logger, providerRepository, userService)
{
private readonly ICurrentContext _currentContext = currentContext;
[HttpPost]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<IResult> CreateAsync(
[FromRoute] Guid providerId,
[FromBody] CreateClientOrganizationRequestBody requestBody)
@ -80,6 +87,7 @@ public class ProviderClientsController(
}
[HttpPut("{providerOrganizationId:guid}")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<IResult> UpdateAsync(
[FromRoute] Guid providerId,
[FromRoute] Guid providerOrganizationId,
@ -113,7 +121,7 @@ public class ProviderClientsController(
clientOrganization.PlanType,
seatAdjustment);
if (seatAdjustmentResultsInPurchase && !currentContext.ProviderProviderAdmin(provider.Id))
if (seatAdjustmentResultsInPurchase && !_currentContext.ProviderProviderAdmin(provider.Id))
{
return Error.Unauthorized("Service users cannot purchase additional seats.");
}
@ -127,4 +135,58 @@ public class ProviderClientsController(
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

@ -57,6 +57,7 @@ public class OrganizationResponseModel : ResponseModel
MaxAutoscaleSmServiceAccounts = organization.MaxAutoscaleSmServiceAccounts;
LimitCollectionCreation = organization.LimitCollectionCreation;
LimitCollectionDeletion = organization.LimitCollectionDeletion;
LimitItemDeletion = organization.LimitItemDeletion;
AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems;
UseRiskInsights = organization.UseRiskInsights;
}
@ -102,6 +103,7 @@ public class OrganizationResponseModel : ResponseModel
public int? MaxAutoscaleSmServiceAccounts { get; set; }
public bool LimitCollectionCreation { get; set; }
public bool LimitCollectionDeletion { get; set; }
public bool LimitItemDeletion { get; set; }
public bool AllowAdminAccessToAllCollectionItems { get; set; }
public bool UseRiskInsights { get; set; }
}

View File

@ -67,6 +67,7 @@ public class ProfileOrganizationResponseModel : ResponseModel
AccessSecretsManager = organization.AccessSecretsManager;
LimitCollectionCreation = organization.LimitCollectionCreation;
LimitCollectionDeletion = organization.LimitCollectionDeletion;
LimitItemDeletion = organization.LimitItemDeletion;
AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems;
UserIsManagedByOrganization = organizationIdsManagingUser.Contains(organization.OrganizationId);
UseRiskInsights = organization.UseRiskInsights;
@ -128,6 +129,7 @@ public class ProfileOrganizationResponseModel : ResponseModel
public bool AccessSecretsManager { get; set; }
public bool LimitCollectionCreation { get; set; }
public bool LimitCollectionDeletion { get; set; }
public bool LimitItemDeletion { get; set; }
public bool AllowAdminAccessToAllCollectionItems { get; set; }
/// <summary>
/// Indicates if the organization manages the user.

View File

@ -47,6 +47,7 @@ public class ProfileProviderOrganizationResponseModel : ProfileOrganizationRespo
ProductTierType = StaticStore.GetPlan(organization.PlanType).ProductTier;
LimitCollectionCreation = organization.LimitCollectionCreation;
LimitCollectionDeletion = organization.LimitCollectionDeletion;
LimitItemDeletion = organization.LimitItemDeletion;
AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems;
UseRiskInsights = organization.UseRiskInsights;
}

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.
/// </remarks>
[HttpGet]
[ProducesResponseType(typeof(ListResponseModel<EventResponseModel>), (int)HttpStatusCode.OK)]
[ProducesResponseType(typeof(PagedListResponseModel<EventResponseModel>), (int)HttpStatusCode.OK)]
public async Task<IActionResult> List([FromQuery] EventFilterRequestModel request)
{
var dateRange = request.ToDateRange();
@ -65,7 +65,7 @@ public class EventsController : Controller
}
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);
}
}

View File

@ -41,7 +41,7 @@ public class PoliciesController : Controller
/// </remarks>
/// <param name="type">The type of policy to be retrieved.</param>
[HttpGet("{type}")]
[ProducesResponseType(typeof(GroupResponseModel), (int)HttpStatusCode.OK)]
[ProducesResponseType(typeof(PolicyResponseModel), (int)HttpStatusCode.OK)]
[ProducesResponseType((int)HttpStatusCode.NotFound)]
public async Task<IActionResult> Get(PolicyType type)
{

View File

@ -50,6 +50,7 @@ public class MemberResponseModel : MemberBaseModel, IResponseModel
Status = user.Status;
Collections = collections?.Select(c => new AssociationWithPermissionsResponseModel(c));
ResetPasswordEnrolled = user.ResetPasswordKey != null;
SsoExternalId = user.SsoExternalId;
}
/// <summary>
@ -104,4 +105,10 @@ public class MemberResponseModel : MemberBaseModel, IResponseModel
/// </summary>
[Required]
public bool ResetPasswordEnrolled { get; }
/// <summary>
/// SSO external identifier for linking this member to an identity provider.
/// </summary>
/// <example>sso_external_id_123456</example>
public string SsoExternalId { get; set; }
}

View File

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

View File

@ -266,8 +266,18 @@ public class AccountsController : Controller
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(
model.ToUser(user),
user,
model.MasterPasswordHash,
model.Key,
model.OrgIdentifier);
@ -977,7 +987,6 @@ public class AccountsController : Controller
await _userService.ResendNewDeviceVerificationEmail(request.Email, request.Secret);
}
[RequireFeature(FeatureFlagKeys.NewDeviceVerification)]
[HttpPost("verify-devices")]
[HttpPut("verify-devices")]
public async Task SetUserVerifyDevicesAsync([FromBody] SetVerifyDevicesRequestModel request)

View File

@ -304,7 +304,7 @@ public class TwoFactorController : Controller
if (user != null)
{
// check if 2FA email is from passwordless
// Check if 2FA email is from Passwordless.
if (!string.IsNullOrEmpty(requestModel.AuthRequestAccessCode))
{
if (await _verifyAuthRequestCommand
@ -317,17 +317,14 @@ public class TwoFactorController : Controller
}
else if (!string.IsNullOrEmpty(requestModel.SsoEmail2FaSessionToken))
{
if (this.ValidateSsoEmail2FaToken(requestModel.SsoEmail2FaSessionToken, user))
if (ValidateSsoEmail2FaToken(requestModel.SsoEmail2FaSessionToken, user))
{
await _userService.SendTwoFactorEmailAsync(user);
return;
}
else
{
await this.ThrowDelayedBadRequestExceptionAsync(
"Cannot send two-factor email: a valid, non-expired SSO Email 2FA Session token is required to send 2FA emails.",
2000);
}
await ThrowDelayedBadRequestExceptionAsync(
"Cannot send two-factor email: a valid, non-expired SSO Email 2FA Session token is required to send 2FA emails.");
}
else if (await _userService.VerifySecretAsync(user, requestModel.Secret))
{
@ -336,8 +333,7 @@ public class TwoFactorController : Controller
}
}
await this.ThrowDelayedBadRequestExceptionAsync(
"Cannot send two-factor email.", 2000);
await ThrowDelayedBadRequestExceptionAsync("Cannot send two-factor email.");
}
[HttpPut("email")]
@ -374,7 +370,7 @@ public class TwoFactorController : Controller
public async Task<TwoFactorProviderResponseModel> PutOrganizationDisable(string id,
[FromBody] TwoFactorProviderRequestModel model)
{
var user = await CheckAsync(model, false);
await CheckAsync(model, false);
var orgIdGuid = new Guid(id);
if (!await _currentContext.ManagePolicies(orgIdGuid))
@ -401,6 +397,10 @@ public class TwoFactorController : Controller
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")]
[AllowAnonymous]
public async Task PostRecover([FromBody] TwoFactorRecoveryRequestModel model)
@ -463,10 +463,8 @@ public class TwoFactorController : Controller
await Task.Delay(2000);
throw new BadRequestException(name, $"{name} is invalid.");
}
else
{
await Task.Delay(500);
}
await Task.Delay(500);
}
private bool ValidateSsoEmail2FaToken(string ssoEmail2FaSessionToken, User user)

View File

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

View File

@ -2,7 +2,7 @@
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.Billing.Models.Requests;
using Bit.Api.Billing.Models.Responses;
using Bit.Core;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Models.Sales;
using Bit.Core.Billing.Services;
using Bit.Core.Context;
@ -18,7 +18,6 @@ namespace Bit.Api.Billing.Controllers;
[Authorize("Application")]
public class OrganizationBillingController(
ICurrentContext currentContext,
IFeatureService featureService,
IOrganizationBillingService organizationBillingService,
IOrganizationRepository organizationRepository,
IPaymentService paymentService,
@ -139,11 +138,6 @@ public class OrganizationBillingController(
[HttpGet("payment-method")]
public async Task<IResult> GetPaymentMethodAsync([FromRoute] Guid organizationId)
{
if (!featureService.IsEnabled(FeatureFlagKeys.AC2476_DeprecateStripeSourcesAPI))
{
return Error.NotFound();
}
if (!await currentContext.EditPaymentMethods(organizationId))
{
return Error.Unauthorized();
@ -168,11 +162,6 @@ public class OrganizationBillingController(
[FromRoute] Guid organizationId,
[FromBody] UpdatePaymentMethodRequestBody requestBody)
{
if (!featureService.IsEnabled(FeatureFlagKeys.AC2476_DeprecateStripeSourcesAPI))
{
return Error.NotFound();
}
if (!await currentContext.EditPaymentMethods(organizationId))
{
return Error.Unauthorized();
@ -199,11 +188,6 @@ public class OrganizationBillingController(
[FromRoute] Guid organizationId,
[FromBody] VerifyBankAccountRequestBody requestBody)
{
if (!featureService.IsEnabled(FeatureFlagKeys.AC2476_DeprecateStripeSourcesAPI))
{
return Error.NotFound();
}
if (!await currentContext.EditPaymentMethods(organizationId))
{
return Error.Unauthorized();
@ -229,11 +213,6 @@ public class OrganizationBillingController(
[HttpGet("tax-information")]
public async Task<IResult> GetTaxInformationAsync([FromRoute] Guid organizationId)
{
if (!featureService.IsEnabled(FeatureFlagKeys.AC2476_DeprecateStripeSourcesAPI))
{
return Error.NotFound();
}
if (!await currentContext.EditPaymentMethods(organizationId))
{
return Error.Unauthorized();
@ -258,11 +237,6 @@ public class OrganizationBillingController(
[FromRoute] Guid organizationId,
[FromBody] TaxInformationRequestBody requestBody)
{
if (!featureService.IsEnabled(FeatureFlagKeys.AC2476_DeprecateStripeSourcesAPI))
{
return Error.NotFound();
}
if (!await currentContext.EditPaymentMethods(organizationId))
{
return Error.Unauthorized();
@ -292,11 +266,6 @@ public class OrganizationBillingController(
throw new UnauthorizedAccessException();
}
if (!featureService.IsEnabled(FeatureFlagKeys.AC2476_DeprecateStripeSourcesAPI))
{
return Error.NotFound();
}
if (!await currentContext.EditPaymentMethods(organizationId))
{
return Error.Unauthorized();
@ -310,7 +279,18 @@ public class OrganizationBillingController(
}
var organizationSignup = model.ToOrganizationSignup(user);
var sale = OrganizationSale.From(organization, organizationSignup);
var plan = StaticStore.GetPlan(model.PlanType);
sale.Organization.PlanType = plan.Type;
sale.Organization.Plan = plan.Name;
sale.SubscriptionSetup.SkipTrial = true;
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();
}

View File

@ -1,6 +1,5 @@
using Bit.Api.Models.Request.Organizations;
using Bit.Api.Models.Response.Organizations;
using Bit.Core;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationConnections.Interfaces;
using Bit.Core.AdminConsole.Repositories;
@ -107,7 +106,7 @@ public class OrganizationSponsorshipsController : Controller
{
var isFreeFamilyPolicyEnabled = false;
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,
PolicyType.FreeFamiliesSponsorshipPolicy);

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,5 +1,4 @@
using Bit.Api.Models.Response;
using Bit.Core.Repositories;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@ -10,10 +9,6 @@ namespace Bit.Api.Controllers;
[Authorize("Web")]
public class PlansController : Controller
{
private readonly ITaxRateRepository _taxRateRepository;
public PlansController(ITaxRateRepository taxRateRepository) => _taxRateRepository = taxRateRepository;
[HttpGet("")]
[AllowAnonymous]
public ListResponseModel<PlanResponseModel> Get()
@ -21,12 +16,4 @@ public class PlansController : Controller
var responses = StaticStore.Plans.Select(plan => new PlanResponseModel(plan));
return new ListResponseModel<PlanResponseModel>(responses);
}
[HttpGet("sales-tax-rates")]
public async Task<ListResponseModel<TaxRateResponseModel>> GetTaxRates()
{
var data = await _taxRateRepository.GetAllActiveAsync();
var responses = data.Select(x => new TaxRateResponseModel(x));
return new ListResponseModel<TaxRateResponseModel>(responses);
}
}

View File

@ -4,10 +4,9 @@ namespace Bit.Api.Models.Public.Response;
public class ListResponseModel<T> : IResponseModel where T : IResponseModel
{
public ListResponseModel(IEnumerable<T> data, string continuationToken = null)
public ListResponseModel(IEnumerable<T> data)
{
Data = data;
ContinuationToken = continuationToken;
}
/// <summary>
@ -21,8 +20,4 @@ public class ListResponseModel<T> : IResponseModel where T : IResponseModel
/// </summary>
[Required]
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

@ -7,12 +7,14 @@ public class OrganizationCollectionManagementUpdateRequestModel
{
public bool LimitCollectionCreation { get; set; }
public bool LimitCollectionDeletion { get; set; }
public bool LimitItemDeletion { get; set; }
public bool AllowAdminAccessToAllCollectionItems { get; set; }
public virtual Organization ToOrganization(Organization existingOrganization, IFeatureService featureService)
{
existingOrganization.LimitCollectionCreation = LimitCollectionCreation;
existingOrganization.LimitCollectionDeletion = LimitCollectionDeletion;
existingOrganization.LimitItemDeletion = LimitItemDeletion;
existingOrganization.AllowAdminAccessToAllCollectionItems = AllowAdminAccessToAllCollectionItems;
return existingOrganization;
}

View File

@ -37,6 +37,7 @@ public class ProfileResponseModel : ResponseModel
UsesKeyConnector = user.UsesKeyConnector;
AvatarColor = user.AvatarColor;
CreationDate = user.CreationDate;
VerifyDevices = user.VerifyDevices;
Organizations = organizationsUserDetails?.Select(o => new ProfileOrganizationResponseModel(o, organizationIdsManagingUser));
Providers = providerUserDetails?.Select(p => new ProfileProviderResponseModel(p));
ProviderOrganizations =
@ -62,6 +63,7 @@ public class ProfileResponseModel : ResponseModel
public bool UsesKeyConnector { get; set; }
public string AvatarColor { get; set; }
public DateTime CreationDate { get; set; }
public bool VerifyDevices { get; set; }
public IEnumerable<ProfileOrganizationResponseModel> Organizations { get; set; }
public IEnumerable<ProfileProviderResponseModel> Providers { get; set; }
public IEnumerable<ProfileProviderOrganizationResponseModel> ProviderOrganizations { get; set; }

View File

@ -1,28 +0,0 @@
using Bit.Core.Entities;
using Bit.Core.Models.Api;
namespace Bit.Api.Models.Response;
public class TaxRateResponseModel : ResponseModel
{
public TaxRateResponseModel(TaxRate taxRate)
: base("profile")
{
if (taxRate == null)
{
throw new ArgumentNullException(nameof(taxRate));
}
Id = taxRate.Id;
Country = taxRate.Country;
State = taxRate.State;
PostalCode = taxRate.PostalCode;
Rate = taxRate.Rate;
}
public string Id { get; set; }
public string Country { get; set; }
public string State { get; set; }
public string PostalCode { get; set; }
public decimal Rate { get; set; }
}

View File

@ -43,7 +43,7 @@ public class PushController : Controller
{
CheckUsage();
await _pushRegistrationService.CreateOrUpdateRegistrationAsync(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));
}
[HttpPost("delete")]
@ -79,12 +79,12 @@ public class PushController : Controller
if (!string.IsNullOrWhiteSpace(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))
{
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);
}
}

View File

@ -29,6 +29,7 @@ using Bit.Core.Vault.Entities;
using Bit.Api.Auth.Models.Request.WebAuthn;
using Bit.Core.Auth.Models.Data;
using Bit.Core.Auth.Identity.TokenProviders;
using Bit.Core.Tools.ImportFeatures;
using Bit.Core.Tools.ReportFeatures;
@ -175,6 +176,7 @@ public class Startup
services.AddCoreLocalizationServices();
services.AddBillingOperations();
services.AddReportingServices();
services.AddImportServices();
// Authorization Handlers
services.AddAuthorizationHandlers();

View File

@ -7,7 +7,7 @@ using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Vault.Services;
using Bit.Core.Tools.ImportFeatures.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@ -17,31 +17,30 @@ namespace Bit.Api.Tools.Controllers;
[Authorize("Application")]
public class ImportCiphersController : Controller
{
private readonly ICipherService _cipherService;
private readonly IUserService _userService;
private readonly ICurrentContext _currentContext;
private readonly ILogger<ImportCiphersController> _logger;
private readonly GlobalSettings _globalSettings;
private readonly ICollectionRepository _collectionRepository;
private readonly IAuthorizationService _authorizationService;
private readonly IImportCiphersCommand _importCiphersCommand;
public ImportCiphersController(
ICipherService cipherService,
IUserService userService,
ICurrentContext currentContext,
ILogger<ImportCiphersController> logger,
GlobalSettings globalSettings,
ICollectionRepository collectionRepository,
IAuthorizationService authorizationService,
IOrganizationRepository organizationRepository)
IImportCiphersCommand importCiphersCommand)
{
_cipherService = cipherService;
_userService = userService;
_currentContext = currentContext;
_logger = logger;
_globalSettings = globalSettings;
_collectionRepository = collectionRepository;
_authorizationService = authorizationService;
_importCiphersCommand = importCiphersCommand;
}
[HttpPost("import")]
@ -57,7 +56,7 @@ public class ImportCiphersController : Controller
var userId = _userService.GetProperUserId(User).Value;
var folders = model.Folders.Select(f => f.ToFolder(userId)).ToList();
var ciphers = model.Ciphers.Select(c => c.ToCipherDetails(userId, false)).ToList();
await _cipherService.ImportCiphersAsync(folders, ciphers, model.FolderRelationships);
await _importCiphersCommand.ImportIntoIndividualVaultAsync(folders, ciphers, model.FolderRelationships);
}
[HttpPost("import-organization")]
@ -65,8 +64,9 @@ public class ImportCiphersController : Controller
[FromBody] ImportOrganizationCiphersRequestModel model)
{
if (!_globalSettings.SelfHosted &&
(model.Ciphers.Count() > 7000 || model.CollectionRelationships.Count() > 14000 ||
model.Collections.Count() > 2000))
(model.Ciphers.Count() > _globalSettings.ImportCiphersLimitation.CiphersLimit ||
model.CollectionRelationships.Count() > _globalSettings.ImportCiphersLimitation.CollectionRelationshipsLimit ||
model.Collections.Count() > _globalSettings.ImportCiphersLimitation.CollectionsLimit))
{
throw new BadRequestException("You cannot import this much data at once.");
}
@ -85,7 +85,7 @@ public class ImportCiphersController : Controller
var userId = _userService.GetProperUserId(User).Value;
var ciphers = model.Ciphers.Select(l => l.ToOrganizationCipherDetails(orgId)).ToList();
await _cipherService.ImportCiphersAsync(collections, ciphers, model.CollectionRelationships, userId);
await _importCiphersCommand.ImportIntoOrganizationalVaultAsync(collections, ciphers, model.CollectionRelationships, userId);
}
private async Task<bool> CheckOrgImportPermission(List<Collection> collections, Guid orgId)
@ -96,12 +96,6 @@ public class ImportCiphersController : Controller
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
//Permissions check will be done later on AuthorizationService
var orgCollectionIds =
@ -118,6 +112,12 @@ public class ImportCiphersController : Controller
return false;
};
//Users allowed to import if they CanCreate Collections
if (!(await _authorizationService.AuthorizeAsync(User, collections, BulkCollectionOperations.Create)).Succeeded)
{
return false;
}
return true;
}
}

View File

@ -1,16 +1,11 @@
using Bit.Api.Models.Response;
using Bit.Api.Tools.Authorization;
using Bit.Api.Tools.Authorization;
using Bit.Api.Tools.Models.Response;
using Bit.Api.Vault.Models.Response;
using Bit.Core;
using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Vault.Models.Data;
using Bit.Core.Vault.Queries;
using Bit.Core.Vault.Services;
using Microsoft.AspNetCore.Authorization;
@ -56,39 +51,6 @@ public class OrganizationExportController : Controller
[HttpGet("export")]
public async Task<IActionResult> Export(Guid organizationId)
{
if (_featureService.IsEnabled(FeatureFlagKeys.PM11360RemoveProviderExportPermission))
{
return await Export_vNext(organizationId);
}
var userId = _userService.GetProperUserId(User).Value;
IEnumerable<Collection> orgCollections = await _collectionService.GetOrganizationCollectionsAsync(organizationId);
(IEnumerable<CipherOrganizationDetails> orgCiphers, Dictionary<Guid, IGrouping<Guid, CollectionCipher>> collectionCiphersGroupDict) = await _cipherService.GetOrganizationCiphers(userId, organizationId);
if (_currentContext.ClientVersion == null || _currentContext.ClientVersion >= new Version("2023.1.0"))
{
var organizationExportResponseModel = new OrganizationExportResponseModel
{
Collections = orgCollections.Select(c => new CollectionResponseModel(c)),
Ciphers = orgCiphers.Select(c => new CipherMiniDetailsResponseModel(c, _globalSettings, collectionCiphersGroupDict, c.OrganizationUseTotp))
};
return Ok(organizationExportResponseModel);
}
// Backward compatibility with versions before 2023.1.0 that use ListResponseModel
var organizationExportListResponseModel = new OrganizationExportListResponseModel
{
Collections = GetOrganizationCollectionsResponse(orgCollections),
Ciphers = GetOrganizationCiphersResponse(orgCiphers, collectionCiphersGroupDict)
};
return Ok(organizationExportListResponseModel);
}
private async Task<IActionResult> Export_vNext(Guid organizationId)
{
var canExportAll = await _authorizationService.AuthorizeAsync(User, new OrganizationScope(organizationId),
VaultExportOperations.ExportWholeVault);
@ -116,19 +78,4 @@ public class OrganizationExportController : Controller
// Unauthorized
throw new NotFoundException();
}
private ListResponseModel<CollectionResponseModel> GetOrganizationCollectionsResponse(IEnumerable<Collection> orgCollections)
{
var collections = orgCollections.Select(c => new CollectionResponseModel(c));
return new ListResponseModel<CollectionResponseModel>(collections);
}
private ListResponseModel<CipherMiniDetailsResponseModel> GetOrganizationCiphersResponse(IEnumerable<CipherOrganizationDetails> orgCiphers,
Dictionary<Guid, IGrouping<Guid, CollectionCipher>> collectionCiphersGroupDict)
{
var responses = orgCiphers.Select(c => new CipherMiniDetailsResponseModel(c, _globalSettings,
collectionCiphersGroupDict, c.OrganizationUseTotp));
return new ListResponseModel<CipherMiniDetailsResponseModel>(responses);
}
}

View File

@ -9,7 +9,6 @@ using Bit.Core.Context;
using Bit.Core.Exceptions;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Tools.Entities;
using Bit.Core.Tools.Enums;
using Bit.Core.Tools.Models.Data;
using Bit.Core.Tools.Repositories;
@ -163,32 +162,6 @@ public class SendsController : Controller
return new SendResponseModel(send, _globalSettings);
}
[HttpPost("file")]
[Obsolete("Deprecated File Send API", false)]
[RequestSizeLimit(Constants.FileSize101mb)]
[DisableFormValueModelBinding]
public async Task<SendResponseModel> PostFile()
{
if (!Request?.ContentType.Contains("multipart/") ?? true)
{
throw new BadRequestException("Invalid content.");
}
Send send = null;
await Request.GetSendFileAsync(async (stream, fileName, model) =>
{
model.ValidateCreation();
var userId = _userService.GetProperUserId(User).Value;
var (madeSend, madeData) = model.ToSend(userId, fileName, _sendService);
send = madeSend;
await _sendService.SaveFileSendAsync(send, madeData, model.FileLength.GetValueOrDefault(0));
await _sendService.UploadFileToExistingSendAsync(stream, send);
});
return new SendResponseModel(send, _globalSettings);
}
[HttpPost("file/v2")]
public async Task<SendFileUploadDataResponseModel> PostFile([FromBody] SendRequestModel model)
{

View File

@ -424,6 +424,59 @@ public class CiphersController : Controller
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>
/// TODO: Move this to its own authorization handler or equivalent service - AC-2062
/// </summary>
@ -579,7 +632,7 @@ public class CiphersController : Controller
var userId = _userService.GetProperUserId(User).Value;
var cipher = await GetByIdAsync(id, userId);
if (cipher == null || !cipher.OrganizationId.HasValue ||
!await _currentContext.OrganizationUser(cipher.OrganizationId.Value))
!await _currentContext.OrganizationUser(cipher.OrganizationId.Value) || !cipher.ViewPassword)
{
throw new NotFoundException();
}
@ -634,7 +687,7 @@ public class CiphersController : Controller
[HttpPost("bulk-collections")]
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))
{
throw new NotFoundException();
@ -1097,7 +1150,7 @@ public class CiphersController : Controller
[HttpDelete("{id}/attachment/{attachmentId}")]
[HttpPost("{id}/attachment/{attachmentId}/delete")]
public async Task DeleteAttachment(Guid id, string attachmentId)
public async Task<DeleteAttachmentResponseData> DeleteAttachment(Guid id, string attachmentId)
{
var userId = _userService.GetProperUserId(User).Value;
var cipher = await GetByIdAsync(id, userId);
@ -1106,7 +1159,7 @@ public class CiphersController : Controller
throw new NotFoundException();
}
await _cipherService.DeleteAttachmentAsync(cipher, attachmentId, userId, false);
return await _cipherService.DeleteAttachmentAsync(cipher, attachmentId, userId, false);
}
[HttpDelete("{id}/attachment/{attachmentId}/admin")]

View File

@ -1,4 +1,5 @@
using Bit.Api.Models.Response;
using Bit.Api.Vault.Models.Request;
using Bit.Api.Vault.Models.Response;
using Bit.Core;
using Bit.Core.Services;
@ -20,17 +21,20 @@ public class SecurityTaskController : Controller
private readonly IGetTaskDetailsForUserQuery _getTaskDetailsForUserQuery;
private readonly IMarkTaskAsCompleteCommand _markTaskAsCompleteCommand;
private readonly IGetTasksForOrganizationQuery _getTasksForOrganizationQuery;
private readonly ICreateManyTasksCommand _createManyTasksCommand;
public SecurityTaskController(
IUserService userService,
IGetTaskDetailsForUserQuery getTaskDetailsForUserQuery,
IMarkTaskAsCompleteCommand markTaskAsCompleteCommand,
IGetTasksForOrganizationQuery getTasksForOrganizationQuery)
IGetTasksForOrganizationQuery getTasksForOrganizationQuery,
ICreateManyTasksCommand createManyTasksCommand)
{
_userService = userService;
_getTaskDetailsForUserQuery = getTaskDetailsForUserQuery;
_markTaskAsCompleteCommand = markTaskAsCompleteCommand;
_getTasksForOrganizationQuery = getTasksForOrganizationQuery;
_createManyTasksCommand = createManyTasksCommand;
}
/// <summary>
@ -71,4 +75,19 @@ public class SecurityTaskController : Controller
var response = securityTasks.Select(x => new SecurityTasksResponseModel(x)).ToList();
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);
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",
"privateKey": "SECRET"
},
"importCiphersLimitation": {
"ciphersLimit": 40000,
"collectionRelationshipsLimit": 80000,
"collectionsLimit": 2000
},
"bitPay": {
"production": false,
"token": "SECRET",

View File

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

View File

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

View File

@ -1,6 +1,8 @@
using System.ComponentModel.DataAnnotations;
using System.Net.Http.Headers;
using System.Reflection;
using System.Text;
using System.Text.Json;
using System.Web;
using Bit.Billing.Models;
using Bit.Core.Repositories;
@ -17,7 +19,6 @@ public class FreshdeskController : Controller
private readonly BillingSettings _billingSettings;
private readonly IUserRepository _userRepository;
private readonly IOrganizationRepository _organizationRepository;
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly ILogger<FreshdeskController> _logger;
private readonly GlobalSettings _globalSettings;
private readonly IHttpClientFactory _httpClientFactory;
@ -25,7 +26,6 @@ public class FreshdeskController : Controller
public FreshdeskController(
IUserRepository userRepository,
IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository,
IOptions<BillingSettings> billingSettings,
ILogger<FreshdeskController> logger,
GlobalSettings globalSettings,
@ -34,7 +34,6 @@ public class FreshdeskController : Controller
_billingSettings = billingSettings?.Value;
_userRepository = userRepository;
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
_logger = logger;
_globalSettings = globalSettings;
_httpClientFactory = httpClientFactory;
@ -145,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)
{
try
@ -169,6 +283,26 @@ public class FreshdeskController : Controller
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
{
return enumValue.GetType().GetMember(enumValue.ToString()).First().GetCustomAttribute<TAttribute>();

View File

@ -1,53 +0,0 @@
using Microsoft.AspNetCore.Mvc;
namespace Billing.Controllers;
public class LoginController : Controller
{
/*
private readonly PasswordlessSignInManager<IdentityUser> _signInManager;
public LoginController(
PasswordlessSignInManager<IdentityUser> signInManager)
{
_signInManager = signInManager;
}
public IActionResult Index()
{
return View();
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Index(LoginModel model)
{
if (ModelState.IsValid)
{
var result = await _signInManager.PasswordlessSignInAsync(model.Email,
Url.Action("Confirm", "Login", null, Request.Scheme));
if (result.Succeeded)
{
return RedirectToAction("Index", "Home");
}
else
{
ModelState.AddModelError(string.Empty, "Account not found.");
}
}
return View(model);
}
public async Task<IActionResult> Confirm(string email, string token)
{
var result = await _signInManager.PasswordlessSignInAsync(email, token, false);
if (!result.Succeeded)
{
return View("Error");
}
return RedirectToAction("Index", "Home");
}
*/
}

View File

@ -1,6 +1,7 @@
using System.Text;
using Bit.Billing.Models;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Services;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Repositories;
@ -23,6 +24,7 @@ public class PayPalController : Controller
private readonly ITransactionRepository _transactionRepository;
private readonly IUserRepository _userRepository;
private readonly IProviderRepository _providerRepository;
private readonly IPremiumUserBillingService _premiumUserBillingService;
public PayPalController(
IOptions<BillingSettings> billingSettings,
@ -32,7 +34,8 @@ public class PayPalController : Controller
IPaymentService paymentService,
ITransactionRepository transactionRepository,
IUserRepository userRepository,
IProviderRepository providerRepository)
IProviderRepository providerRepository,
IPremiumUserBillingService premiumUserBillingService)
{
_billingSettings = billingSettings?.Value;
_logger = logger;
@ -42,6 +45,7 @@ public class PayPalController : Controller
_transactionRepository = transactionRepository;
_userRepository = userRepository;
_providerRepository = providerRepository;
_premiumUserBillingService = premiumUserBillingService;
}
[HttpPost("ipn")]
@ -257,10 +261,9 @@ public class PayPalController : Controller
{
var user = await _userRepository.GetByIdAsync(transaction.UserId.Value);
if (await _paymentService.CreditAccountAsync(user, transaction.Amount))
if (user != null)
{
await _userRepository.ReplaceAsync(user);
await _premiumUserBillingService.Credit(user, transaction.Amount);
billingEmail = user.BillingEmailAddress();
}
}

View File

@ -32,5 +32,6 @@ public class JobsHostedService : BaseJobsHostedService
public static void AddJobsServices(IServiceCollection services)
{
services.AddTransient<AliveJob>();
services.AddTransient<SubscriptionCancellationJob>();
}
}

View File

@ -0,0 +1,58 @@
using Bit.Billing.Services;
using Bit.Core.Repositories;
using Quartz;
using Stripe;
namespace Bit.Billing.Jobs;
public class SubscriptionCancellationJob(
IStripeFacade stripeFacade,
IOrganizationRepository organizationRepository)
: IJob
{
public async Task Execute(IJobExecutionContext context)
{
var subscriptionId = context.MergedJobDataMap.GetString("subscriptionId");
var organizationId = new Guid(context.MergedJobDataMap.GetString("organizationId") ?? string.Empty);
var organization = await organizationRepository.GetByIdAsync(organizationId);
if (organization == null || organization.Enabled)
{
// Organization was deleted or re-enabled by CS, skip cancellation
return;
}
var subscription = await stripeFacade.GetSubscription(subscriptionId);
if (subscription?.Status != "unpaid")
{
// Subscription is no longer unpaid, skip cancellation
return;
}
// Cancel the subscription
await stripeFacade.CancelSubscription(subscriptionId, new SubscriptionCancelOptions());
// Void any open invoices
var options = new InvoiceListOptions
{
Status = "open",
Subscription = subscriptionId,
Limit = 100
};
var invoices = await stripeFacade.ListInvoices(options);
foreach (var invoice in invoices)
{
await stripeFacade.VoidInvoice(invoice.Id);
}
while (invoices.HasMore)
{
options.StartingAfter = invoices.Data.Last().Id;
invoices = await stripeFacade.ListInvoices(options);
foreach (var invoice in invoices)
{
await stripeFacade.VoidInvoice(invoice.Id);
}
}
}
}

View File

@ -0,0 +1,44 @@
namespace Bit.Billing.Models;
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
public class FreshdeskViewTicketModel
{
[JsonPropertyName("spam")]
public bool? Spam { get; set; }
[JsonPropertyName("priority")]
public int? Priority { get; set; }
[JsonPropertyName("source")]
public int? Source { get; set; }
[JsonPropertyName("status")]
public int? Status { get; set; }
[JsonPropertyName("subject")]
public string Subject { get; set; }
[JsonPropertyName("support_email")]
public string SupportEmail { get; set; }
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("description")]
public string Description { get; set; }
[JsonPropertyName("description_text")]
public string DescriptionText { get; set; }
[JsonPropertyName("created_at")]
public DateTime CreatedAt { get; set; }
[JsonPropertyName("updated_at")]
public DateTime UpdatedAt { get; set; }
[JsonPropertyName("tags")]
public List<string> Tags { get; set; }
}

View File

@ -0,0 +1,54 @@

using System.Text.Json.Serialization;
namespace Bit.Billing.Models;
public class OnyxAnswerWithCitationRequestModel
{
[JsonPropertyName("messages")]
public List<Message> Messages { get; set; }
[JsonPropertyName("persona_id")]
public int PersonaId { get; set; } = 1;
[JsonPropertyName("prompt_id")]
public int PromptId { get; set; } = 1;
[JsonPropertyName("retrieval_options")]
public RetrievalOptions RetrievalOptions { get; set; }
public OnyxAnswerWithCitationRequestModel(string message)
{
message = message.Replace(Environment.NewLine, " ").Replace('\r', ' ').Replace('\n', ' ');
Messages = new List<Message>() { new Message() { MessageText = message } };
RetrievalOptions = new RetrievalOptions();
}
}
public class Message
{
[JsonPropertyName("message")]
public string MessageText { get; set; }
[JsonPropertyName("sender")]
public string Sender { get; set; } = "user";
}
public class RetrievalOptions
{
[JsonPropertyName("run_search")]
public string RunSearch { get; set; } = RetrievalOptionsRunSearch.Auto;
[JsonPropertyName("real_time")]
public bool RealTime { get; set; } = true;
[JsonPropertyName("limit")]
public int? Limit { get; set; } = 3;
}
public class RetrievalOptionsRunSearch
{
public const string Always = "always";
public const string Never = "never";
public const string Auto = "auto";
}

View File

@ -0,0 +1,30 @@
using System.Text.Json.Serialization;
namespace Bit.Billing.Models;
public class OnyxAnswerWithCitationResponseModel
{
[JsonPropertyName("answer")]
public string Answer { get; set; }
[JsonPropertyName("rephrase")]
public string Rephrase { get; set; }
[JsonPropertyName("citations")]
public List<Citation> Citations { get; set; }
[JsonPropertyName("llm_selected_doc_indices")]
public List<int> LlmSelectedDocIndices { get; set; }
[JsonPropertyName("error_msg")]
public string ErrorMsg { get; set; }
}
public class Citation
{
[JsonPropertyName("citation_num")]
public int CitationNum { get; set; }
[JsonPropertyName("document_id")]
public string DocumentId { get; set; }
}

View File

@ -80,12 +80,6 @@ public interface IStripeFacade
RequestOptions requestOptions = null,
CancellationToken cancellationToken = default);
Task<TaxRate> GetTaxRate(
string taxRateId,
TaxRateGetOptions options = null,
RequestOptions requestOptions = null,
CancellationToken cancellationToken = default);
Task<Discount> DeleteCustomerDiscount(
string customerId,
RequestOptions requestOptions = null,

View File

@ -14,19 +14,22 @@ public class CustomerUpdatedHandler : ICustomerUpdatedHandler
private readonly ICurrentContext _currentContext;
private readonly IStripeEventService _stripeEventService;
private readonly IStripeEventUtilityService _stripeEventUtilityService;
private readonly ILogger<CustomerUpdatedHandler> _logger;
public CustomerUpdatedHandler(
IOrganizationRepository organizationRepository,
IReferenceEventService referenceEventService,
ICurrentContext currentContext,
IStripeEventService stripeEventService,
IStripeEventUtilityService stripeEventUtilityService)
IStripeEventUtilityService stripeEventUtilityService,
ILogger<CustomerUpdatedHandler> logger)
{
_organizationRepository = organizationRepository;
_organizationRepository = organizationRepository ?? throw new ArgumentNullException(nameof(organizationRepository));
_referenceEventService = referenceEventService;
_currentContext = currentContext;
_stripeEventService = stripeEventService;
_stripeEventUtilityService = stripeEventUtilityService;
_logger = logger;
}
/// <summary>
@ -35,25 +38,76 @@ public class CustomerUpdatedHandler : ICustomerUpdatedHandler
/// <param name="parsedEvent"></param>
public async Task HandleAsync(Event parsedEvent)
{
var customer = await _stripeEventService.GetCustomer(parsedEvent, true, ["subscriptions"]);
if (customer.Subscriptions == null || !customer.Subscriptions.Any())
if (parsedEvent == null)
{
_logger.LogError("Parsed event was null in CustomerUpdatedHandler");
throw new ArgumentNullException(nameof(parsedEvent));
}
if (_stripeEventService == null)
{
_logger.LogError("StripeEventService was not initialized in CustomerUpdatedHandler");
throw new InvalidOperationException($"{nameof(_stripeEventService)} is not initialized");
}
var customer = await _stripeEventService.GetCustomer(parsedEvent, true, ["subscriptions"]);
if (customer?.Subscriptions == null || !customer.Subscriptions.Any())
{
_logger.LogWarning("Customer or subscriptions were null or empty in CustomerUpdatedHandler. Customer ID: {CustomerId}", customer?.Id);
return;
}
var subscription = customer.Subscriptions.First();
if (subscription.Metadata == null)
{
_logger.LogWarning("Subscription metadata was null in CustomerUpdatedHandler. Subscription ID: {SubscriptionId}", subscription.Id);
return;
}
if (_stripeEventUtilityService == null)
{
_logger.LogError("StripeEventUtilityService was not initialized in CustomerUpdatedHandler");
throw new InvalidOperationException($"{nameof(_stripeEventUtilityService)} is not initialized");
}
var (organizationId, _, providerId) = _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata);
if (!organizationId.HasValue)
{
_logger.LogWarning("Organization ID was not found in subscription metadata. Subscription ID: {SubscriptionId}", subscription.Id);
return;
}
if (_organizationRepository == null)
{
_logger.LogError("OrganizationRepository was not initialized in CustomerUpdatedHandler");
throw new InvalidOperationException($"{nameof(_organizationRepository)} is not initialized");
}
var organization = await _organizationRepository.GetByIdAsync(organizationId.Value);
if (organization == null)
{
_logger.LogWarning("Organization not found. Organization ID: {OrganizationId}", organizationId.Value);
return;
}
organization.BillingEmail = customer.Email;
await _organizationRepository.ReplaceAsync(organization);
if (_referenceEventService == null)
{
_logger.LogError("ReferenceEventService was not initialized in CustomerUpdatedHandler");
throw new InvalidOperationException($"{nameof(_referenceEventService)} is not initialized");
}
if (_currentContext == null)
{
_logger.LogError("CurrentContext was not initialized in CustomerUpdatedHandler");
throw new InvalidOperationException($"{nameof(_currentContext)} is not initialized");
}
await _referenceEventService.RaiseEventAsync(
new ReferenceEvent(ReferenceEventType.OrganizationEditedInStripe, organization, _currentContext));
}

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