mirror of
https://github.com/bitwarden/server.git
synced 2025-05-29 07:14:50 -05:00
Merge branch 'main' into PM-16921
This commit is contained in:
commit
507b72caac
200
.github/renovate.json
vendored
200
.github/renovate.json
vendored
@ -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
199
.github/renovate.json5
vendored
Normal file
@ -0,0 +1,199 @@
|
||||
{
|
||||
$schema: "https://docs.renovatebot.com/renovate-schema.json",
|
||||
extends: ["github>bitwarden/renovate-config"], // Extends our default configuration for pinned dependencies
|
||||
enabledManagers: [
|
||||
"dockerfile",
|
||||
"docker-compose",
|
||||
"github-actions",
|
||||
"npm",
|
||||
"nuget",
|
||||
],
|
||||
packageRules: [
|
||||
{
|
||||
groupName: "dockerfile minor",
|
||||
matchManagers: ["dockerfile"],
|
||||
matchUpdateTypes: ["minor"],
|
||||
},
|
||||
{
|
||||
groupName: "docker-compose minor",
|
||||
matchManagers: ["docker-compose"],
|
||||
matchUpdateTypes: ["minor"],
|
||||
},
|
||||
{
|
||||
groupName: "github-action minor",
|
||||
matchManagers: ["github-actions"],
|
||||
matchUpdateTypes: ["minor"],
|
||||
},
|
||||
{
|
||||
matchManagers: ["dockerfile", "docker-compose"],
|
||||
commitMessagePrefix: "[deps] BRE:",
|
||||
},
|
||||
{
|
||||
matchPackageNames: ["DnsClient"],
|
||||
description: "Admin Console owned dependencies",
|
||||
commitMessagePrefix: "[deps] AC:",
|
||||
reviewers: ["team:team-admin-console-dev"],
|
||||
},
|
||||
{
|
||||
matchFileNames: ["src/Admin/package.json", "src/Sso/package.json"],
|
||||
description: "Admin & SSO npm packages",
|
||||
commitMessagePrefix: "[deps] Auth:",
|
||||
reviewers: ["team:team-auth-dev"],
|
||||
},
|
||||
{
|
||||
matchPackageNames: [
|
||||
"Azure.Extensions.AspNetCore.DataProtection.Blobs",
|
||||
"DuoUniversal",
|
||||
"Fido2.AspNet",
|
||||
"Duende.IdentityServer",
|
||||
"Microsoft.Extensions.Identity.Stores",
|
||||
"Otp.NET",
|
||||
"Sustainsys.Saml2.AspNetCore2",
|
||||
"YubicoDotNetClient",
|
||||
],
|
||||
description: "Auth owned dependencies",
|
||||
commitMessagePrefix: "[deps] Auth:",
|
||||
reviewers: ["team:team-auth-dev"],
|
||||
},
|
||||
{
|
||||
matchPackageNames: [
|
||||
"AutoFixture.AutoNSubstitute",
|
||||
"AutoFixture.Xunit2",
|
||||
"BenchmarkDotNet",
|
||||
"BitPay.Light",
|
||||
"Braintree",
|
||||
"coverlet.collector",
|
||||
"CsvHelper",
|
||||
"Kralizek.AutoFixture.Extensions.MockHttp",
|
||||
"Microsoft.AspNetCore.Mvc.Testing",
|
||||
"Microsoft.Extensions.Logging",
|
||||
"Microsoft.Extensions.Logging.Console",
|
||||
"Newtonsoft.Json",
|
||||
"NSubstitute",
|
||||
"Sentry.Serilog",
|
||||
"Serilog.AspNetCore",
|
||||
"Serilog.Extensions.Logging",
|
||||
"Serilog.Extensions.Logging.File",
|
||||
"Serilog.Sinks.AzureCosmosDB",
|
||||
"Serilog.Sinks.SyslogMessages",
|
||||
"Stripe.net",
|
||||
"Swashbuckle.AspNetCore",
|
||||
"Swashbuckle.AspNetCore.SwaggerGen",
|
||||
"xunit",
|
||||
"xunit.runner.visualstudio",
|
||||
],
|
||||
description: "Billing owned dependencies",
|
||||
commitMessagePrefix: "[deps] Billing:",
|
||||
reviewers: ["team:team-billing-dev"],
|
||||
},
|
||||
{
|
||||
matchPackagePatterns: ["^Microsoft.Extensions.Logging"],
|
||||
groupName: "Microsoft.Extensions.Logging",
|
||||
description: "Group Microsoft.Extensions.Logging to exclude them from the dotnet monorepo preset",
|
||||
},
|
||||
{
|
||||
matchPackageNames: [
|
||||
"Dapper",
|
||||
"dbup-sqlserver",
|
||||
"dotnet-ef",
|
||||
"linq2db.EntityFrameworkCore",
|
||||
"Microsoft.Azure.Cosmos",
|
||||
"Microsoft.Data.SqlClient",
|
||||
"Microsoft.EntityFrameworkCore.Design",
|
||||
"Microsoft.EntityFrameworkCore.InMemory",
|
||||
"Microsoft.EntityFrameworkCore.Relational",
|
||||
"Microsoft.EntityFrameworkCore.Sqlite",
|
||||
"Microsoft.EntityFrameworkCore.SqlServer",
|
||||
"Microsoft.Extensions.Caching.Cosmos",
|
||||
"Microsoft.Extensions.Caching.SqlServer",
|
||||
"Microsoft.Extensions.Caching.StackExchangeRedis",
|
||||
"Npgsql.EntityFrameworkCore.PostgreSQL",
|
||||
"Pomelo.EntityFrameworkCore.MySql",
|
||||
],
|
||||
description: "DbOps owned dependencies",
|
||||
commitMessagePrefix: "[deps] DbOps:",
|
||||
reviewers: ["team:dept-dbops"],
|
||||
},
|
||||
{
|
||||
matchPackageNames: ["CommandDotNet", "YamlDotNet"],
|
||||
description: "DevOps owned dependencies",
|
||||
commitMessagePrefix: "[deps] BRE:",
|
||||
reviewers: ["team:dept-bre"],
|
||||
},
|
||||
{
|
||||
matchPackageNames: [
|
||||
"AspNetCoreRateLimit",
|
||||
"AspNetCoreRateLimit.Redis",
|
||||
"Azure.Data.Tables",
|
||||
"Azure.Messaging.EventGrid",
|
||||
"Azure.Messaging.ServiceBus",
|
||||
"Azure.Storage.Blobs",
|
||||
"Azure.Storage.Queues",
|
||||
"Microsoft.AspNetCore.Authentication.JwtBearer",
|
||||
"Microsoft.AspNetCore.Http",
|
||||
"Quartz",
|
||||
],
|
||||
description: "Platform owned dependencies",
|
||||
commitMessagePrefix: "[deps] Platform:",
|
||||
reviewers: ["team:team-platform-dev"],
|
||||
},
|
||||
{
|
||||
matchPackagePatterns: ["EntityFrameworkCore", "^dotnet-ef"],
|
||||
groupName: "EntityFrameworkCore",
|
||||
description: "Group EntityFrameworkCore to exclude them from the dotnet monorepo preset",
|
||||
},
|
||||
{
|
||||
matchPackageNames: [
|
||||
"AutoMapper.Extensions.Microsoft.DependencyInjection",
|
||||
"AWSSDK.SimpleEmail",
|
||||
"AWSSDK.SQS",
|
||||
"Handlebars.Net",
|
||||
"LaunchDarkly.ServerSdk",
|
||||
"MailKit",
|
||||
"Microsoft.AspNetCore.SignalR.Protocols.MessagePack",
|
||||
"Microsoft.AspNetCore.SignalR.StackExchangeRedis",
|
||||
"Microsoft.Azure.NotificationHubs",
|
||||
"Microsoft.Extensions.Configuration.EnvironmentVariables",
|
||||
"Microsoft.Extensions.Configuration.UserSecrets",
|
||||
"Microsoft.Extensions.Configuration",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions",
|
||||
"Microsoft.Extensions.DependencyInjection",
|
||||
"SendGrid",
|
||||
],
|
||||
description: "Tools owned dependencies",
|
||||
commitMessagePrefix: "[deps] Tools:",
|
||||
reviewers: ["team:team-tools-dev"],
|
||||
},
|
||||
{
|
||||
matchPackagePatterns: ["^Microsoft.AspNetCore.SignalR"],
|
||||
groupName: "SignalR",
|
||||
description: "Group SignalR to exclude them from the dotnet monorepo preset",
|
||||
},
|
||||
{
|
||||
matchPackagePatterns: ["^Microsoft.Extensions.Configuration"],
|
||||
groupName: "Microsoft.Extensions.Configuration",
|
||||
description: "Group Microsoft.Extensions.Configuration to exclude them from the dotnet monorepo preset",
|
||||
},
|
||||
{
|
||||
matchPackagePatterns: ["^Microsoft.Extensions.DependencyInjection"],
|
||||
groupName: "Microsoft.Extensions.DependencyInjection",
|
||||
description: "Group Microsoft.Extensions.DependencyInjection to exclude them from the dotnet monorepo preset",
|
||||
},
|
||||
{
|
||||
matchPackageNames: [
|
||||
"AngleSharp",
|
||||
"AspNetCore.HealthChecks.AzureServiceBus",
|
||||
"AspNetCore.HealthChecks.AzureStorage",
|
||||
"AspNetCore.HealthChecks.Network",
|
||||
"AspNetCore.HealthChecks.Redis",
|
||||
"AspNetCore.HealthChecks.SendGrid",
|
||||
"AspNetCore.HealthChecks.SqlServer",
|
||||
"AspNetCore.HealthChecks.Uris",
|
||||
],
|
||||
description: "Vault owned dependencies",
|
||||
commitMessagePrefix: "[deps] Vault:",
|
||||
reviewers: ["team:team-vault-dev"],
|
||||
},
|
||||
],
|
||||
ignoreDeps: ["dotnet-sdk"],
|
||||
}
|
24
.github/workflows/build.yml
vendored
24
.github/workflows/build.yml
vendored
@ -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
|
||||
|
2
.github/workflows/code-references.yml
vendored
2
.github/workflows/code-references.yml
vendored
@ -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
|
||||
|
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@ -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,
|
||||
|
5
.github/workflows/scan.yml
vendored
5
.github/workflows/scan.yml
vendored
@ -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 }}"
|
||||
|
2
.github/workflows/stale-bot.yml
vendored
2
.github/workflows/stale-bot.yml
vendored
@ -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"
|
||||
|
6
.github/workflows/test-database.yml
vendored
6
.github/workflows/test-database.yml
vendored
@ -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: |
|
||||
|
3
.github/workflows/test.yml
vendored
3
.github/workflows/test.yml
vendored
@ -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
18
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"nick-rudenko.back-n-forth",
|
||||
"streetsidesoftware.code-spell-checker",
|
||||
"MS-vsliveshare.vsliveshare",
|
||||
|
||||
"mhutchie.git-graph",
|
||||
"donjayamanne.githistory",
|
||||
"eamodio.gitlens",
|
||||
|
||||
"jakebathman.mysql-syntax",
|
||||
"ckolkman.vscode-postgres",
|
||||
|
||||
"ms-dotnettools.csharp",
|
||||
"formulahendry.dotnet-test-explorer",
|
||||
"adrianwilczynski.user-secrets"
|
||||
]
|
||||
}
|
@ -3,11 +3,17 @@
|
||||
<PropertyGroup>
|
||||
<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>
|
||||
|
||||
<!--
|
||||
|
@ -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}
|
||||
|
@ -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()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -5,7 +5,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CsvHelper" Version="32.0.3" />
|
||||
<PackageReference Include="CsvHelper" Version="33.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
@ -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();
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
}
|
170
bitwarden_license/src/Scim/Groups/PatchGroupCommandvNext.cs
Normal file
170
bitwarden_license/src/Scim/Groups/PatchGroupCommandvNext.cs
Normal 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);
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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";
|
||||
}
|
||||
|
@ -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>();
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
|
||||
|
@ -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";
|
||||
|
@ -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)
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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:
|
||||
|
@ -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"
|
||||
|
@ -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>"
|
||||
|
38
dev/servicebusemulator_config.json
Normal file
38
dev/servicebusemulator_config.json
Normal file
@ -0,0 +1,38 @@
|
||||
{
|
||||
"UserConfig": {
|
||||
"Namespaces": [
|
||||
{
|
||||
"Name": "sbemulatorns",
|
||||
"Queues": [
|
||||
{
|
||||
"Name": "queue.1",
|
||||
"Properties": {
|
||||
"DeadLetteringOnMessageExpiration": false,
|
||||
"DefaultMessageTimeToLive": "PT1H",
|
||||
"DuplicateDetectionHistoryTimeWindow": "PT20S",
|
||||
"ForwardDeadLetteredMessagesTo": "",
|
||||
"ForwardTo": "",
|
||||
"LockDuration": "PT1M",
|
||||
"MaxDeliveryCount": 3,
|
||||
"RequiresDuplicateDetection": false,
|
||||
"RequiresSession": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"Topics": [
|
||||
{
|
||||
"Name": "event-logging",
|
||||
"Subscriptions": [
|
||||
{
|
||||
"Name": "events-write-subscription"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Logging": {
|
||||
"Type": "File"
|
||||
}
|
||||
}
|
||||
}
|
@ -16,7 +16,6 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Folder Include="Billing\Controllers\" />
|
||||
<Folder Include="Billing\Models\" />
|
||||
</ItemGroup>
|
||||
|
||||
<Choose>
|
||||
|
@ -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;
|
||||
|
@ -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)]
|
||||
|
@ -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();
|
||||
|
@ -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; } = [];
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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)
|
||||
|
@ -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>
|
||||
|
26
src/Admin/Billing/Models/ProviderPlanViewModel.cs
Normal file
26
src/Admin/Billing/Models/ProviderPlanViewModel.cs
Normal 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;
|
||||
}
|
||||
}
|
18
src/Admin/Billing/Views/Providers/ProviderPlans.cshtml
Normal file
18
src/Admin/Billing/Views/Providers/ProviderPlans.cshtml
Normal 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>
|
||||
}
|
@ -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)
|
||||
{
|
||||
|
@ -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)
|
||||
{
|
||||
|
@ -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,
|
||||
|
@ -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; }
|
||||
}
|
@ -1,8 +0,0 @@
|
||||
using Bit.Core.Entities;
|
||||
|
||||
namespace Bit.Admin.Models;
|
||||
|
||||
public class TaxRatesModel : PagedModel<TaxRate>
|
||||
{
|
||||
public string Message { get; set; }
|
||||
}
|
@ -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,
|
||||
|
@ -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">
|
||||
|
@ -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 & 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>
|
@ -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>
|
@ -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';
|
||||
|
@ -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)
|
||||
{
|
||||
|
@ -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")]
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
@ -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; }
|
||||
}
|
||||
|
@ -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.
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
{
|
||||
|
@ -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; }
|
||||
}
|
||||
|
@ -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'">
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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; }
|
||||
|
@ -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();
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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; }
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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; }
|
||||
}
|
||||
|
10
src/Api/Models/Public/Response/PagedListResponseModel.cs
Normal file
10
src/Api/Models/Public/Response/PagedListResponseModel.cs
Normal 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;
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
@ -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; }
|
||||
|
@ -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; }
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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();
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
{
|
||||
|
@ -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")]
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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; }
|
||||
}
|
@ -56,6 +56,11 @@
|
||||
"publicKey": "SECRET",
|
||||
"privateKey": "SECRET"
|
||||
},
|
||||
"importCiphersLimitation": {
|
||||
"ciphersLimit": 40000,
|
||||
"collectionRelationshipsLimit": 80000,
|
||||
"collectionsLimit": 2000
|
||||
},
|
||||
"bitPay": {
|
||||
"production": false,
|
||||
"token": "SECRET",
|
||||
|
@ -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>
|
||||
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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>();
|
||||
|
@ -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");
|
||||
}
|
||||
*/
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -32,5 +32,6 @@ public class JobsHostedService : BaseJobsHostedService
|
||||
public static void AddJobsServices(IServiceCollection services)
|
||||
{
|
||||
services.AddTransient<AliveJob>();
|
||||
services.AddTransient<SubscriptionCancellationJob>();
|
||||
}
|
||||
}
|
||||
|
58
src/Billing/Jobs/SubscriptionCancellationJob.cs
Normal file
58
src/Billing/Jobs/SubscriptionCancellationJob.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
44
src/Billing/Models/FreshdeskViewTicketModel.cs
Normal file
44
src/Billing/Models/FreshdeskViewTicketModel.cs
Normal 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; }
|
||||
}
|
54
src/Billing/Models/OnyxAnswerWithCitationRequestModel.cs
Normal file
54
src/Billing/Models/OnyxAnswerWithCitationRequestModel.cs
Normal 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";
|
||||
}
|
30
src/Billing/Models/OnyxAnswerWithCitationResponseModel.cs
Normal file
30
src/Billing/Models/OnyxAnswerWithCitationResponseModel.cs
Normal 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; }
|
||||
}
|
@ -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,
|
||||
|
@ -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
Loading…
x
Reference in New Issue
Block a user