1
0
mirror of https://github.com/bitwarden/server.git synced 2024-12-01 13:43:23 +01:00

Merge remote-tracking branch 'origin/main' into policy-definition-save

This commit is contained in:
Thomas Rittson 2024-10-07 14:36:35 +10:00
commit cb9865a88a
No known key found for this signature in database
GPG Key ID: CDDDA03861C35E27
228 changed files with 32729 additions and 723 deletions

View File

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

View File

@ -30,7 +30,7 @@ jobs:
secrets: "github-pat-bitwarden-devops-bot-repo-scope" secrets: "github-pat-bitwarden-devops-bot-repo-scope"
- name: Check out branch - name: Check out branch
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
with: with:
token: ${{ steps.retrieve-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }} token: ${{ steps.retrieve-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
@ -54,7 +54,7 @@ jobs:
if: ${{ needs.setup.outputs.copy_finalization_scripts == 'true' }} if: ${{ needs.setup.outputs.copy_finalization_scripts == 'true' }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
with: with:
fetch-depth: 0 fetch-depth: 0

View File

@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1 uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
@ -68,7 +68,7 @@ jobs:
node: true node: true
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1 uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
@ -173,7 +173,7 @@ jobs:
dotnet: true dotnet: true
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
- name: Check branch to publish - name: Check branch to publish
env: env:
@ -263,7 +263,7 @@ jobs:
-d ${{ matrix.base_path }}/${{ matrix.project_name }}/obj/build-output/publish -d ${{ matrix.base_path }}/${{ matrix.project_name }}/obj/build-output/publish
- name: Build Docker image - name: Build Docker image
uses: docker/build-push-action@5cd11c3a4ced054e52742c5fd54dca954e0edd85 # v6.7.0 uses: docker/build-push-action@32945a339266b759abcbdc89316275140b0fc960 # v6.8.0
with: with:
context: ${{ matrix.base_path }}/${{ matrix.project_name }} context: ${{ matrix.base_path }}/${{ matrix.project_name }}
file: ${{ matrix.base_path }}/${{ matrix.project_name }}/Dockerfile file: ${{ matrix.base_path }}/${{ matrix.project_name }}/Dockerfile
@ -282,7 +282,7 @@ jobs:
output-format: sarif output-format: sarif
- name: Upload Grype results to GitHub - name: Upload Grype results to GitHub
uses: github/codeql-action/upload-sarif@294a9d92911152fe08befb9ec03e240add280cb3 # v3.26.8 uses: github/codeql-action/upload-sarif@461ef6c76dfe95d5c364de2f431ddbd31a417628 # v3.26.9
with: with:
sarif_file: ${{ steps.container-scan.outputs.sarif }} sarif_file: ${{ steps.container-scan.outputs.sarif }}
@ -292,7 +292,7 @@ jobs:
needs: build-docker needs: build-docker
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1 uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
@ -467,7 +467,7 @@ jobs:
- win-x64 - win-x64
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1 uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1

View File

@ -24,7 +24,7 @@ jobs:
secrets: "github-pat-bitwarden-devops-bot-repo-scope" secrets: "github-pat-bitwarden-devops-bot-repo-scope"
- name: Checkout main - name: Checkout main
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
with: with:
ref: main ref: main
token: ${{ steps.retrieve-bot-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }} token: ${{ steps.retrieve-bot-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }}

View File

@ -33,7 +33,7 @@ jobs:
steps: steps:
- name: Check out repository - name: Check out repository
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
- name: Collect - name: Collect
id: collect id: collect

View File

@ -1,102 +0,0 @@
---
name: Container registry purge
on:
schedule:
- cron: "0 0 * * SUN"
workflow_dispatch:
inputs: {}
jobs:
purge:
name: Purge old images
runs-on: ubuntu-22.04
steps:
- name: Log in to Azure
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
with:
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
- name: Purge images
env:
REGISTRY: bitwardenprod
AGO_DUR_VER: "180d"
AGO_DUR: "30d"
run: |
REPO_LIST=$(az acr repository list -n $REGISTRY -o tsv)
for REPO in $REPO_LIST
do
PURGE_LATEST=""
PURGE_VERSION=""
PURGE_ELSE=""
TAG_LIST=$(az acr repository show-tags -n $REGISTRY --repository $REPO -o tsv)
for TAG in $TAG_LIST
do
if [ $TAG = "latest" ] || [ $TAG = "dev" ]; then
PURGE_LATEST+="--filter '$REPO:$TAG' "
elif [[ $TAG =~ [0-9]+\.[0-9]+\.[0-9]+ ]]; then
PURGE_VERSION+="--filter '$REPO:$TAG' "
else
PURGE_ELSE+="--filter '$REPO:$TAG' "
fi
done
if [ ! -z "$PURGE_LATEST" ]
then
PURGE_LATEST_CMD="acr purge $PURGE_LATEST --ago $AGO_DUR_VER --untagged --keep 1"
az acr run --cmd "$PURGE_LATEST_CMD" --registry $REGISTRY /dev/null &
fi
if [ ! -z "$PURGE_VERSION" ]
then
PURGE_VERSION_CMD="acr purge $PURGE_VERSION --ago $AGO_DUR_VER --untagged"
az acr run --cmd "$PURGE_VERSION_CMD" --registry $REGISTRY /dev/null &
fi
if [ ! -z "$PURGE_ELSE" ]
then
PURGE_ELSE_CMD="acr purge $PURGE_ELSE --ago $AGO_DUR --untagged"
az acr run --cmd "$PURGE_ELSE_CMD" --registry $REGISTRY /dev/null &
fi
wait
done
check-failures:
name: Check for failures
if: always()
runs-on: ubuntu-22.04
needs: [purge]
steps:
- name: Check if any job failed
if: |
(github.ref == 'refs/heads/main'
|| github.ref == 'refs/heads/rc'
|| github.ref == 'refs/heads/hotfix-rc')
&& contains(needs.*.result, 'failure')
run: exit 1
- name: Log in to Azure - CI subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
if: failure()
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
- name: Retrieve secrets
id: retrieve-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@main
if: failure()
with:
keyvault: "bitwarden-ci"
secrets: "devops-alerts-slack-webhook-url"
- name: Notify Slack on failure
uses: act10ns/slack@44541246747a30eb3102d87f7a4cc5471b0ffb7d # v2.1.0
if: failure()
env:
SLACK_WEBHOOK_URL: ${{ steps.retrieve-secrets.outputs.devops-alerts-slack-webhook-url }}
with:
status: ${{ job.status }}

View File

@ -29,7 +29,7 @@ jobs:
label: "DB-migrations-changed" label: "DB-migrations-changed"
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
with: with:
fetch-depth: 2 fetch-depth: 2

View File

@ -99,7 +99,7 @@ jobs:
echo "Github Release Option: $RELEASE_OPTION" echo "Github Release Option: $RELEASE_OPTION"
- name: Check out repo - name: Check out repo
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
- name: Set up project name - name: Set up project name
id: setup id: setup

View File

@ -37,7 +37,7 @@ jobs:
fi fi
- name: Check out repo - name: Check out repo
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
- name: Check release version - name: Check release version
id: version id: version

View File

@ -26,7 +26,7 @@ jobs:
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
with: with:
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}
@ -46,7 +46,7 @@ jobs:
--output-path . ${{ env.INCREMENTAL }} --output-path . ${{ env.INCREMENTAL }}
- name: Upload Checkmarx results to GitHub - name: Upload Checkmarx results to GitHub
uses: github/codeql-action/upload-sarif@294a9d92911152fe08befb9ec03e240add280cb3 # v3.26.8 uses: github/codeql-action/upload-sarif@461ef6c76dfe95d5c364de2f431ddbd31a417628 # v3.26.9
with: with:
sarif_file: cx_result.sarif sarif_file: cx_result.sarif
@ -66,7 +66,7 @@ jobs:
distribution: "zulu" distribution: "zulu"
- name: Check out repo - name: Check out repo
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
with: with:
fetch-depth: 0 fetch-depth: 0
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}

View File

@ -36,7 +36,7 @@ jobs:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1 uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
@ -147,7 +147,7 @@ jobs:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1 uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1

View File

@ -46,7 +46,7 @@ jobs:
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1 uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1

View File

@ -39,7 +39,7 @@ jobs:
fi fi
- name: Check out branch - name: Check out branch
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
- name: Check if RC branch exists - name: Check if RC branch exists
if: ${{ inputs.cut_rc_branch == true }} if: ${{ inputs.cut_rc_branch == true }}
@ -230,7 +230,7 @@ jobs:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- name: Check out branch - name: Check out branch
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
with: with:
ref: main ref: main

View File

@ -4,6 +4,7 @@ using Bit.Core.AdminConsole.Models.OrganizationConnectionConfigs;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Settings; using Bit.Core.Settings;
using Bit.Core.Utilities;
namespace Bit.Scim.Context; namespace Bit.Scim.Context;
@ -11,6 +12,32 @@ public class ScimContext : IScimContext
{ {
private bool _builtHttpContext; private bool _builtHttpContext;
// See IP list from Ping in docs: https://support.pingidentity.com/s/article/PingOne-IP-Addresses
private static readonly HashSet<string> _pingIpAddresses =
[
"18.217.152.87",
"52.14.10.143",
"13.58.49.148",
"34.211.92.81",
"54.214.158.219",
"34.218.98.164",
"15.223.133.47",
"3.97.84.38",
"15.223.19.71",
"3.97.98.120",
"52.60.115.173",
"3.97.202.223",
"18.184.65.93",
"52.57.244.92",
"18.195.7.252",
"108.128.67.71",
"34.246.158.102",
"108.128.250.27",
"52.63.103.92",
"13.54.131.18",
"52.62.204.36"
];
public ScimProviderType RequestScimProvider { get; set; } = ScimProviderType.Default; public ScimProviderType RequestScimProvider { get; set; } = ScimProviderType.Default;
public ScimConfig ScimConfiguration { get; set; } public ScimConfig ScimConfiguration { get; set; }
public Guid? OrganizationId { get; set; } public Guid? OrganizationId { get; set; }
@ -55,10 +82,18 @@ public class ScimContext : IScimContext
RequestScimProvider = ScimProviderType.Okta; RequestScimProvider = ScimProviderType.Okta;
} }
} }
if (RequestScimProvider == ScimProviderType.Default && if (RequestScimProvider == ScimProviderType.Default &&
httpContext.Request.Headers.ContainsKey("Adscimversion")) httpContext.Request.Headers.ContainsKey("Adscimversion"))
{ {
RequestScimProvider = ScimProviderType.AzureAd; RequestScimProvider = ScimProviderType.AzureAd;
} }
var ipAddress = CoreHelpers.GetIpAddress(httpContext, globalSettings);
if (RequestScimProvider == ScimProviderType.Default &&
_pingIpAddresses.Contains(ipAddress))
{
RequestScimProvider = ScimProviderType.Ping;
}
} }
} }

View File

@ -43,7 +43,8 @@ public class PutGroupCommand : IPutGroupCommand
private async Task UpdateGroupMembersAsync(Group group, ScimGroupRequestModel model) private async Task UpdateGroupMembersAsync(Group group, ScimGroupRequestModel model)
{ {
if (_scimContext.RequestScimProvider != ScimProviderType.Okta) if (_scimContext.RequestScimProvider != ScimProviderType.Okta &&
_scimContext.RequestScimProvider != ScimProviderType.Ping)
{ {
return; return;
} }

View File

@ -20,15 +20,16 @@ public class GetUsersListQuery : IGetUsersListQuery
string externalIdFilter = null; string externalIdFilter = null;
if (!string.IsNullOrWhiteSpace(filter)) if (!string.IsNullOrWhiteSpace(filter))
{ {
if (filter.StartsWith("userName eq ")) var filterLower = filter.ToLowerInvariant();
if (filterLower.StartsWith("username eq "))
{ {
usernameFilter = filter.Substring(12).Trim('"').ToLowerInvariant(); usernameFilter = filterLower.Substring(12).Trim('"');
if (usernameFilter.Contains("@")) if (usernameFilter.Contains("@"))
{ {
emailFilter = usernameFilter; emailFilter = usernameFilter;
} }
} }
else if (filter.StartsWith("externalId eq ")) else if (filterLower.StartsWith("externalid eq "))
{ {
externalIdFilter = filter.Substring(14).Trim('"'); externalIdFilter = filter.Substring(14).Trim('"');
} }

View File

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

View File

@ -14,6 +14,10 @@
<ProjectReference Include="..\Core\Core.csproj" /> <ProjectReference Include="..\Core\Core.csproj" />
<ProjectReference Include="..\..\util\SqliteMigrations\SqliteMigrations.csproj" /> <ProjectReference Include="..\..\util\SqliteMigrations\SqliteMigrations.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<Folder Include="Billing\Controllers\" />
<Folder Include="Billing\Models\" />
</ItemGroup>
<Choose> <Choose>
<When Condition="!$(DefineConstants.Contains('OSS'))"> <When Condition="!$(DefineConstants.Contains('OSS'))">

View File

@ -367,7 +367,7 @@ public class ProvidersController : Controller
return BadRequest("Provider does not exist"); return BadRequest("Provider does not exist");
} }
if (!string.Equals(providerName.Trim(), provider.Name, StringComparison.OrdinalIgnoreCase)) if (!string.Equals(providerName.Trim(), provider.DisplayName(), StringComparison.OrdinalIgnoreCase))
{ {
return BadRequest("Invalid provider name"); return BadRequest("Invalid provider name");
} }

View File

@ -174,8 +174,6 @@
<div class="d-flex mt-4"> <div class="d-flex mt-4">
<button type="submit" class="btn btn-primary" form="edit-form">Save</button> <button type="submit" class="btn btn-primary" form="edit-form">Save</button>
@if (FeatureService.IsEnabled(FeatureFlagKeys.EnableDeleteProvider))
{
<div class="ml-auto d-flex"> <div class="ml-auto d-flex">
<button class="btn btn-danger" onclick="openRequestDeleteModal(@Model.ProviderOrganizations.Count())">Request Delete</button> <button class="btn btn-danger" onclick="openRequestDeleteModal(@Model.ProviderOrganizations.Count())">Request Delete</button>
<button id="requestDeletionBtn" hidden="hidden" data-toggle="modal" data-target="#requestDeletionModal"></button> <button id="requestDeletionBtn" hidden="hidden" data-toggle="modal" data-target="#requestDeletionModal"></button>
@ -186,6 +184,5 @@
<button id="linkAccWarningBtn" hidden="hidden" data-toggle="modal" data-target="#linkedWarningModal"></button> <button id="linkAccWarningBtn" hidden="hidden" data-toggle="modal" data-target="#linkedWarningModal"></button>
</div> </div>
}
</div> </div>
} }

View File

@ -20,9 +20,10 @@
function deleteProvider(id) { function deleteProvider(id) {
const providerName = $('#DeleteModal input#provider-name').val(); const providerName = $('#DeleteModal input#provider-name').val();
const encodedProviderName = encodeURIComponent(providerName);
$.ajax({ $.ajax({
type: "POST", type: "POST",
url: `@Url.Action("Delete", "Providers")?id=${id}&providerName=${providerName}`, url: `@Url.Action("Delete", "Providers")?id=${id}&providerName=${encodedProviderName}`,
dataType: 'json', dataType: 'json',
contentType: false, contentType: false,
processData: false, processData: false,

View File

@ -0,0 +1,83 @@
using Bit.Admin.Billing.Models;
using Bit.Admin.Enums;
using Bit.Admin.Utilities;
using Bit.Core.Billing.Migration.Models;
using Bit.Core.Billing.Migration.Services;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Admin.Billing.Controllers;
[Authorize]
[Route("migrate-providers")]
[SelfHosted(NotSelfHostedOnly = true)]
public class MigrateProvidersController(
IProviderMigrator providerMigrator) : Controller
{
[HttpGet]
[RequirePermission(Permission.Tools_MigrateProviders)]
public IActionResult Index()
{
return View(new MigrateProvidersRequestModel());
}
[HttpPost]
[RequirePermission(Permission.Tools_MigrateProviders)]
[ValidateAntiForgeryToken]
public async Task<IActionResult> PostAsync(MigrateProvidersRequestModel request)
{
var providerIds = GetProviderIdsFromInput(request.ProviderIds);
if (providerIds.Count == 0)
{
return RedirectToAction("Index");
}
foreach (var providerId in providerIds)
{
await providerMigrator.Migrate(providerId);
}
return RedirectToAction("Results", new { ProviderIds = string.Join("\r\n", providerIds) });
}
[HttpGet("results")]
[RequirePermission(Permission.Tools_MigrateProviders)]
public async Task<IActionResult> ResultsAsync(MigrateProvidersRequestModel request)
{
var providerIds = GetProviderIdsFromInput(request.ProviderIds);
if (providerIds.Count == 0)
{
return View(Array.Empty<ProviderMigrationResult>());
}
var results = await Task.WhenAll(providerIds.Select(providerMigrator.GetResult));
return View(results);
}
[HttpGet("results/{providerId:guid}")]
[RequirePermission(Permission.Tools_MigrateProviders)]
public async Task<IActionResult> DetailsAsync([FromRoute] Guid providerId)
{
var result = await providerMigrator.GetResult(providerId);
if (result == null)
{
return RedirectToAction("Index");
}
return View(result);
}
private static List<Guid> GetProviderIdsFromInput(string text) => !string.IsNullOrEmpty(text)
? text.Split(
["\r\n", "\r", "\n"],
StringSplitOptions.TrimEntries
)
.Select(id => new Guid(id))
.ToList()
: [];
}

View File

@ -0,0 +1,71 @@
using System.Text.Json;
using Bit.Admin.Billing.Models.ProcessStripeEvents;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Admin.Billing.Controllers;
[Authorize]
[Route("process-stripe-events")]
[SelfHosted(NotSelfHostedOnly = true)]
public class ProcessStripeEventsController(
IHttpClientFactory httpClientFactory,
IGlobalSettings globalSettings) : Controller
{
[HttpGet]
public ActionResult Index()
{
return View(new EventsFormModel());
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ProcessAsync([FromForm] EventsFormModel model)
{
var eventIds = model.GetEventIds();
const string baseEndpoint = "stripe/recovery/events";
var endpoint = model.Inspect ? $"{baseEndpoint}/inspect" : $"{baseEndpoint}/process";
var (response, failedResponseMessage) = await PostAsync(endpoint, new EventsRequestBody
{
EventIds = eventIds
});
if (response == null)
{
return StatusCode((int)failedResponseMessage.StatusCode, "An error occurred during your request.");
}
response.ActionType = model.Inspect ? EventActionType.Inspect : EventActionType.Process;
return View("Results", response);
}
private async Task<(EventsResponseBody, HttpResponseMessage)> PostAsync(
string endpoint,
EventsRequestBody requestModel)
{
var client = httpClientFactory.CreateClient("InternalBilling");
client.BaseAddress = new Uri(globalSettings.BaseServiceUri.InternalBilling);
var json = JsonSerializer.Serialize(requestModel);
var requestBody = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
var responseMessage = await client.PostAsync(endpoint, requestBody);
if (!responseMessage.IsSuccessStatusCode)
{
return (null, responseMessage);
}
var responseContent = await responseMessage.Content.ReadAsStringAsync();
var response = JsonSerializer.Deserialize<EventsResponseBody>(responseContent);
return (response, null);
}
}

View File

@ -0,0 +1,10 @@
using System.ComponentModel.DataAnnotations;
namespace Bit.Admin.Billing.Models;
public class MigrateProvidersRequestModel
{
[Required]
[Display(Name = "Provider IDs")]
public string ProviderIds { get; set; }
}

View File

@ -0,0 +1,29 @@
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
namespace Bit.Admin.Billing.Models.ProcessStripeEvents;
public class EventsFormModel : IValidatableObject
{
[Required]
public string EventIds { get; set; }
[Required]
[DisplayName("Inspect Only")]
public bool Inspect { get; set; }
public List<string> GetEventIds() =>
EventIds?.Split([Environment.NewLine], StringSplitOptions.RemoveEmptyEntries)
.Select(eventId => eventId.Trim())
.ToList() ?? [];
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
var eventIds = GetEventIds();
if (eventIds.Any(eventId => !eventId.StartsWith("evt_")))
{
yield return new ValidationResult("Event Ids must start with 'evt_'.");
}
}
}

View File

@ -0,0 +1,9 @@
using System.Text.Json.Serialization;
namespace Bit.Admin.Billing.Models.ProcessStripeEvents;
public class EventsRequestBody
{
[JsonPropertyName("eventIds")]
public List<string> EventIds { get; set; }
}

View File

@ -0,0 +1,39 @@
using System.Text.Json.Serialization;
namespace Bit.Admin.Billing.Models.ProcessStripeEvents;
public class EventsResponseBody
{
[JsonPropertyName("events")]
public List<EventResponseBody> Events { get; set; }
[JsonIgnore]
public EventActionType ActionType { get; set; }
}
public class EventResponseBody
{
[JsonPropertyName("id")]
public string Id { get; set; }
[JsonPropertyName("url")]
public string URL { get; set; }
[JsonPropertyName("apiVersion")]
public string APIVersion { get; set; }
[JsonPropertyName("type")]
public string Type { get; set; }
[JsonPropertyName("createdUTC")]
public DateTime CreatedUTC { get; set; }
[JsonPropertyName("processingError")]
public string ProcessingError { get; set; }
}
public enum EventActionType
{
Inspect,
Process
}

View File

@ -0,0 +1,39 @@
@using System.Text.Json
@model Bit.Core.Billing.Migration.Models.ProviderMigrationResult
@{
ViewData["Title"] = "Results";
}
<h1>Migrate Providers</h1>
<h2>Migration Details: @Model.ProviderName</h2>
<dl class="row">
<dt class="col-sm-4 col-lg-3">Id</dt>
<dd class="col-sm-8 col-lg-9"><code>@Model.ProviderId</code></dd>
<dt class="col-sm-4 col-lg-3">Result</dt>
<dd class="col-sm-8 col-lg-9">@Model.Result</dd>
</dl>
<h3>Client Organizations</h3>
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Result</th>
<th>Previous State</th>
</tr>
</thead>
<tbody>
@foreach (var clientResult in Model.Clients)
{
<tr>
<td>@clientResult.OrganizationId</td>
<td>@clientResult.OrganizationName</td>
<td>@clientResult.Result</td>
<td><pre>@Html.Raw(JsonSerializer.Serialize(clientResult.PreviousState))</pre></td>
</tr>
}
</tbody>
</table>
</div>

View File

@ -0,0 +1,46 @@
@model Bit.Admin.Billing.Models.MigrateProvidersRequestModel;
@{
ViewData["Title"] = "Migrate Providers";
}
<h1>Migrate Providers</h1>
<h2>Bulk Consolidated Billing Migration Tool</h2>
<section>
<p>
This tool allows you to provide a list of IDs for Providers that you would like to migrate to Consolidated Billing.
Because of the expensive nature of the operation, you can only migrate 10 Providers at a time.
</p>
<p class="alert alert-warning">
Updates made through this tool are irreversible without manual intervention.
</p>
<p>Example Input (Please enter each Provider ID separated by a new line):</p>
<div class="card">
<div class="card-body">
<pre class="mb-0">f513affc-2290-4336-879e-21ec3ecf3e78
f7a5cb0d-4b74-445c-8d8c-232d1d32bbe2
bf82d3cf-0e21-4f39-b81b-ef52b2fc6a3a
174e82fc-70c3-448d-9fe7-00bad2a3ab00
22a4bbbf-58e3-4e4c-a86a-a0d7caf4ff14</pre>
</div>
</div>
<form method="post" asp-controller="MigrateProviders" asp-action="Post" class="mt-2">
<div asp-validation-summary="All" class="text-danger"></div>
<div class="form-group">
<label asp-for="ProviderIds"></label>
<textarea rows="10" class="form-control" type="text" asp-for="ProviderIds"></textarea>
</div>
<div class="form-group">
<input type="submit" value="Run" class="btn btn-primary mb-2"/>
</div>
</form>
<form method="get" asp-controller="MigrateProviders" asp-action="Results" class="mt-2">
<div asp-validation-summary="All" class="text-danger"></div>
<div class="form-group">
<label asp-for="ProviderIds"></label>
<textarea rows="10" class="form-control" type="text" asp-for="ProviderIds"></textarea>
</div>
<div class="form-group">
<input type="submit" value="See Previous Results" class="btn btn-primary mb-2"/>
</div>
</form>
</section>

View File

@ -0,0 +1,28 @@
@model Bit.Core.Billing.Migration.Models.ProviderMigrationResult[]
@{
ViewData["Title"] = "Results";
}
<h1>Migrate Providers</h1>
<h2>Results</h2>
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Result</th>
</tr>
</thead>
<tbody>
@foreach (var result in Model)
{
<tr>
<td><a href="@Url.Action("Details", "MigrateProviders", new { providerId = result.ProviderId })">@result.ProviderId</a></td>
<td>@result.ProviderName</td>
<td>@result.Result</td>
</tr>
}
</tbody>
</table>
</div>

View File

@ -0,0 +1,25 @@
@model Bit.Admin.Billing.Models.ProcessStripeEvents.EventsFormModel
@{
ViewData["Title"] = "Process Stripe Events";
}
<h1>Process Stripe Events</h1>
<form method="post" asp-controller="ProcessStripeEvents" asp-action="Process">
<div class="row">
<div class="col-1">
<div class="form-group">
<input type="submit" value="Process" class="btn btn-primary mb-2"/>
</div>
</div>
<div class="col-2">
<div class="form-group form-check">
<input type="checkbox" class="form-check-input" asp-for="Inspect">
<label class="form-check-label" asp-for="Inspect"></label>
</div>
</div>
</div>
<div class="form-group">
<textarea id="event-ids" type="text" class="form-control" rows="100" asp-for="EventIds"></textarea>
</div>
</form>

View File

@ -0,0 +1,49 @@
@using Bit.Admin.Billing.Models.ProcessStripeEvents
@model Bit.Admin.Billing.Models.ProcessStripeEvents.EventsResponseBody
@{
var title = Model.ActionType == EventActionType.Inspect ? "Inspect Stripe Events" : "Process Stripe Events";
ViewData["Title"] = title;
}
<h1>@title</h1>
<h2>Results</h2>
<div class="table-responsive">
@if (!Model.Events.Any())
{
<p>No data found.</p>
}
else
{
<table class="table table-striped table-hover">
<thead>
<tr>
<th>ID</th>
<th>Type</th>
<th>API Version</th>
<th>Created</th>
@if (Model.ActionType == EventActionType.Process)
{
<th>Processing Error</th>
}
</tr>
</thead>
<tbody>
@foreach (var eventResponseBody in Model.Events)
{
<tr>
<td><a href="@eventResponseBody.URL">@eventResponseBody.Id</a></td>
<td>@eventResponseBody.Type</td>
<td>@eventResponseBody.APIVersion</td>
<td>@eventResponseBody.CreatedUTC</td>
@if (Model.ActionType == EventActionType.Process)
{
<td>@eventResponseBody.ProcessingError</td>
}
</tr>
}
</tbody>
</table>
}
</div>

View File

@ -0,0 +1,5 @@
@using Microsoft.AspNetCore.Identity
@using Bit.Admin.AdminConsole
@using Bit.Admin.AdminConsole.Models
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper "*, Admin"

View File

@ -0,0 +1,3 @@
@{
Layout = "_Layout";
}

View File

@ -1,11 +1,11 @@
using Bit.Admin.Enums; #nullable enable
using Bit.Admin.Enums;
using Bit.Admin.Models; using Bit.Admin.Models;
using Bit.Admin.Services; using Bit.Admin.Services;
using Bit.Admin.Utilities; using Bit.Admin.Utilities;
using Bit.Core; using Bit.Core;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Settings; using Bit.Core.Settings;
@ -24,9 +24,9 @@ public class UsersController : Controller
private readonly IPaymentService _paymentService; private readonly IPaymentService _paymentService;
private readonly GlobalSettings _globalSettings; private readonly GlobalSettings _globalSettings;
private readonly IAccessControlService _accessControlService; private readonly IAccessControlService _accessControlService;
private readonly ICurrentContext _currentContext;
private readonly IFeatureService _featureService;
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
private readonly IFeatureService _featureService;
private readonly IUserService _userService;
public UsersController( public UsersController(
IUserRepository userRepository, IUserRepository userRepository,
@ -34,18 +34,18 @@ public class UsersController : Controller
IPaymentService paymentService, IPaymentService paymentService,
GlobalSettings globalSettings, GlobalSettings globalSettings,
IAccessControlService accessControlService, IAccessControlService accessControlService,
ICurrentContext currentContext, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
IFeatureService featureService, IFeatureService featureService,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery) IUserService userService)
{ {
_userRepository = userRepository; _userRepository = userRepository;
_cipherRepository = cipherRepository; _cipherRepository = cipherRepository;
_paymentService = paymentService; _paymentService = paymentService;
_globalSettings = globalSettings; _globalSettings = globalSettings;
_accessControlService = accessControlService; _accessControlService = accessControlService;
_currentContext = currentContext;
_featureService = featureService;
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
_featureService = featureService;
_userService = userService;
} }
[RequirePermission(Permission.User_List_View)] [RequirePermission(Permission.User_List_View)]
@ -64,19 +64,26 @@ public class UsersController : Controller
var skip = (page - 1) * count; var skip = (page - 1) * count;
var users = await _userRepository.SearchAsync(email, skip, count); var users = await _userRepository.SearchAsync(email, skip, count);
var userModels = new List<UserViewModel>();
if (_featureService.IsEnabled(FeatureFlagKeys.MembersTwoFAQueryOptimization)) if (_featureService.IsEnabled(FeatureFlagKeys.MembersTwoFAQueryOptimization))
{ {
var user2Fa = (await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(users.Select(u => u.Id))).ToList(); var twoFactorAuthLookup = (await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(users.Select(u => u.Id))).ToList();
// TempDataSerializer is having an issue serializing an empty IEnumerable<Tuple<T1,T2>>, do not set if empty.
if (user2Fa.Count != 0) userModels = UserViewModel.MapViewModels(users, twoFactorAuthLookup).ToList();
}
else
{ {
TempData["UsersTwoFactorIsEnabled"] = user2Fa; foreach (var user in users)
{
var isTwoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user);
userModels.Add(UserViewModel.MapViewModel(user, isTwoFactorEnabled));
} }
} }
return View(new UsersModel return View(new UsersModel
{ {
Items = users as List<User>, Items = userModels,
Email = string.IsNullOrWhiteSpace(email) ? null : email, Email = string.IsNullOrWhiteSpace(email) ? null : email,
Page = page, Page = page,
Count = count, Count = count,
@ -87,13 +94,17 @@ public class UsersController : Controller
public async Task<IActionResult> View(Guid id) public async Task<IActionResult> View(Guid id)
{ {
var user = await _userRepository.GetByIdAsync(id); var user = await _userRepository.GetByIdAsync(id);
if (user == null) if (user == null)
{ {
return RedirectToAction("Index"); return RedirectToAction("Index");
} }
var ciphers = await _cipherRepository.GetManyByUserIdAsync(id); var ciphers = await _cipherRepository.GetManyByUserIdAsync(id);
return View(new UserViewModel(user, ciphers));
var isTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user);
return View(UserViewModel.MapViewModel(user, isTwoFactorEnabled, ciphers));
} }
[SelfHosted(NotSelfHostedOnly = true)] [SelfHosted(NotSelfHostedOnly = true)]
@ -108,7 +119,8 @@ public class UsersController : Controller
var ciphers = await _cipherRepository.GetManyByUserIdAsync(id); var ciphers = await _cipherRepository.GetManyByUserIdAsync(id);
var billingInfo = await _paymentService.GetBillingAsync(user); var billingInfo = await _paymentService.GetBillingAsync(user);
var billingHistoryInfo = await _paymentService.GetBillingHistoryAsync(user); var billingHistoryInfo = await _paymentService.GetBillingHistoryAsync(user);
return View(new UserEditModel(user, ciphers, billingInfo, billingHistoryInfo, _globalSettings)); var isTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user);
return View(new UserEditModel(user, isTwoFactorEnabled, ciphers, billingInfo, billingHistoryInfo, _globalSettings));
} }
[HttpPost] [HttpPost]

View File

@ -47,5 +47,7 @@ public enum Permission
Tools_GenerateLicenseFile, Tools_GenerateLicenseFile,
Tools_ManageTaxRates, Tools_ManageTaxRates,
Tools_ManageStripeSubscriptions, Tools_ManageStripeSubscriptions,
Tools_CreateEditTransaction Tools_CreateEditTransaction,
Tools_ProcessStripeEvents,
Tools_MigrateProviders
} }

View File

@ -7,18 +7,23 @@ using Bit.Core.Vault.Entities;
namespace Bit.Admin.Models; namespace Bit.Admin.Models;
public class UserEditModel : UserViewModel public class UserEditModel
{ {
public UserEditModel() { } public UserEditModel()
{
}
public UserEditModel( public UserEditModel(
User user, User user,
bool isTwoFactorEnabled,
IEnumerable<Cipher> ciphers, IEnumerable<Cipher> ciphers,
BillingInfo billingInfo, BillingInfo billingInfo,
BillingHistoryInfo billingHistoryInfo, BillingHistoryInfo billingHistoryInfo,
GlobalSettings globalSettings) GlobalSettings globalSettings)
: base(user, ciphers)
{ {
User = UserViewModel.MapViewModel(user, isTwoFactorEnabled, ciphers);
BillingInfo = billingInfo; BillingInfo = billingInfo;
BillingHistoryInfo = billingHistoryInfo; BillingHistoryInfo = billingHistoryInfo;
BraintreeMerchantId = globalSettings.Braintree.MerchantId; BraintreeMerchantId = globalSettings.Braintree.MerchantId;
@ -35,32 +40,32 @@ public class UserEditModel : UserViewModel
PremiumExpirationDate = user.PremiumExpirationDate; PremiumExpirationDate = user.PremiumExpirationDate;
} }
public BillingInfo BillingInfo { get; set; } public UserViewModel User { get; init; }
public BillingHistoryInfo BillingHistoryInfo { get; set; } public BillingInfo BillingInfo { get; init; }
public BillingHistoryInfo BillingHistoryInfo { get; init; }
public string RandomLicenseKey => CoreHelpers.SecureRandomString(20); public string RandomLicenseKey => CoreHelpers.SecureRandomString(20);
public string OneYearExpirationDate => DateTime.Now.AddYears(1).ToString("yyyy-MM-ddTHH:mm"); public string OneYearExpirationDate => DateTime.Now.AddYears(1).ToString("yyyy-MM-ddTHH:mm");
public string BraintreeMerchantId { get; set; } public string BraintreeMerchantId { get; init; }
[Display(Name = "Name")] [Display(Name = "Name")]
public string Name { get; set; } public string Name { get; init; }
[Required] [Required]
[Display(Name = "Email")] [Display(Name = "Email")]
public string Email { get; set; } public string Email { get; init; }
[Display(Name = "Email Verified")] [Display(Name = "Email Verified")]
public bool EmailVerified { get; set; } public bool EmailVerified { get; init; }
[Display(Name = "Premium")] [Display(Name = "Premium")]
public bool Premium { get; set; } public bool Premium { get; init; }
[Display(Name = "Max. Storage GB")] [Display(Name = "Max. Storage GB")]
public short? MaxStorageGb { get; set; } public short? MaxStorageGb { get; init; }
[Display(Name = "Gateway")] [Display(Name = "Gateway")]
public Core.Enums.GatewayType? Gateway { get; set; } public Core.Enums.GatewayType? Gateway { get; init; }
[Display(Name = "Gateway Customer Id")] [Display(Name = "Gateway Customer Id")]
public string GatewayCustomerId { get; set; } public string GatewayCustomerId { get; init; }
[Display(Name = "Gateway Subscription Id")] [Display(Name = "Gateway Subscription Id")]
public string GatewaySubscriptionId { get; set; } public string GatewaySubscriptionId { get; init; }
[Display(Name = "License Key")] [Display(Name = "License Key")]
public string LicenseKey { get; set; } public string LicenseKey { get; init; }
[Display(Name = "Premium Expiration Date")] [Display(Name = "Premium Expiration Date")]
public DateTime? PremiumExpirationDate { get; set; } public DateTime? PremiumExpirationDate { get; init; }
} }

View File

@ -1,18 +1,131 @@
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Vault.Entities; using Bit.Core.Vault.Entities;
namespace Bit.Admin.Models; namespace Bit.Admin.Models;
public class UserViewModel public class UserViewModel
{ {
public UserViewModel() { } public Guid Id { get; }
public string Name { get; }
public string Email { get; }
public DateTime CreationDate { get; }
public DateTime? PremiumExpirationDate { get; }
public bool Premium { get; }
public short? MaxStorageGb { get; }
public bool EmailVerified { get; }
public bool TwoFactorEnabled { get; }
public DateTime AccountRevisionDate { get; }
public DateTime RevisionDate { get; }
public DateTime? LastEmailChangeDate { get; }
public DateTime? LastKdfChangeDate { get; }
public DateTime? LastKeyRotationDate { get; }
public DateTime? LastPasswordChangeDate { get; }
public GatewayType? Gateway { get; }
public string GatewayCustomerId { get; }
public string GatewaySubscriptionId { get; }
public string LicenseKey { get; }
public int CipherCount { get; set; }
public UserViewModel(User user, IEnumerable<Cipher> ciphers) public UserViewModel(Guid id,
string name,
string email,
DateTime creationDate,
DateTime? premiumExpirationDate,
bool premium,
short? maxStorageGb,
bool emailVerified,
bool twoFactorEnabled,
DateTime accountRevisionDate,
DateTime revisionDate,
DateTime? lastEmailChangeDate,
DateTime? lastKdfChangeDate,
DateTime? lastKeyRotationDate,
DateTime? lastPasswordChangeDate,
GatewayType? gateway,
string gatewayCustomerId,
string gatewaySubscriptionId,
string licenseKey,
IEnumerable<Cipher> ciphers)
{ {
User = user; Id = id;
Name = name;
Email = email;
CreationDate = creationDate;
PremiumExpirationDate = premiumExpirationDate;
Premium = premium;
MaxStorageGb = maxStorageGb;
EmailVerified = emailVerified;
TwoFactorEnabled = twoFactorEnabled;
AccountRevisionDate = accountRevisionDate;
RevisionDate = revisionDate;
LastEmailChangeDate = lastEmailChangeDate;
LastKdfChangeDate = lastKdfChangeDate;
LastKeyRotationDate = lastKeyRotationDate;
LastPasswordChangeDate = lastPasswordChangeDate;
Gateway = gateway;
GatewayCustomerId = gatewayCustomerId;
GatewaySubscriptionId = gatewaySubscriptionId;
LicenseKey = licenseKey;
CipherCount = ciphers.Count(); CipherCount = ciphers.Count();
} }
public User User { get; set; } public static IEnumerable<UserViewModel> MapViewModels(
public int CipherCount { get; set; } IEnumerable<User> users,
IEnumerable<(Guid userId, bool twoFactorIsEnabled)> lookup) =>
users.Select(user => MapViewModel(user, lookup));
public static UserViewModel MapViewModel(User user,
IEnumerable<(Guid userId, bool twoFactorIsEnabled)> lookup) =>
new(
user.Id,
user.Name,
user.Email,
user.CreationDate,
user.PremiumExpirationDate,
user.Premium,
user.MaxStorageGb,
user.EmailVerified,
IsTwoFactorEnabled(user, lookup),
user.AccountRevisionDate,
user.RevisionDate,
user.LastEmailChangeDate,
user.LastKdfChangeDate,
user.LastKeyRotationDate,
user.LastPasswordChangeDate,
user.Gateway,
user.GatewayCustomerId ?? string.Empty,
user.GatewaySubscriptionId ?? string.Empty,
user.LicenseKey ?? string.Empty,
Array.Empty<Cipher>());
public static UserViewModel MapViewModel(User user, bool isTwoFactorEnabled) =>
MapViewModel(user, isTwoFactorEnabled, Array.Empty<Cipher>());
public static UserViewModel MapViewModel(User user, bool isTwoFactorEnabled, IEnumerable<Cipher> ciphers) =>
new(
user.Id,
user.Name,
user.Email,
user.CreationDate,
user.PremiumExpirationDate,
user.Premium,
user.MaxStorageGb,
user.EmailVerified,
isTwoFactorEnabled,
user.AccountRevisionDate,
user.RevisionDate,
user.LastEmailChangeDate,
user.LastKdfChangeDate,
user.LastKeyRotationDate,
user.LastPasswordChangeDate,
user.Gateway,
user.GatewayCustomerId ?? string.Empty,
user.GatewaySubscriptionId ?? string.Empty,
user.LicenseKey ?? string.Empty,
ciphers);
public static bool IsTwoFactorEnabled(User user,
IEnumerable<(Guid userId, bool twoFactorIsEnabled)> twoFactorIsEnabledLookup) =>
twoFactorIsEnabledLookup.FirstOrDefault(x => x.userId == user.Id).twoFactorIsEnabled;
} }

View File

@ -1,8 +1,6 @@
using Bit.Core.Entities; namespace Bit.Admin.Models;
namespace Bit.Admin.Models; public class UsersModel : PagedModel<UserViewModel>
public class UsersModel : PagedModel<User>
{ {
public string Email { get; set; } public string Email { get; set; }
public string Action { get; set; } public string Action { get; set; }

View File

@ -10,6 +10,7 @@ using Microsoft.AspNetCore.Mvc.Razor;
using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.DependencyInjection.Extensions;
using Bit.Admin.Services; using Bit.Admin.Services;
using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Migration;
#if !OSS #if !OSS
using Bit.Commercial.Core.Utilities; using Bit.Commercial.Core.Utilities;
@ -88,7 +89,10 @@ public class Startup
services.AddBaseServices(globalSettings); services.AddBaseServices(globalSettings);
services.AddDefaultServices(globalSettings); services.AddDefaultServices(globalSettings);
services.AddScoped<IAccessControlService, AccessControlService>(); services.AddScoped<IAccessControlService, AccessControlService>();
services.AddDistributedCache(globalSettings);
services.AddBillingOperations(); services.AddBillingOperations();
services.AddHttpClient();
services.AddProviderMigration();
#if OSS #if OSS
services.AddOosServices(); services.AddOosServices();
@ -108,6 +112,7 @@ public class Startup
{ {
o.ViewLocationFormats.Add("/Auth/Views/{1}/{0}.cshtml"); o.ViewLocationFormats.Add("/Auth/Views/{1}/{0}.cshtml");
o.ViewLocationFormats.Add("/AdminConsole/Views/{1}/{0}.cshtml"); o.ViewLocationFormats.Add("/AdminConsole/Views/{1}/{0}.cshtml");
o.ViewLocationFormats.Add("/Billing/Views/{1}/{0}.cshtml");
}); });
// Jobs service // Jobs service

View File

@ -161,7 +161,9 @@ public static class RolePermissionMapping
Permission.Tools_GenerateLicenseFile, Permission.Tools_GenerateLicenseFile,
Permission.Tools_ManageTaxRates, Permission.Tools_ManageTaxRates,
Permission.Tools_ManageStripeSubscriptions, Permission.Tools_ManageStripeSubscriptions,
Permission.Tools_CreateEditTransaction Permission.Tools_CreateEditTransaction,
Permission.Tools_ProcessStripeEvents,
Permission.Tools_MigrateProviders
} }
}, },
{ "sales", new List<Permission> { "sales", new List<Permission>

View File

@ -14,6 +14,8 @@
var canGenerateLicense = AccessControlService.UserHasPermission(Permission.Tools_GenerateLicenseFile); var canGenerateLicense = AccessControlService.UserHasPermission(Permission.Tools_GenerateLicenseFile);
var canManageTaxRates = AccessControlService.UserHasPermission(Permission.Tools_ManageTaxRates); var canManageTaxRates = AccessControlService.UserHasPermission(Permission.Tools_ManageTaxRates);
var canManageStripeSubscriptions = AccessControlService.UserHasPermission(Permission.Tools_ManageStripeSubscriptions); 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 || var canViewTools = canChargeBraintree || canCreateTransaction || canPromoteAdmin ||
canGenerateLicense || canManageTaxRates || canManageStripeSubscriptions; canGenerateLicense || canManageTaxRates || canManageStripeSubscriptions;
@ -107,6 +109,18 @@
Manage Stripe Subscriptions Manage Stripe Subscriptions
</a> </a>
} }
@if (canProcessStripeEvents)
{
<a class="dropdown-item" asp-controller="ProcessStripeEvents" asp-action="Index">
Process Stripe Events
</a>
}
@if (canMigrateProviders)
{
<a class="dropdown-item" asp-controller="MigrateProviders" asp-action="index">
Migrate Providers
</a>
}
</div> </div>
</li> </li>
} }

View File

@ -86,7 +86,7 @@
@if (canViewUserInformation) @if (canViewUserInformation)
{ {
<h2>User Information</h2> <h2>User Information</h2>
@await Html.PartialAsync("_ViewInformation", Model) @await Html.PartialAsync("_ViewInformation", Model.User)
} }
@if (canViewBillingInformation) @if (canViewBillingInformation)
{ {

View File

@ -1,6 +1,4 @@
@model UsersModel @model UsersModel
@inject Bit.Core.Services.IUserService userService
@inject Bit.Core.Services.IFeatureService featureService
@{ @{
ViewData["Title"] = "Users"; ViewData["Title"] = "Users";
} }
@ -46,7 +44,8 @@
@if (user.Premium) @if (user.Premium)
{ {
<i class="fa fa-star fa-lg fa-fw" <i class="fa fa-star fa-lg fa-fw"
title="Premium, expires @(user.PremiumExpirationDate?.ToShortDateString() ?? "-")"></i> title="Premium, expires @(user.PremiumExpirationDate?.ToShortDateString() ?? "-")">
</i>
} }
else else
{ {
@ -55,12 +54,14 @@
@if (user.MaxStorageGb.HasValue && user.MaxStorageGb > 1) @if (user.MaxStorageGb.HasValue && user.MaxStorageGb > 1)
{ {
<i class="fa fa-plus-square fa-lg fa-fw" <i class="fa fa-plus-square fa-lg fa-fw"
title="Additional Storage, @(user.MaxStorageGb - 1) GB"></i> title="Additional Storage, @(user.MaxStorageGb - 1) GB">
</i>
} }
else else
{ {
<i class="fa fa-plus-square-o fa-lg fa-fw text-muted" <i class="fa fa-plus-square-o fa-lg fa-fw text-muted"
title="No Additional Storage"></i> title="No Additional Storage">
</i>
} }
@if (user.EmailVerified) @if (user.EmailVerified)
{ {
@ -70,12 +71,7 @@
{ {
<i class="fa fa-times-circle-o fa-lg fa-fw text-muted" title="Email Not Verified"></i> <i class="fa fa-times-circle-o fa-lg fa-fw text-muted" title="Email Not Verified"></i>
} }
@if (featureService.IsEnabled(Bit.Core.FeatureFlagKeys.MembersTwoFAQueryOptimization)) @if (user.TwoFactorEnabled)
{
var usersTwoFactorIsEnabled = TempData["UsersTwoFactorIsEnabled"] as IEnumerable<(Guid userId, bool twoFactorIsEnabled)>;
var matchingUser2Fa = usersTwoFactorIsEnabled?.FirstOrDefault(tuple => tuple.userId == user.Id);
@if(matchingUser2Fa is { twoFactorIsEnabled: true })
{ {
<i class="fa fa-lock fa-lg fa-fw" title="2FA Enabled"></i> <i class="fa fa-lock fa-lg fa-fw" title="2FA Enabled"></i>
} }
@ -83,18 +79,6 @@
{ {
<i class="fa fa-unlock fa-lg fa-fw text-muted" title="2FA Not Enabled"></i> <i class="fa fa-unlock fa-lg fa-fw text-muted" title="2FA Not Enabled"></i>
} }
}
else
{
@if(await userService.TwoFactorIsEnabledAsync(user))
{
<i class="fa fa-lock fa-lg fa-fw" title="2FA Enabled"></i>
}
else
{
<i class="fa fa-unlock fa-lg fa-fw text-muted" title="2FA Not Enabled"></i>
}
}
</td> </td>
</tr> </tr>
} }
@ -109,7 +93,9 @@
{ {
<li class="page-item"> <li class="page-item">
<a class="page-link" asp-action="Index" asp-route-page="@Model.PreviousPage.Value" <a class="page-link" asp-action="Index" asp-route-page="@Model.PreviousPage.Value"
asp-route-count="@Model.Count" asp-route-email="@Model.Email">Previous</a> asp-route-count="@Model.Count" asp-route-email="@Model.Email">
Previous
</a>
</li> </li>
} }
else else
@ -122,7 +108,9 @@
{ {
<li class="page-item"> <li class="page-item">
<a class="page-link" asp-action="Index" asp-route-page="@Model.NextPage.Value" <a class="page-link" asp-action="Index" asp-route-page="@Model.NextPage.Value"
asp-route-count="@Model.Count" asp-route-email="@Model.Email">Next</a> asp-route-count="@Model.Count" asp-route-email="@Model.Email">
Next
</a>
</li> </li>
} }
else else

View File

@ -1,13 +1,13 @@
@model UserViewModel @model UserViewModel
@{ @{
ViewData["Title"] = "User: " + Model.User.Email; ViewData["Title"] = "User: " + Model.Email;
} }
<h1>User <small>@Model.User.Email</small></h1> <h1>User <small>@Model.Email</small></h1>
<h2>Information</h2> <h2>Information</h2>
@await Html.PartialAsync("_ViewInformation", Model) @await Html.PartialAsync("_ViewInformation", Model)
<form asp-action="Delete" asp-route-id="@Model.User.Id" <form asp-action="Delete" asp-route-id="@Model.Id"
onsubmit="return confirm('Are you sure you want to delete this user?')"> onsubmit="return confirm('Are you sure you want to delete this user?')">
<button class="btn btn-danger" type="submit">Delete</button> <button class="btn btn-danger" type="submit">Delete</button>
</form> </form>

View File

@ -1,43 +1,42 @@
@model UserViewModel @model UserViewModel
@inject Bit.Core.Services.IUserService userService
<dl class="row"> <dl class="row">
<dt class="col-sm-4 col-lg-3">Id</dt> <dt class="col-sm-4 col-lg-3">Id</dt>
<dd class="col-sm-8 col-lg-9"><code>@Model.User.Id</code></dd> <dd class="col-sm-8 col-lg-9"><code>@Model.Id</code></dd>
<dt class="col-sm-4 col-lg-3">Premium</dt> <dt class="col-sm-4 col-lg-3">Premium</dt>
<dd class="col-sm-8 col-lg-9">@(Model.User.Premium ? "Yes" : "No")</dd> <dd class="col-sm-8 col-lg-9">@(Model.Premium ? "Yes" : "No")</dd>
<dt class="col-sm-4 col-lg-3">Premium Expires</dt> <dt class="col-sm-4 col-lg-3">Premium Expires</dt>
<dd class="col-sm-8 col-lg-9">@(Model.User.PremiumExpirationDate?.ToString() ?? "-")</dd> <dd class="col-sm-8 col-lg-9">@(Model.PremiumExpirationDate?.ToString() ?? "-")</dd>
<dt class="col-sm-4 col-lg-3">Email Verified</dt> <dt class="col-sm-4 col-lg-3">Email Verified</dt>
<dd class="col-sm-8 col-lg-9">@(Model.User.EmailVerified ? "Yes" : "No")</dd> <dd class="col-sm-8 col-lg-9">@(Model.EmailVerified ? "Yes" : "No")</dd>
<dt class="col-sm-4 col-lg-3">Using 2FA</dt> <dt class="col-sm-4 col-lg-3">Using 2FA</dt>
<dd class="col-sm-8 col-lg-9">@(await userService.TwoFactorIsEnabledAsync(Model.User) ? "Yes" : "No")</dd> <dd class="col-sm-8 col-lg-9">@(Model.TwoFactorEnabled ? "Yes" : "No")</dd>
<dt class="col-sm-4 col-lg-3">Items</dt> <dt class="col-sm-4 col-lg-3">Items</dt>
<dd class="col-sm-8 col-lg-9">@Model.CipherCount</dd> <dd class="col-sm-8 col-lg-9">@Model.CipherCount</dd>
<dt class="col-sm-4 col-lg-3">Vault Modified</dt> <dt class="col-sm-4 col-lg-3">Vault Modified</dt>
<dd class="col-sm-8 col-lg-9">@Model.User.AccountRevisionDate.ToString()</dd> <dd class="col-sm-8 col-lg-9">@Model.AccountRevisionDate.ToString()</dd>
<dt class="col-sm-4 col-lg-3">Created</dt> <dt class="col-sm-4 col-lg-3">Created</dt>
<dd class="col-sm-8 col-lg-9">@Model.User.CreationDate.ToString()</dd> <dd class="col-sm-8 col-lg-9">@Model.CreationDate.ToString()</dd>
<dt class="col-sm-4 col-lg-3">Modified</dt> <dt class="col-sm-4 col-lg-3">Modified</dt>
<dd class="col-sm-8 col-lg-9">@Model.User.RevisionDate.ToString()</dd> <dd class="col-sm-8 col-lg-9">@Model.RevisionDate.ToString()</dd>
<dt class="col-sm-4 col-lg-3">Last Email Address Change</dt> <dt class="col-sm-4 col-lg-3">Last Email Address Change</dt>
<dd class="col-sm-8 col-lg-9">@(Model.User.LastEmailChangeDate?.ToString() ?? "-")</dd> <dd class="col-sm-8 col-lg-9">@(Model.LastEmailChangeDate?.ToString() ?? "-")</dd>
<dt class="col-sm-4 col-lg-3">Last KDF Change</dt> <dt class="col-sm-4 col-lg-3">Last KDF Change</dt>
<dd class="col-sm-8 col-lg-9">@(Model.User.LastKdfChangeDate?.ToString() ?? "-")</dd> <dd class="col-sm-8 col-lg-9">@(Model.LastKdfChangeDate?.ToString() ?? "-")</dd>
<dt class="col-sm-4 col-lg-3">Last Key Rotation</dt> <dt class="col-sm-4 col-lg-3">Last Key Rotation</dt>
<dd class="col-sm-8 col-lg-9">@(Model.User.LastKeyRotationDate?.ToString() ?? "-")</dd> <dd class="col-sm-8 col-lg-9">@(Model.LastKeyRotationDate?.ToString() ?? "-")</dd>
<dt class="col-sm-4 col-lg-3">Last Password Change</dt> <dt class="col-sm-4 col-lg-3">Last Password Change</dt>
<dd class="col-sm-8 col-lg-9">@(Model.User.LastPasswordChangeDate?.ToString() ?? "-")</dd> <dd class="col-sm-8 col-lg-9">@(Model.LastPasswordChangeDate?.ToString() ?? "-")</dd>
</dl> </dl>

View File

@ -13,7 +13,8 @@
"internalApi": "http://localhost:4000", "internalApi": "http://localhost:4000",
"internalVault": "https://localhost:8080", "internalVault": "https://localhost:8080",
"internalSso": "http://localhost:51822", "internalSso": "http://localhost:51822",
"internalScim": "http://localhost:44559" "internalScim": "http://localhost:44559",
"internalBilling": "http://localhost:44519"
}, },
"mail": { "mail": {
"smtp": { "smtp": {

View File

@ -1,12 +1,13 @@
using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Api.Auth.Models.Request.Accounts;
using Bit.Api.Models.Request.Organizations; using Bit.Api.Models.Request.Organizations;
using Bit.Api.Models.Response; using Bit.Api.Models.Response;
using Bit.Api.Vault.AuthorizationHandlers.Collections; using Bit.Api.Vault.AuthorizationHandlers.Collections;
using Bit.Api.Vault.AuthorizationHandlers.OrganizationUsers;
using Bit.Core; using Bit.Core;
using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.Enums; using Bit.Core.Auth.Enums;
@ -44,7 +45,6 @@ public class OrganizationUsersController : Controller
private readonly ICountNewSmSeatsRequiredQuery _countNewSmSeatsRequiredQuery; private readonly ICountNewSmSeatsRequiredQuery _countNewSmSeatsRequiredQuery;
private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand; private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand;
private readonly IUpdateOrganizationUserCommand _updateOrganizationUserCommand; private readonly IUpdateOrganizationUserCommand _updateOrganizationUserCommand;
private readonly IUpdateOrganizationUserGroupsCommand _updateOrganizationUserGroupsCommand;
private readonly IAcceptOrgUserCommand _acceptOrgUserCommand; private readonly IAcceptOrgUserCommand _acceptOrgUserCommand;
private readonly IAuthorizationService _authorizationService; private readonly IAuthorizationService _authorizationService;
private readonly IApplicationCacheService _applicationCacheService; private readonly IApplicationCacheService _applicationCacheService;
@ -52,6 +52,7 @@ public class OrganizationUsersController : Controller
private readonly ISsoConfigRepository _ssoConfigRepository; private readonly ISsoConfigRepository _ssoConfigRepository;
private readonly IOrganizationUserUserDetailsQuery _organizationUserUserDetailsQuery; private readonly IOrganizationUserUserDetailsQuery _organizationUserUserDetailsQuery;
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
private readonly IDeleteManagedOrganizationUserAccountCommand _deleteManagedOrganizationUserAccountCommand;
public OrganizationUsersController( public OrganizationUsersController(
@ -66,14 +67,14 @@ public class OrganizationUsersController : Controller
ICountNewSmSeatsRequiredQuery countNewSmSeatsRequiredQuery, ICountNewSmSeatsRequiredQuery countNewSmSeatsRequiredQuery,
IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand, IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand,
IUpdateOrganizationUserCommand updateOrganizationUserCommand, IUpdateOrganizationUserCommand updateOrganizationUserCommand,
IUpdateOrganizationUserGroupsCommand updateOrganizationUserGroupsCommand,
IAcceptOrgUserCommand acceptOrgUserCommand, IAcceptOrgUserCommand acceptOrgUserCommand,
IAuthorizationService authorizationService, IAuthorizationService authorizationService,
IApplicationCacheService applicationCacheService, IApplicationCacheService applicationCacheService,
IFeatureService featureService, IFeatureService featureService,
ISsoConfigRepository ssoConfigRepository, ISsoConfigRepository ssoConfigRepository,
IOrganizationUserUserDetailsQuery organizationUserUserDetailsQuery, IOrganizationUserUserDetailsQuery organizationUserUserDetailsQuery,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery) ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
IDeleteManagedOrganizationUserAccountCommand deleteManagedOrganizationUserAccountCommand)
{ {
_organizationRepository = organizationRepository; _organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository; _organizationUserRepository = organizationUserRepository;
@ -86,7 +87,6 @@ public class OrganizationUsersController : Controller
_countNewSmSeatsRequiredQuery = countNewSmSeatsRequiredQuery; _countNewSmSeatsRequiredQuery = countNewSmSeatsRequiredQuery;
_updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand; _updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand;
_updateOrganizationUserCommand = updateOrganizationUserCommand; _updateOrganizationUserCommand = updateOrganizationUserCommand;
_updateOrganizationUserGroupsCommand = updateOrganizationUserGroupsCommand;
_acceptOrgUserCommand = acceptOrgUserCommand; _acceptOrgUserCommand = acceptOrgUserCommand;
_authorizationService = authorizationService; _authorizationService = authorizationService;
_applicationCacheService = applicationCacheService; _applicationCacheService = applicationCacheService;
@ -94,6 +94,7 @@ public class OrganizationUsersController : Controller
_ssoConfigRepository = ssoConfigRepository; _ssoConfigRepository = ssoConfigRepository;
_organizationUserUserDetailsQuery = organizationUserUserDetailsQuery; _organizationUserUserDetailsQuery = organizationUserUserDetailsQuery;
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
_deleteManagedOrganizationUserAccountCommand = deleteManagedOrganizationUserAccountCommand;
} }
[HttpGet("{id}")] [HttpGet("{id}")]
@ -115,11 +116,27 @@ public class OrganizationUsersController : Controller
return response; return response;
} }
[HttpGet("mini-details")]
[RequireFeature(FeatureFlagKeys.Pm3478RefactorOrganizationUserApi)]
public async Task<ListResponseModel<OrganizationUserUserMiniDetailsResponseModel>> GetMiniDetails(Guid orgId)
{
var authorizationResult = await _authorizationService.AuthorizeAsync(User, new OrganizationScope(orgId),
OrganizationUserUserMiniDetailsOperations.ReadAll);
if (!authorizationResult.Succeeded)
{
throw new NotFoundException();
}
var organizationUserUserDetails = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(orgId);
return new ListResponseModel<OrganizationUserUserMiniDetailsResponseModel>(
organizationUserUserDetails.Select(ou => new OrganizationUserUserMiniDetailsResponseModel(ou)));
}
[HttpGet("")] [HttpGet("")]
public async Task<ListResponseModel<OrganizationUserUserDetailsResponseModel>> Get(Guid orgId, bool includeGroups = false, bool includeCollections = false) public async Task<ListResponseModel<OrganizationUserUserDetailsResponseModel>> Get(Guid orgId, bool includeGroups = false, bool includeCollections = false)
{ {
var authorized = (await _authorizationService.AuthorizeAsync( var authorized = (await _authorizationService.AuthorizeAsync(
User, OrganizationUserOperations.ReadAll(orgId))).Succeeded; User, new OrganizationScope(orgId), OrganizationUserUserDetailsOperations.ReadAll)).Succeeded;
if (!authorized) if (!authorized)
{ {
throw new NotFoundException(); throw new NotFoundException();
@ -528,6 +545,59 @@ public class OrganizationUsersController : Controller
new OrganizationUserBulkResponseModel(r.Item1.Id, r.Item2))); new OrganizationUserBulkResponseModel(r.Item1.Id, r.Item2)));
} }
[RequireFeature(FeatureFlagKeys.AccountDeprovisioning)]
[HttpDelete("{id}/delete-account")]
[HttpPost("{id}/delete-account")]
public async Task DeleteAccount(Guid orgId, Guid id, [FromBody] SecretVerificationRequestModel model)
{
if (!await _currentContext.ManageUsers(orgId))
{
throw new NotFoundException();
}
var currentUser = await _userService.GetUserByPrincipalAsync(User);
if (currentUser == null)
{
throw new UnauthorizedAccessException();
}
if (!await _userService.VerifySecretAsync(currentUser, model.Secret))
{
await Task.Delay(2000);
throw new BadRequestException(string.Empty, "User verification failed.");
}
await _deleteManagedOrganizationUserAccountCommand.DeleteUserAsync(orgId, id, currentUser.Id);
}
[RequireFeature(FeatureFlagKeys.AccountDeprovisioning)]
[HttpDelete("delete-account")]
[HttpPost("delete-account")]
public async Task<ListResponseModel<OrganizationUserBulkResponseModel>> BulkDeleteAccount(Guid orgId, [FromBody] SecureOrganizationUserBulkRequestModel model)
{
if (!await _currentContext.ManageUsers(orgId))
{
throw new NotFoundException();
}
var currentUser = await _userService.GetUserByPrincipalAsync(User);
if (currentUser == null)
{
throw new UnauthorizedAccessException();
}
if (!await _userService.VerifySecretAsync(currentUser, model.Secret))
{
await Task.Delay(2000);
throw new BadRequestException(string.Empty, "User verification failed.");
}
var results = await _deleteManagedOrganizationUserAccountCommand.DeleteManyUsersAsync(orgId, model.Ids, currentUser.Id);
return new ListResponseModel<OrganizationUserBulkResponseModel>(results.Select(r =>
new OrganizationUserBulkResponseModel(r.OrganizationUserId, r.ErrorMessage)));
}
[HttpPatch("{id}/revoke")] [HttpPatch("{id}/revoke")]
[HttpPut("{id}/revoke")] [HttpPut("{id}/revoke")]
public async Task RevokeAsync(Guid orgId, Guid id) public async Task RevokeAsync(Guid orgId, Guid id)

View File

@ -171,6 +171,21 @@ public class OrganizationsController : Controller
return new OrganizationResponseModel(result.Item1); return new OrganizationResponseModel(result.Item1);
} }
[HttpPost("create-without-payment")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<OrganizationResponseModel> CreateWithoutPaymentAsync([FromBody] OrganizationNoPaymentCreateRequest model)
{
var user = await _userService.GetUserByPrincipalAsync(User);
if (user == null)
{
throw new UnauthorizedAccessException();
}
var organizationSignup = model.ToOrganizationSignup(user);
var result = await _organizationService.SignUpAsync(organizationSignup);
return new OrganizationResponseModel(result.Item1);
}
[HttpPut("{id}")] [HttpPut("{id}")]
[HttpPost("{id}")] [HttpPost("{id}")]
public async Task<OrganizationResponseModel> Put(string id, [FromBody] OrganizationUpdateRequestModel model) public async Task<OrganizationResponseModel> Put(string id, [FromBody] OrganizationUpdateRequestModel model)

View File

@ -14,42 +14,63 @@ public class OrganizationCreateRequestModel : IValidatableObject
[StringLength(50, ErrorMessage = "The field Name exceeds the maximum length.")] [StringLength(50, ErrorMessage = "The field Name exceeds the maximum length.")]
[JsonConverter(typeof(HtmlEncodingStringConverter))] [JsonConverter(typeof(HtmlEncodingStringConverter))]
public string Name { get; set; } public string Name { get; set; }
[StringLength(50, ErrorMessage = "The field Business Name exceeds the maximum length.")] [StringLength(50, ErrorMessage = "The field Business Name exceeds the maximum length.")]
[JsonConverter(typeof(HtmlEncodingStringConverter))] [JsonConverter(typeof(HtmlEncodingStringConverter))]
public string BusinessName { get; set; } public string BusinessName { get; set; }
[Required] [Required]
[StringLength(256)] [StringLength(256)]
[EmailAddress] [EmailAddress]
public string BillingEmail { get; set; } public string BillingEmail { get; set; }
public PlanType PlanType { get; set; } public PlanType PlanType { get; set; }
[Required] [Required]
public string Key { get; set; } public string Key { get; set; }
public OrganizationKeysRequestModel Keys { get; set; } public OrganizationKeysRequestModel Keys { get; set; }
public PaymentMethodType? PaymentMethodType { get; set; } public PaymentMethodType? PaymentMethodType { get; set; }
public string PaymentToken { get; set; } public string PaymentToken { get; set; }
[Range(0, int.MaxValue)] [Range(0, int.MaxValue)]
public int AdditionalSeats { get; set; } public int AdditionalSeats { get; set; }
[Range(0, 99)] [Range(0, 99)]
public short? AdditionalStorageGb { get; set; } public short? AdditionalStorageGb { get; set; }
public bool PremiumAccessAddon { get; set; } public bool PremiumAccessAddon { get; set; }
[EncryptedString] [EncryptedString]
[EncryptedStringLength(1000)] [EncryptedStringLength(1000)]
public string CollectionName { get; set; } public string CollectionName { get; set; }
public string TaxIdNumber { get; set; } public string TaxIdNumber { get; set; }
public string BillingAddressLine1 { get; set; } public string BillingAddressLine1 { get; set; }
public string BillingAddressLine2 { get; set; } public string BillingAddressLine2 { get; set; }
public string BillingAddressCity { get; set; } public string BillingAddressCity { get; set; }
public string BillingAddressState { get; set; } public string BillingAddressState { get; set; }
public string BillingAddressPostalCode { get; set; } public string BillingAddressPostalCode { get; set; }
[StringLength(2)] [StringLength(2)]
public string BillingAddressCountry { get; set; } public string BillingAddressCountry { get; set; }
public int? MaxAutoscaleSeats { get; set; } public int? MaxAutoscaleSeats { get; set; }
[Range(0, int.MaxValue)] [Range(0, int.MaxValue)]
public int? AdditionalSmSeats { get; set; } public int? AdditionalSmSeats { get; set; }
[Range(0, int.MaxValue)] [Range(0, int.MaxValue)]
public int? AdditionalServiceAccounts { get; set; } public int? AdditionalServiceAccounts { get; set; }
[Required] [Required]
public bool UseSecretsManager { get; set; } public bool UseSecretsManager { get; set; }
public bool IsFromSecretsManagerTrial { get; set; } public bool IsFromSecretsManagerTrial { get; set; }
public string InitiationPath { get; set; } public string InitiationPath { get; set; }
@ -99,16 +120,19 @@ public class OrganizationCreateRequestModel : IValidatableObject
{ {
yield return new ValidationResult("Payment required.", new string[] { nameof(PaymentToken) }); yield return new ValidationResult("Payment required.", new string[] { nameof(PaymentToken) });
} }
if (PlanType != PlanType.Free && !PaymentMethodType.HasValue) if (PlanType != PlanType.Free && !PaymentMethodType.HasValue)
{ {
yield return new ValidationResult("Payment method type required.", yield return new ValidationResult("Payment method type required.",
new string[] { nameof(PaymentMethodType) }); new string[] { nameof(PaymentMethodType) });
} }
if (PlanType != PlanType.Free && string.IsNullOrWhiteSpace(BillingAddressCountry)) if (PlanType != PlanType.Free && string.IsNullOrWhiteSpace(BillingAddressCountry))
{ {
yield return new ValidationResult("Country required.", yield return new ValidationResult("Country required.",
new string[] { nameof(BillingAddressCountry) }); new string[] { nameof(BillingAddressCountry) });
} }
if (PlanType != PlanType.Free && BillingAddressCountry == "US" && if (PlanType != PlanType.Free && BillingAddressCountry == "US" &&
string.IsNullOrWhiteSpace(BillingAddressPostalCode)) string.IsNullOrWhiteSpace(BillingAddressPostalCode))
{ {
@ -117,3 +141,4 @@ public class OrganizationCreateRequestModel : IValidatableObject
} }
} }
} }

View File

@ -0,0 +1,116 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
using Bit.Core.Billing.Enums;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Business;
using Bit.Core.Utilities;
namespace Bit.Api.AdminConsole.Models.Request.Organizations;
public class OrganizationNoPaymentCreateRequest
{
[Required]
[StringLength(50, ErrorMessage = "The field Name exceeds the maximum length.")]
[JsonConverter(typeof(HtmlEncodingStringConverter))]
public string Name { get; set; }
[StringLength(50, ErrorMessage = "The field Business Name exceeds the maximum length.")]
[JsonConverter(typeof(HtmlEncodingStringConverter))]
public string BusinessName { get; set; }
[Required]
[StringLength(256)]
[EmailAddress]
public string BillingEmail { get; set; }
public PlanType PlanType { get; set; }
[Required]
public string Key { get; set; }
public OrganizationKeysRequestModel Keys { get; set; }
public PaymentMethodType? PaymentMethodType { get; set; }
public string PaymentToken { get; set; }
[Range(0, int.MaxValue)]
public int AdditionalSeats { get; set; }
[Range(0, 99)]
public short? AdditionalStorageGb { get; set; }
public bool PremiumAccessAddon { get; set; }
[EncryptedString]
[EncryptedStringLength(1000)]
public string CollectionName { get; set; }
public string TaxIdNumber { get; set; }
public string BillingAddressLine1 { get; set; }
public string BillingAddressLine2 { get; set; }
public string BillingAddressCity { get; set; }
public string BillingAddressState { get; set; }
public string BillingAddressPostalCode { get; set; }
[StringLength(2)]
public string BillingAddressCountry { get; set; }
public int? MaxAutoscaleSeats { get; set; }
[Range(0, int.MaxValue)]
public int? AdditionalSmSeats { get; set; }
[Range(0, int.MaxValue)]
public int? AdditionalServiceAccounts { get; set; }
[Required]
public bool UseSecretsManager { get; set; }
public bool IsFromSecretsManagerTrial { get; set; }
public string InitiationPath { get; set; }
public virtual OrganizationSignup ToOrganizationSignup(User user)
{
var orgSignup = new OrganizationSignup
{
Owner = user,
OwnerKey = Key,
Name = Name,
Plan = PlanType,
PaymentMethodType = PaymentMethodType,
PaymentToken = PaymentToken,
AdditionalSeats = AdditionalSeats,
MaxAutoscaleSeats = MaxAutoscaleSeats,
AdditionalStorageGb = AdditionalStorageGb.GetValueOrDefault(0),
PremiumAccessAddon = PremiumAccessAddon,
BillingEmail = BillingEmail,
BusinessName = BusinessName,
CollectionName = CollectionName,
AdditionalSmSeats = AdditionalSmSeats.GetValueOrDefault(),
AdditionalServiceAccounts = AdditionalServiceAccounts.GetValueOrDefault(),
UseSecretsManager = UseSecretsManager,
IsFromSecretsManagerTrial = IsFromSecretsManagerTrial,
TaxInfo = new TaxInfo
{
TaxIdNumber = TaxIdNumber,
BillingAddressLine1 = BillingAddressLine1,
BillingAddressLine2 = BillingAddressLine2,
BillingAddressCity = BillingAddressCity,
BillingAddressState = BillingAddressState,
BillingAddressPostalCode = BillingAddressPostalCode,
BillingAddressCountry = BillingAddressCountry,
},
InitiationPath = InitiationPath,
};
Keys?.ToOrganizationSignup(orgSignup);
return orgSignup;
}
}

View File

@ -0,0 +1,10 @@
using System.ComponentModel.DataAnnotations;
using Bit.Api.Auth.Models.Request.Accounts;
namespace Bit.Api.AdminConsole.Models.Request.Organizations;
public class SecureOrganizationUserBulkRequestModel : SecretVerificationRequestModel
{
[Required]
public IEnumerable<Guid> Ids { get; set; }
}

View File

@ -84,6 +84,29 @@ public class OrganizationUserDetailsResponseModel : OrganizationUserResponseMode
public IEnumerable<Guid> Groups { get; set; } public IEnumerable<Guid> Groups { get; set; }
} }
#nullable enable
public class OrganizationUserUserMiniDetailsResponseModel : ResponseModel
{
public OrganizationUserUserMiniDetailsResponseModel(OrganizationUserUserDetails organizationUser)
: base("organizationUserUserMiniDetails")
{
Id = organizationUser.Id;
UserId = organizationUser.UserId;
Type = organizationUser.Type;
Status = organizationUser.Status;
Name = organizationUser.Name;
Email = organizationUser.Email;
}
public Guid Id { get; }
public Guid? UserId { get; }
public OrganizationUserType Type { get; }
public OrganizationUserStatusType Status { get; }
public string? Name { get; }
public string Email { get; }
}
#nullable disable
public class OrganizationUserUserDetailsResponseModel : OrganizationUserResponseModel public class OrganizationUserUserDetailsResponseModel : OrganizationUserResponseModel
{ {
public OrganizationUserUserDetailsResponseModel(OrganizationUserUserDetails organizationUser, public OrganizationUserUserDetailsResponseModel(OrganizationUserUserDetails organizationUser,

View File

@ -35,7 +35,7 @@
<PackageReference Include="AspNetCore.HealthChecks.SqlServer" Version="8.0.0" /> <PackageReference Include="AspNetCore.HealthChecks.SqlServer" Version="8.0.0" />
<PackageReference Include="AspNetCore.HealthChecks.Uris" Version="8.0.0" /> <PackageReference Include="AspNetCore.HealthChecks.Uris" Version="8.0.0" />
<PackageReference Include="Azure.Messaging.EventGrid" Version="4.25.0" /> <PackageReference Include="Azure.Messaging.EventGrid" Version="4.25.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.7.3" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="6.8.0" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@ -443,10 +443,11 @@ public class AccountsController : Controller
var twoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user); var twoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user);
var hasPremiumFromOrg = await _userService.HasPremiumFromOrganization(user); var hasPremiumFromOrg = await _userService.HasPremiumFromOrganization(user);
var managedByOrganizationId = await GetManagedByOrganizationIdAsync(user);
var response = new ProfileResponseModel(user, organizationUserDetails, providerUserDetails, var response = new ProfileResponseModel(user, organizationUserDetails, providerUserDetails,
providerUserOrganizationDetails, twoFactorEnabled, providerUserOrganizationDetails, twoFactorEnabled,
hasPremiumFromOrg); hasPremiumFromOrg, managedByOrganizationId);
return response; return response;
} }
@ -471,7 +472,12 @@ public class AccountsController : Controller
} }
await _userService.SaveUserAsync(model.ToUser(user)); await _userService.SaveUserAsync(model.ToUser(user));
var response = new ProfileResponseModel(user, null, null, null, await _userService.TwoFactorIsEnabledAsync(user), await _userService.HasPremiumFromOrganization(user));
var twoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user);
var hasPremiumFromOrg = await _userService.HasPremiumFromOrganization(user);
var managedByOrganizationId = await GetManagedByOrganizationIdAsync(user);
var response = new ProfileResponseModel(user, null, null, null, twoFactorEnabled, hasPremiumFromOrg, managedByOrganizationId);
return response; return response;
} }
@ -485,7 +491,12 @@ public class AccountsController : Controller
throw new UnauthorizedAccessException(); throw new UnauthorizedAccessException();
} }
await _userService.SaveUserAsync(model.ToUser(user), true); await _userService.SaveUserAsync(model.ToUser(user), true);
var response = new ProfileResponseModel(user, null, null, null, await _userService.TwoFactorIsEnabledAsync(user), await _userService.HasPremiumFromOrganization(user));
var userTwoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user);
var userHasPremiumFromOrganization = await _userService.HasPremiumFromOrganization(user);
var managedByOrganizationId = await GetManagedByOrganizationIdAsync(user);
var response = new ProfileResponseModel(user, null, null, null, userTwoFactorEnabled, userHasPremiumFromOrganization, managedByOrganizationId);
return response; return response;
} }
@ -633,7 +644,12 @@ public class AccountsController : Controller
BillingAddressCountry = model.Country, BillingAddressCountry = model.Country,
BillingAddressPostalCode = model.PostalCode, BillingAddressPostalCode = model.PostalCode,
}); });
var profile = new ProfileResponseModel(user, null, null, null, await _userService.TwoFactorIsEnabledAsync(user), await _userService.HasPremiumFromOrganization(user));
var userTwoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user);
var userHasPremiumFromOrganization = await _userService.HasPremiumFromOrganization(user);
var managedByOrganizationId = await GetManagedByOrganizationIdAsync(user);
var profile = new ProfileResponseModel(user, null, null, null, userTwoFactorEnabled, userHasPremiumFromOrganization, managedByOrganizationId);
return new PaymentResponseModel return new PaymentResponseModel
{ {
UserProfile = profile, UserProfile = profile,
@ -920,4 +936,15 @@ public class AccountsController : Controller
throw new BadRequestException("Token", "Invalid token"); throw new BadRequestException("Token", "Invalid token");
} }
} }
private async Task<Guid?> GetManagedByOrganizationIdAsync(User user)
{
if (!_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning))
{
return null;
}
var organizationManagingUser = await _userService.GetOrganizationManagingUserAsync(user.Id);
return organizationManagingUser?.Id;
}
} }

View File

@ -14,7 +14,8 @@ public class ProfileResponseModel : ResponseModel
IEnumerable<ProviderUserProviderDetails> providerUserDetails, IEnumerable<ProviderUserProviderDetails> providerUserDetails,
IEnumerable<ProviderUserOrganizationDetails> providerUserOrganizationDetails, IEnumerable<ProviderUserOrganizationDetails> providerUserOrganizationDetails,
bool twoFactorEnabled, bool twoFactorEnabled,
bool premiumFromOrganization) : base("profile") bool premiumFromOrganization,
Guid? managedByOrganizationId) : base("profile")
{ {
if (user == null) if (user == null)
{ {
@ -40,6 +41,7 @@ public class ProfileResponseModel : ResponseModel
Providers = providerUserDetails?.Select(p => new ProfileProviderResponseModel(p)); Providers = providerUserDetails?.Select(p => new ProfileProviderResponseModel(p));
ProviderOrganizations = ProviderOrganizations =
providerUserOrganizationDetails?.Select(po => new ProfileProviderOrganizationResponseModel(po)); providerUserOrganizationDetails?.Select(po => new ProfileProviderOrganizationResponseModel(po));
ManagedByOrganizationId = managedByOrganizationId;
} }
public ProfileResponseModel() : base("profile") public ProfileResponseModel() : base("profile")
@ -61,6 +63,7 @@ public class ProfileResponseModel : ResponseModel
public bool UsesKeyConnector { get; set; } public bool UsesKeyConnector { get; set; }
public string AvatarColor { get; set; } public string AvatarColor { get; set; }
public DateTime CreationDate { get; set; } public DateTime CreationDate { get; set; }
public Guid? ManagedByOrganizationId { get; set; }
public IEnumerable<ProfileOrganizationResponseModel> Organizations { get; set; } public IEnumerable<ProfileOrganizationResponseModel> Organizations { get; set; }
public IEnumerable<ProfileProviderResponseModel> Providers { get; set; } public IEnumerable<ProfileProviderResponseModel> Providers { get; set; }
public IEnumerable<ProfileProviderOrganizationResponseModel> ProviderOrganizations { get; set; } public IEnumerable<ProfileProviderOrganizationResponseModel> ProviderOrganizations { get; set; }

View File

@ -1,6 +1,5 @@
using Bit.Api.Vault.AuthorizationHandlers.Collections; using Bit.Api.Vault.AuthorizationHandlers.Collections;
using Bit.Api.Vault.AuthorizationHandlers.Groups; using Bit.Api.Vault.AuthorizationHandlers.Groups;
using Bit.Api.Vault.AuthorizationHandlers.OrganizationUsers;
using Bit.Core.IdentityServer; using Bit.Core.IdentityServer;
using Bit.Core.Settings; using Bit.Core.Settings;
using Bit.Core.Utilities; using Bit.Core.Utilities;
@ -100,6 +99,5 @@ public static class ServiceCollectionExtensions
services.AddScoped<IAuthorizationHandler, BulkCollectionAuthorizationHandler>(); services.AddScoped<IAuthorizationHandler, BulkCollectionAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, CollectionAuthorizationHandler>(); services.AddScoped<IAuthorizationHandler, CollectionAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, GroupAuthorizationHandler>(); services.AddScoped<IAuthorizationHandler, GroupAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, OrganizationUserAuthorizationHandler>();
} }
} }

View File

@ -1,60 +0,0 @@
#nullable enable
using Bit.Core.Context;
using Microsoft.AspNetCore.Authorization;
namespace Bit.Api.Vault.AuthorizationHandlers.OrganizationUsers;
/// <summary>
/// Handles authorization logic for OrganizationUser objects.
/// This uses new logic implemented in the Flexible Collections initiative.
/// </summary>
public class OrganizationUserAuthorizationHandler : AuthorizationHandler<OrganizationUserOperationRequirement>
{
private readonly ICurrentContext _currentContext;
public OrganizationUserAuthorizationHandler(ICurrentContext currentContext)
{
_currentContext = currentContext;
}
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context,
OrganizationUserOperationRequirement requirement)
{
if (!_currentContext.UserId.HasValue)
{
context.Fail();
return;
}
if (requirement.OrganizationId == default)
{
context.Fail();
return;
}
var org = _currentContext.GetOrganization(requirement.OrganizationId);
switch (requirement)
{
case not null when requirement.Name == nameof(OrganizationUserOperations.ReadAll):
await CanReadAllAsync(context, requirement, org);
break;
}
}
private async Task CanReadAllAsync(AuthorizationHandlerContext context, OrganizationUserOperationRequirement requirement,
CurrentContextOrganization? org)
{
// All users of an organization can read all other users of that organization for collection access management
if (org is not null)
{
context.Succeed(requirement);
}
// Allow provider users to read all organization users if they are a provider for the target organization
if (await _currentContext.ProviderUserForOrgAsync(requirement.OrganizationId))
{
context.Succeed(requirement);
}
}
}

View File

@ -1,22 +0,0 @@
using Microsoft.AspNetCore.Authorization.Infrastructure;
namespace Bit.Api.Vault.AuthorizationHandlers.OrganizationUsers;
public class OrganizationUserOperationRequirement : OperationAuthorizationRequirement
{
public Guid OrganizationId { get; }
public OrganizationUserOperationRequirement(string name, Guid organizationId)
{
Name = name;
OrganizationId = organizationId;
}
}
public static class OrganizationUserOperations
{
public static OrganizationUserOperationRequirement ReadAll(Guid organizationId)
{
return new OrganizationUserOperationRequirement(nameof(ReadAll), organizationId);
}
}

View File

@ -1,4 +1,5 @@
using Bit.Api.Vault.Models.Response; using Bit.Api.Vault.Models.Response;
using Bit.Core;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
@ -6,6 +7,7 @@ using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Models.Data; using Bit.Core.Models.Data;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Settings; using Bit.Core.Settings;
@ -30,6 +32,7 @@ public class SyncController : Controller
private readonly IPolicyRepository _policyRepository; private readonly IPolicyRepository _policyRepository;
private readonly ISendRepository _sendRepository; private readonly ISendRepository _sendRepository;
private readonly GlobalSettings _globalSettings; private readonly GlobalSettings _globalSettings;
private readonly IFeatureService _featureService;
public SyncController( public SyncController(
IUserService userService, IUserService userService,
@ -41,7 +44,8 @@ public class SyncController : Controller
IProviderUserRepository providerUserRepository, IProviderUserRepository providerUserRepository,
IPolicyRepository policyRepository, IPolicyRepository policyRepository,
ISendRepository sendRepository, ISendRepository sendRepository,
GlobalSettings globalSettings) GlobalSettings globalSettings,
IFeatureService featureService)
{ {
_userService = userService; _userService = userService;
_folderRepository = folderRepository; _folderRepository = folderRepository;
@ -53,6 +57,7 @@ public class SyncController : Controller
_policyRepository = policyRepository; _policyRepository = policyRepository;
_sendRepository = sendRepository; _sendRepository = sendRepository;
_globalSettings = globalSettings; _globalSettings = globalSettings;
_featureService = featureService;
} }
[HttpGet("")] [HttpGet("")]
@ -90,9 +95,23 @@ public class SyncController : Controller
var userTwoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user); var userTwoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user);
var userHasPremiumFromOrganization = await _userService.HasPremiumFromOrganization(user); var userHasPremiumFromOrganization = await _userService.HasPremiumFromOrganization(user);
var response = new SyncResponseModel(_globalSettings, user, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationUserDetails, var managedByOrganizationId = await GetManagedByOrganizationIdAsync(user, organizationUserDetails);
providerUserDetails, providerUserOrganizationDetails, folders, collections, ciphers,
collectionCiphersGroupDict, excludeDomains, policies, sends); var response = new SyncResponseModel(_globalSettings, user, userTwoFactorEnabled, userHasPremiumFromOrganization,
managedByOrganizationId, organizationUserDetails, providerUserDetails, providerUserOrganizationDetails,
folders, collections, ciphers, collectionCiphersGroupDict, excludeDomains, policies, sends);
return response; return response;
} }
private async Task<Guid?> GetManagedByOrganizationIdAsync(User user, IEnumerable<OrganizationUserOrganizationDetails> organizationUserDetails)
{
if (!_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) ||
!organizationUserDetails.Any(o => o.Enabled && o.UseSso))
{
return null;
}
var organizationManagingUser = await _userService.GetOrganizationManagingUserAsync(user.Id);
return organizationManagingUser?.Id;
}
} }

View File

@ -21,6 +21,7 @@ public class SyncResponseModel : ResponseModel
User user, User user,
bool userTwoFactorEnabled, bool userTwoFactorEnabled,
bool userHasPremiumFromOrganization, bool userHasPremiumFromOrganization,
Guid? managedByOrganizationId,
IEnumerable<OrganizationUserOrganizationDetails> organizationUserDetails, IEnumerable<OrganizationUserOrganizationDetails> organizationUserDetails,
IEnumerable<ProviderUserProviderDetails> providerUserDetails, IEnumerable<ProviderUserProviderDetails> providerUserDetails,
IEnumerable<ProviderUserOrganizationDetails> providerUserOrganizationDetails, IEnumerable<ProviderUserOrganizationDetails> providerUserOrganizationDetails,
@ -34,7 +35,7 @@ public class SyncResponseModel : ResponseModel
: base("sync") : base("sync")
{ {
Profile = new ProfileResponseModel(user, organizationUserDetails, providerUserDetails, Profile = new ProfileResponseModel(user, organizationUserDetails, providerUserDetails,
providerUserOrganizationDetails, userTwoFactorEnabled, userHasPremiumFromOrganization); providerUserOrganizationDetails, userTwoFactorEnabled, userHasPremiumFromOrganization, managedByOrganizationId);
Folders = folders.Select(f => new FolderResponseModel(f)); Folders = folders.Select(f => new FolderResponseModel(f));
Ciphers = ciphers.Select(c => new CipherDetailsResponseModel(c, globalSettings, collectionCiphersDict)); Ciphers = ciphers.Select(c => new CipherDetailsResponseModel(c, globalSettings, collectionCiphersDict));
Collections = collections?.Select( Collections = collections?.Select(

View File

@ -0,0 +1,68 @@
using Bit.Billing.Models.Recovery;
using Bit.Billing.Services;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Stripe;
namespace Bit.Billing.Controllers;
[Route("stripe/recovery")]
[SelfHosted(NotSelfHostedOnly = true)]
public class RecoveryController(
IStripeEventProcessor stripeEventProcessor,
IStripeFacade stripeFacade,
IWebHostEnvironment webHostEnvironment) : Controller
{
private readonly string _stripeURL = webHostEnvironment.IsDevelopment() || webHostEnvironment.IsEnvironment("QA")
? "https://dashboard.stripe.com/test"
: "https://dashboard.stripe.com";
// ReSharper disable once RouteTemplates.ActionRoutePrefixCanBeExtractedToControllerRoute
[HttpPost("events/inspect")]
public async Task<Ok<EventsResponseBody>> InspectEventsAsync([FromBody] EventsRequestBody requestBody)
{
var inspected = await Task.WhenAll(requestBody.EventIds.Select(async eventId =>
{
var @event = await stripeFacade.GetEvent(eventId);
return Map(@event);
}));
var response = new EventsResponseBody { Events = inspected.ToList() };
return TypedResults.Ok(response);
}
// ReSharper disable once RouteTemplates.ActionRoutePrefixCanBeExtractedToControllerRoute
[HttpPost("events/process")]
public async Task<Ok<EventsResponseBody>> ProcessEventsAsync([FromBody] EventsRequestBody requestBody)
{
var processed = await Task.WhenAll(requestBody.EventIds.Select(async eventId =>
{
var @event = await stripeFacade.GetEvent(eventId);
try
{
await stripeEventProcessor.ProcessEventAsync(@event);
return Map(@event);
}
catch (Exception exception)
{
return Map(@event, exception.Message);
}
}));
var response = new EventsResponseBody { Events = processed.ToList() };
return TypedResults.Ok(response);
}
private EventResponseBody Map(Event @event, string processingError = null) => new()
{
Id = @event.Id,
URL = $"{_stripeURL}/workbench/events/{@event.Id}",
APIVersion = @event.ApiVersion,
Type = @event.Type,
CreatedUTC = @event.Created,
ProcessingError = processingError
};
}

View File

@ -0,0 +1,9 @@
using System.Text.Json.Serialization;
namespace Bit.Billing.Models.Recovery;
public class EventsRequestBody
{
[JsonPropertyName("eventIds")]
public List<string> EventIds { get; set; }
}

View File

@ -0,0 +1,31 @@
using System.Text.Json.Serialization;
namespace Bit.Billing.Models.Recovery;
public class EventsResponseBody
{
[JsonPropertyName("events")]
public List<EventResponseBody> Events { get; set; }
}
public class EventResponseBody
{
[JsonPropertyName("id")]
public string Id { get; set; }
[JsonPropertyName("url")]
public string URL { get; set; }
[JsonPropertyName("apiVersion")]
public string APIVersion { get; set; }
[JsonPropertyName("type")]
public string Type { get; set; }
[JsonPropertyName("createdUTC")]
public DateTime CreatedUTC { get; set; }
[JsonPropertyName("processingError")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string ProcessingError { get; set; }
}

View File

@ -16,6 +16,12 @@ public interface IStripeFacade
RequestOptions requestOptions = null, RequestOptions requestOptions = null,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
Task<Event> GetEvent(
string eventId,
EventGetOptions eventGetOptions = null,
RequestOptions requestOptions = null,
CancellationToken cancellationToken = default);
Task<Invoice> GetInvoice( Task<Invoice> GetInvoice(
string invoiceId, string invoiceId,
InvoiceGetOptions invoiceGetOptions = null, InvoiceGetOptions invoiceGetOptions = null,

View File

@ -2,34 +2,63 @@
namespace Bit.Billing.Services.Implementations; namespace Bit.Billing.Services.Implementations;
public class InvoiceCreatedHandler : IInvoiceCreatedHandler public class InvoiceCreatedHandler(
{ ILogger<InvoiceCreatedHandler> logger,
private readonly IStripeEventService _stripeEventService;
private readonly IStripeEventUtilityService _stripeEventUtilityService;
private readonly IProviderEventService _providerEventService;
public InvoiceCreatedHandler(
IStripeEventService stripeEventService, IStripeEventService stripeEventService,
IStripeEventUtilityService stripeEventUtilityService, IStripeEventUtilityService stripeEventUtilityService,
IProviderEventService providerEventService) IProviderEventService providerEventService)
: IInvoiceCreatedHandler
{ {
_stripeEventService = stripeEventService;
_stripeEventUtilityService = stripeEventUtilityService;
_providerEventService = providerEventService;
}
/// <summary> /// <summary>
/// Handles the <see cref="HandledStripeWebhook.InvoiceCreated"/> event type from Stripe. /// <para>
/// This handler processes the `invoice.created` event in <see href="https://docs.stripe.com/api/events/types#event_types-invoice.created">Stripe</see>. It has
/// two primary responsibilities.
/// </para>
/// <para>
/// 1. Checks to see if the newly created invoice belongs to a PayPal customer. If it does, and the invoice is ready to be paid, it will attempt to pay the invoice
/// with Braintree and then let Stripe know the invoice can be marked as paid.
/// </para>
/// <para>
/// 2. If the invoice is for a provider, it records a point-in-time snapshot of the invoice broken down by the provider's client organizations. This is later used in
/// the provider invoice export.
/// </para>
/// </summary> /// </summary>
/// <param name="parsedEvent"></param>
public async Task HandleAsync(Event parsedEvent) public async Task HandleAsync(Event parsedEvent)
{ {
var invoice = await _stripeEventService.GetInvoice(parsedEvent, true); try
if (_stripeEventUtilityService.ShouldAttemptToPayInvoice(invoice))
{ {
await _stripeEventUtilityService.AttemptToPayInvoiceAsync(invoice); var invoice = await stripeEventService.GetInvoice(parsedEvent, true, ["customer"]);
var usingPayPal = invoice.Customer?.Metadata.ContainsKey("btCustomerId") ?? false;
if (usingPayPal && invoice is
{
AmountDue: > 0,
Paid: false,
CollectionMethod: "charge_automatically",
BillingReason:
"subscription_create" or
"subscription_cycle" or
"automatic_pending_invoice_item_invoice",
SubscriptionId: not null and not ""
})
{
await stripeEventUtilityService.AttemptToPayInvoiceAsync(invoice);
}
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to attempt paying for invoice while handling 'invoice.created' event ({EventID})", parsedEvent.Id);
} }
await _providerEventService.TryRecordInvoiceLineItems(parsedEvent); try
{
await providerEventService.TryRecordInvoiceLineItems(parsedEvent);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to record provider invoice line items while handling 'invoice.created' event ({EventID})", parsedEvent.Id);
}
} }
} }

View File

@ -206,6 +206,12 @@ public class StripeEventUtilityService : IStripeEventUtilityService
transaction.PaymentMethodType = PaymentMethodType.Card; transaction.PaymentMethodType = PaymentMethodType.Card;
transaction.Details = $"{card.Brand?.ToUpperInvariant()}, *{card.Last4}"; transaction.Details = $"{card.Brand?.ToUpperInvariant()}, *{card.Last4}";
} }
else if (charge.PaymentMethodDetails.UsBankAccount != null)
{
var usBankAccount = charge.PaymentMethodDetails.UsBankAccount;
transaction.PaymentMethodType = PaymentMethodType.BankAccount;
transaction.Details = $"{usBankAccount.BankName}, *{usBankAccount.Last4}";
}
else if (charge.PaymentMethodDetails.AchDebit != null) else if (charge.PaymentMethodDetails.AchDebit != null)
{ {
var achDebit = charge.PaymentMethodDetails.AchDebit; var achDebit = charge.PaymentMethodDetails.AchDebit;

View File

@ -6,6 +6,7 @@ public class StripeFacade : IStripeFacade
{ {
private readonly ChargeService _chargeService = new(); private readonly ChargeService _chargeService = new();
private readonly CustomerService _customerService = new(); private readonly CustomerService _customerService = new();
private readonly EventService _eventService = new();
private readonly InvoiceService _invoiceService = new(); private readonly InvoiceService _invoiceService = new();
private readonly PaymentMethodService _paymentMethodService = new(); private readonly PaymentMethodService _paymentMethodService = new();
private readonly SubscriptionService _subscriptionService = new(); private readonly SubscriptionService _subscriptionService = new();
@ -19,6 +20,13 @@ public class StripeFacade : IStripeFacade
CancellationToken cancellationToken = default) => CancellationToken cancellationToken = default) =>
await _chargeService.GetAsync(chargeId, chargeGetOptions, requestOptions, cancellationToken); await _chargeService.GetAsync(chargeId, chargeGetOptions, requestOptions, cancellationToken);
public async Task<Event> GetEvent(
string eventId,
EventGetOptions eventGetOptions = null,
RequestOptions requestOptions = null,
CancellationToken cancellationToken = default) =>
await _eventService.GetAsync(eventId, eventGetOptions, requestOptions, cancellationToken);
public async Task<Customer> GetCustomer( public async Task<Customer> GetCustomer(
string customerId, string customerId,
CustomerGetOptions customerGetOptions = null, CustomerGetOptions customerGetOptions = null,

View File

@ -32,12 +32,14 @@ public class SubscriptionDeletedHandler : ISubscriptionDeletedHandler
var (organizationId, userId, providerId) = _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata); var (organizationId, userId, providerId) = _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata);
var subCanceled = subscription.Status == StripeSubscriptionStatus.Canceled; var subCanceled = subscription.Status == StripeSubscriptionStatus.Canceled;
const string providerMigrationCancellationComment = "Cancelled as part of provider migration to Consolidated Billing";
if (!subCanceled) if (!subCanceled)
{ {
return; return;
} }
if (organizationId.HasValue) if (organizationId.HasValue && subscription is not { CancellationDetails.Comment: providerMigrationCancellationComment })
{ {
await _organizationService.DisableAsync(organizationId.Value, subscription.CurrentPeriodEnd); await _organizationService.DisableAsync(organizationId.Value, subscription.CurrentPeriodEnd);
} }

View File

@ -94,6 +94,7 @@ public class Organization : ITableObject<Guid>, IStorableSubscriber, IRevisable,
/// they have Can Manage permissions for. /// they have Can Manage permissions for.
/// </summary> /// </summary>
public bool LimitCollectionCreationDeletion { get; set; } public bool LimitCollectionCreationDeletion { get; set; }
/// <summary> /// <summary>
/// If set to true, admins, owners, and some custom users can read/write all collections and items in the Admin Console. /// If set to true, admins, owners, and some custom users can read/write all collections and items in the Admin Console.
/// If set to false, users generally need collection-level permissions to read/write a collection or its items. /// If set to false, users generally need collection-level permissions to read/write a collection or its items.

View File

@ -9,4 +9,5 @@ public enum ScimProviderType : byte
JumpCloud = 4, JumpCloud = 4,
GoogleWorkspace = 5, GoogleWorkspace = 5,
Rippling = 6, Rippling = 6,
Ping = 7,
} }

View File

@ -0,0 +1,23 @@
#nullable enable
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
/// <summary>
/// A typed wrapper for an organization Guid. This is used for authorization checks
/// scoped to an organization's resources (e.g. all users for an organization).
/// In these cases, AuthorizationService needs more than just a Guid, but we also don't want to fetch the
/// Organization object from the database each time when it's usually not needed.
/// This should not be used for operations on the organization itself.
/// It implicitly converts to a regular Guid.
/// </summary>
public record OrganizationScope
{
public OrganizationScope(Guid id)
{
Id = id;
}
private Guid Id { get; }
public static implicit operator Guid(OrganizationScope organizationScope) =>
organizationScope.Id;
public override string ToString() => Id.ToString();
}

View File

@ -0,0 +1,77 @@
#nullable enable
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Services;
using Microsoft.AspNetCore.Authorization;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
public class OrganizationUserUserDetailsAuthorizationHandler
: AuthorizationHandler<OrganizationUserUserDetailsOperationRequirement, OrganizationScope>
{
private readonly ICurrentContext _currentContext;
private readonly IFeatureService _featureService;
public OrganizationUserUserDetailsAuthorizationHandler(ICurrentContext currentContext, IFeatureService featureService)
{
_currentContext = currentContext;
_featureService = featureService;
}
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context,
OrganizationUserUserDetailsOperationRequirement requirement, OrganizationScope organizationScope)
{
var authorized = false;
switch (requirement)
{
case not null when requirement.Name == nameof(OrganizationUserUserDetailsOperations.ReadAll):
authorized = await CanReadAllAsync(organizationScope);
break;
}
if (authorized)
{
context.Succeed(requirement!);
}
}
private async Task<bool> CanReadAllAsync(Guid organizationId)
{
if (_featureService.IsEnabled(FeatureFlagKeys.Pm3478RefactorOrganizationUserApi))
{
return await CanReadAllAsync_vNext(organizationId);
}
return await CanReadAllAsync_vCurrent(organizationId);
}
private async Task<bool> CanReadAllAsync_vCurrent(Guid organizationId)
{
// All users of an organization can read all other users of that organization for collection access management
var org = _currentContext.GetOrganization(organizationId);
if (org is not null)
{
return true;
}
// Allow provider users to read all organization users if they are a provider for the target organization
return await _currentContext.ProviderUserForOrgAsync(organizationId);
}
private async Task<bool> CanReadAllAsync_vNext(Guid organizationId)
{
// Admins can access this for general user management
var organization = _currentContext.GetOrganization(organizationId);
if (organization is
{ Type: OrganizationUserType.Owner } or
{ Type: OrganizationUserType.Admin } or
{ Permissions.ManageUsers: true })
{
return true;
}
// Allow provider users to read all organization users if they are a provider for the target organization
return await _currentContext.ProviderUserForOrgAsync(organizationId);
}
}

View File

@ -0,0 +1,10 @@
using Microsoft.AspNetCore.Authorization.Infrastructure;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
public class OrganizationUserUserDetailsOperationRequirement : OperationAuthorizationRequirement;
public static class OrganizationUserUserDetailsOperations
{
public static OrganizationUserUserDetailsOperationRequirement ReadAll = new() { Name = nameof(ReadAll) };
}

View File

@ -0,0 +1,51 @@
using Bit.Core.Context;
using Bit.Core.Services;
using Microsoft.AspNetCore.Authorization;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
public class OrganizationUserUserMiniDetailsAuthorizationHandler :
AuthorizationHandler<OrganizationUserUserMiniDetailsOperationRequirement, OrganizationScope>
{
private readonly IApplicationCacheService _applicationCacheService;
private readonly ICurrentContext _currentContext;
public OrganizationUserUserMiniDetailsAuthorizationHandler(
IApplicationCacheService applicationCacheService,
ICurrentContext currentContext)
{
_applicationCacheService = applicationCacheService;
_currentContext = currentContext;
}
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context,
OrganizationUserUserMiniDetailsOperationRequirement requirement, OrganizationScope organizationScope)
{
var authorized = false;
switch (requirement)
{
case not null when requirement.Name == nameof(OrganizationUserUserMiniDetailsOperations.ReadAll):
authorized = await CanReadAllAsync(organizationScope);
break;
}
if (authorized)
{
context.Succeed(requirement);
}
}
private async Task<bool> CanReadAllAsync(Guid organizationId)
{
// All organization users can access this data to manage collection access
var organization = _currentContext.GetOrganization(organizationId);
if (organization != null)
{
return true;
}
// Providers can also access this to manage the organization generally
return await _currentContext.ProviderUserForOrgAsync(organizationId);
}
}

View File

@ -0,0 +1,10 @@
using Microsoft.AspNetCore.Authorization.Infrastructure;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
public class OrganizationUserUserMiniDetailsOperationRequirement : OperationAuthorizationRequirement;
public static class OrganizationUserUserMiniDetailsOperations
{
public static readonly OrganizationUserUserMiniDetailsOperationRequirement ReadAll = new() { Name = nameof(ReadAll) };
}

View File

@ -358,6 +358,11 @@ public class OrganizationService : IOrganizationService
} }
} }
if (organization.UseSecretsManager && organization.Seats + seatAdjustment < organization.SmSeats)
{
throw new BadRequestException("You cannot have more Secrets Manager seats than Password Manager seats.");
}
var paymentIntentClientSecret = await _paymentService.AdjustSeatsAsync(organization, plan, additionalSeats); var paymentIntentClientSecret = await _paymentService.AdjustSeatsAsync(organization, plan, additionalSeats);
await _referenceEventService.RaiseEventAsync( await _referenceEventService.RaiseEventAsync(
new ReferenceEvent(ReferenceEventType.AdjustSeats, organization, _currentContext) new ReferenceEvent(ReferenceEventType.AdjustSeats, organization, _currentContext)
@ -589,12 +594,22 @@ public class OrganizationService : IOrganizationService
await _organizationBillingService.Finalize(sale); await _organizationBillingService.Finalize(sale);
} }
else else
{
if (signup.PaymentMethodType != null)
{ {
await _paymentService.PurchaseOrganizationAsync(organization, signup.PaymentMethodType.Value, await _paymentService.PurchaseOrganizationAsync(organization, signup.PaymentMethodType.Value,
signup.PaymentToken, plan, signup.AdditionalStorageGb, signup.AdditionalSeats, signup.PaymentToken, plan, signup.AdditionalStorageGb, signup.AdditionalSeats,
signup.PremiumAccessAddon, signup.TaxInfo, signup.IsFromProvider, signup.AdditionalSmSeats.GetValueOrDefault(), signup.PremiumAccessAddon, signup.TaxInfo, signup.IsFromProvider, signup.AdditionalSmSeats.GetValueOrDefault(),
signup.AdditionalServiceAccounts.GetValueOrDefault(), signup.IsFromSecretsManagerTrial); signup.AdditionalServiceAccounts.GetValueOrDefault(), signup.IsFromSecretsManagerTrial);
} }
else
{
await _paymentService.PurchaseOrganizationNoPaymentMethod(organization, plan, signup.AdditionalSeats,
signup.PremiumAccessAddon, signup.AdditionalSmSeats.GetValueOrDefault(),
signup.AdditionalServiceAccounts.GetValueOrDefault(), signup.IsFromSecretsManagerTrial);
}
}
} }
var ownerId = signup.IsFromProvider ? default : signup.Owner.Id; var ownerId = signup.IsFromProvider ? default : signup.Owner.Id;
@ -1176,12 +1191,7 @@ public class OrganizationService : IOrganizationService
var currentOrganization = await _organizationRepository.GetByIdAsync(organization.Id); var currentOrganization = await _organizationRepository.GetByIdAsync(organization.Id);
// Revert autoscaling // Revert autoscaling
if (initialSeatCount.HasValue && currentOrganization.Seats.HasValue && currentOrganization.Seats.Value != initialSeatCount.Value) // Do this first so that SmSeats never exceed PM seats (due to current billing requirements)
{
await AdjustSeatsAsync(organization, initialSeatCount.Value - currentOrganization.Seats.Value);
}
// Revert SmSeat autoscaling
if (initialSmSeatCount.HasValue && currentOrganization.SmSeats.HasValue && if (initialSmSeatCount.HasValue && currentOrganization.SmSeats.HasValue &&
currentOrganization.SmSeats.Value != initialSmSeatCount.Value) currentOrganization.SmSeats.Value != initialSmSeatCount.Value)
{ {
@ -1192,6 +1202,11 @@ public class OrganizationService : IOrganizationService
await _updateSecretsManagerSubscriptionCommand.UpdateSubscriptionAsync(smSubscriptionUpdateRevert); await _updateSecretsManagerSubscriptionCommand.UpdateSubscriptionAsync(smSubscriptionUpdateRevert);
} }
if (initialSeatCount.HasValue && currentOrganization.Seats.HasValue && currentOrganization.Seats.Value != initialSeatCount.Value)
{
await AdjustSeatsAsync(organization, initialSeatCount.Value - currentOrganization.Seats.Value);
}
exceptions.Add(e); exceptions.Add(e);
} }

View File

@ -28,6 +28,11 @@ public static class StripeConstants
public const string TaxIdInvalid = "tax_id_invalid"; public const string TaxIdInvalid = "tax_id_invalid";
} }
public static class PaymentBehavior
{
public const string DefaultIncomplete = "default_incomplete";
}
public static class PaymentMethodTypes public static class PaymentMethodTypes
{ {
public const string Card = "card"; public const string Card = "card";

View File

@ -0,0 +1,32 @@
using System.ComponentModel.DataAnnotations;
using Bit.Core.Billing.Enums;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Utilities;
#nullable enable
namespace Bit.Core.Billing.Entities;
public class ClientOrganizationMigrationRecord : ITableObject<Guid>
{
public Guid Id { get; set; }
public Guid OrganizationId { get; set; }
public Guid ProviderId { get; set; }
public PlanType PlanType { get; set; }
public int Seats { get; set; }
public short? MaxStorageGb { get; set; }
[MaxLength(50)] public string GatewayCustomerId { get; set; } = null!;
[MaxLength(50)] public string GatewaySubscriptionId { get; set; } = null!;
public DateTime? ExpirationDate { get; set; }
public int? MaxAutoscaleSeats { get; set; }
public OrganizationStatusType Status { get; set; }
public void SetNewId()
{
if (Id == default)
{
Id = CoreHelpers.GenerateComb();
}
}
}

View File

@ -12,6 +12,7 @@ public static class ServiceCollectionExtensions
public static void AddBillingOperations(this IServiceCollection services) public static void AddBillingOperations(this IServiceCollection services)
{ {
services.AddTransient<IOrganizationBillingService, OrganizationBillingService>(); services.AddTransient<IOrganizationBillingService, OrganizationBillingService>();
services.AddTransient<IPremiumUserBillingService, PremiumUserBillingService>();
services.AddTransient<ISetupIntentCache, SetupIntentDistributedCache>(); services.AddTransient<ISetupIntentCache, SetupIntentDistributedCache>();
services.AddTransient<ISubscriberService, SubscriberService>(); services.AddTransient<ISubscriberService, SubscriberService>();
} }

View File

@ -0,0 +1,23 @@
namespace Bit.Core.Billing.Migration.Models;
public enum ClientMigrationProgress
{
Started = 1,
MigrationRecordCreated = 2,
SubscriptionEnded = 3,
Completed = 4,
Reversing = 5,
ResetOrganization = 6,
RecreatedSubscription = 7,
RemovedMigrationRecord = 8,
Reversed = 9
}
public class ClientMigrationTracker
{
public Guid ProviderId { get; set; }
public Guid OrganizationId { get; set; }
public string OrganizationName { get; set; }
public ClientMigrationProgress Progress { get; set; } = ClientMigrationProgress.Started;
}

View File

@ -0,0 +1,45 @@
using Bit.Core.Billing.Entities;
namespace Bit.Core.Billing.Migration.Models;
public class ProviderMigrationResult
{
public Guid ProviderId { get; set; }
public string ProviderName { get; set; }
public string Result { get; set; }
public List<ClientMigrationResult> Clients { get; set; }
}
public class ClientMigrationResult
{
public Guid OrganizationId { get; set; }
public string OrganizationName { get; set; }
public string Result { get; set; }
public ClientPreviousState PreviousState { get; set; }
}
public class ClientPreviousState
{
public ClientPreviousState() { }
public ClientPreviousState(ClientOrganizationMigrationRecord migrationRecord)
{
PlanType = migrationRecord.PlanType.ToString();
Seats = migrationRecord.Seats;
MaxStorageGb = migrationRecord.MaxStorageGb;
GatewayCustomerId = migrationRecord.GatewayCustomerId;
GatewaySubscriptionId = migrationRecord.GatewaySubscriptionId;
ExpirationDate = migrationRecord.ExpirationDate;
MaxAutoscaleSeats = migrationRecord.MaxAutoscaleSeats;
Status = migrationRecord.Status.ToString();
}
public string PlanType { get; set; }
public int Seats { get; set; }
public short? MaxStorageGb { get; set; }
public string GatewayCustomerId { get; set; } = null!;
public string GatewaySubscriptionId { get; set; } = null!;
public DateTime? ExpirationDate { get; set; }
public int? MaxAutoscaleSeats { get; set; }
public string Status { get; set; }
}

View File

@ -0,0 +1,25 @@
namespace Bit.Core.Billing.Migration.Models;
public enum ProviderMigrationProgress
{
Started = 1,
ClientsMigrated = 2,
TeamsPlanConfigured = 3,
EnterprisePlanConfigured = 4,
CustomerSetup = 5,
SubscriptionSetup = 6,
CreditApplied = 7,
Completed = 8,
Reversing = 9,
ReversedClientMigrations = 10,
RemovedProviderPlans = 11
}
public class ProviderMigrationTracker
{
public Guid ProviderId { get; set; }
public string ProviderName { get; set; }
public List<Guid> OrganizationIds { get; set; }
public ProviderMigrationProgress Progress { get; set; } = ProviderMigrationProgress.Started;
}

View File

@ -0,0 +1,15 @@
using Bit.Core.Billing.Migration.Services;
using Bit.Core.Billing.Migration.Services.Implementations;
using Microsoft.Extensions.DependencyInjection;
namespace Bit.Core.Billing.Migration;
public static class ServiceCollectionExtensions
{
public static void AddProviderMigration(this IServiceCollection services)
{
services.AddTransient<IMigrationTrackerCache, MigrationTrackerDistributedCache>();
services.AddTransient<IOrganizationMigrator, OrganizationMigrator>();
services.AddTransient<IProviderMigrator, ProviderMigrator>();
}
}

View File

@ -0,0 +1,17 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.Billing.Migration.Models;
namespace Bit.Core.Billing.Migration.Services;
public interface IMigrationTrackerCache
{
Task StartTracker(Provider provider);
Task SetOrganizationIds(Guid providerId, IEnumerable<Guid> organizationIds);
Task<ProviderMigrationTracker> GetTracker(Guid providerId);
Task UpdateTrackingStatus(Guid providerId, ProviderMigrationProgress status);
Task StartTracker(Guid providerId, Organization organization);
Task<ClientMigrationTracker> GetTracker(Guid providerId, Guid organizationId);
Task UpdateTrackingStatus(Guid providerId, Guid organizationId, ClientMigrationProgress status);
}

View File

@ -0,0 +1,8 @@
using Bit.Core.AdminConsole.Entities;
namespace Bit.Core.Billing.Migration.Services;
public interface IOrganizationMigrator
{
Task Migrate(Guid providerId, Organization organization);
}

View File

@ -0,0 +1,10 @@
using Bit.Core.Billing.Migration.Models;
namespace Bit.Core.Billing.Migration.Services;
public interface IProviderMigrator
{
Task Migrate(Guid providerId);
Task<ProviderMigrationResult> GetResult(Guid providerId);
}

View File

@ -0,0 +1,107 @@
using System.Text.Json;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.Billing.Migration.Models;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.DependencyInjection;
namespace Bit.Core.Billing.Migration.Services.Implementations;
public class MigrationTrackerDistributedCache(
[FromKeyedServices("persistent")]
IDistributedCache distributedCache) : IMigrationTrackerCache
{
public async Task StartTracker(Provider provider) =>
await SetAsync(new ProviderMigrationTracker
{
ProviderId = provider.Id,
ProviderName = provider.Name
});
public async Task SetOrganizationIds(Guid providerId, IEnumerable<Guid> organizationIds)
{
var tracker = await GetAsync(providerId);
tracker.OrganizationIds = organizationIds.ToList();
await SetAsync(tracker);
}
public Task<ProviderMigrationTracker> GetTracker(Guid providerId) => GetAsync(providerId);
public async Task UpdateTrackingStatus(Guid providerId, ProviderMigrationProgress status)
{
var tracker = await GetAsync(providerId);
tracker.Progress = status;
await SetAsync(tracker);
}
public async Task StartTracker(Guid providerId, Organization organization) =>
await SetAsync(new ClientMigrationTracker
{
ProviderId = providerId,
OrganizationId = organization.Id,
OrganizationName = organization.Name
});
public Task<ClientMigrationTracker> GetTracker(Guid providerId, Guid organizationId) =>
GetAsync(providerId, organizationId);
public async Task UpdateTrackingStatus(Guid providerId, Guid organizationId, ClientMigrationProgress status)
{
var tracker = await GetAsync(providerId, organizationId);
tracker.Progress = status;
await SetAsync(tracker);
}
private static string GetProviderCacheKey(Guid providerId) => $"provider_{providerId}_migration";
private static string GetClientCacheKey(Guid providerId, Guid clientId) =>
$"provider_{providerId}_client_{clientId}_migration";
private async Task<ProviderMigrationTracker> GetAsync(Guid providerId)
{
var cacheKey = GetProviderCacheKey(providerId);
var json = await distributedCache.GetStringAsync(cacheKey);
return string.IsNullOrEmpty(json) ? null : JsonSerializer.Deserialize<ProviderMigrationTracker>(json);
}
private async Task<ClientMigrationTracker> GetAsync(Guid providerId, Guid organizationId)
{
var cacheKey = GetClientCacheKey(providerId, organizationId);
var json = await distributedCache.GetStringAsync(cacheKey);
return string.IsNullOrEmpty(json) ? null : JsonSerializer.Deserialize<ClientMigrationTracker>(json);
}
private async Task SetAsync(ProviderMigrationTracker tracker)
{
var cacheKey = GetProviderCacheKey(tracker.ProviderId);
var json = JsonSerializer.Serialize(tracker);
await distributedCache.SetStringAsync(cacheKey, json, new DistributedCacheEntryOptions
{
SlidingExpiration = TimeSpan.FromMinutes(30)
});
}
private async Task SetAsync(ClientMigrationTracker tracker)
{
var cacheKey = GetClientCacheKey(tracker.ProviderId, tracker.OrganizationId);
var json = JsonSerializer.Serialize(tracker);
await distributedCache.SetStringAsync(cacheKey, json, new DistributedCacheEntryOptions
{
SlidingExpiration = TimeSpan.FromMinutes(30)
});
}
}

View File

@ -0,0 +1,326 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Entities;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Migration.Models;
using Bit.Core.Billing.Repositories;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Microsoft.Extensions.Logging;
using Stripe;
using Plan = Bit.Core.Models.StaticStore.Plan;
namespace Bit.Core.Billing.Migration.Services.Implementations;
public class OrganizationMigrator(
IClientOrganizationMigrationRecordRepository clientOrganizationMigrationRecordRepository,
ILogger<OrganizationMigrator> logger,
IMigrationTrackerCache migrationTrackerCache,
IOrganizationRepository organizationRepository,
IStripeAdapter stripeAdapter) : IOrganizationMigrator
{
private const string _cancellationComment = "Cancelled as part of provider migration to Consolidated Billing";
public async Task Migrate(Guid providerId, Organization organization)
{
logger.LogInformation("CB: Starting migration for organization ({OrganizationID})", organization.Id);
await migrationTrackerCache.StartTracker(providerId, organization);
await CreateMigrationRecordAsync(providerId, organization);
await CancelSubscriptionAsync(providerId, organization);
await UpdateOrganizationAsync(providerId, organization);
}
#region Steps
private async Task CreateMigrationRecordAsync(Guid providerId, Organization organization)
{
logger.LogInformation("CB: Creating ClientOrganizationMigrationRecord for organization ({OrganizationID})", organization.Id);
var migrationRecord = await clientOrganizationMigrationRecordRepository.GetByOrganizationId(organization.Id);
if (migrationRecord != null)
{
logger.LogInformation(
"CB: ClientOrganizationMigrationRecord already exists for organization ({OrganizationID}), deleting record",
organization.Id);
await clientOrganizationMigrationRecordRepository.DeleteAsync(migrationRecord);
}
await clientOrganizationMigrationRecordRepository.CreateAsync(new ClientOrganizationMigrationRecord
{
OrganizationId = organization.Id,
ProviderId = providerId,
PlanType = organization.PlanType,
Seats = organization.Seats ?? 0,
MaxStorageGb = organization.MaxStorageGb,
GatewayCustomerId = organization.GatewayCustomerId!,
GatewaySubscriptionId = organization.GatewaySubscriptionId!,
ExpirationDate = organization.ExpirationDate,
MaxAutoscaleSeats = organization.MaxAutoscaleSeats,
Status = organization.Status
});
logger.LogInformation("CB: Created migration record for organization ({OrganizationID})", organization.Id);
await migrationTrackerCache.UpdateTrackingStatus(providerId, organization.Id,
ClientMigrationProgress.MigrationRecordCreated);
}
private async Task CancelSubscriptionAsync(Guid providerId, Organization organization)
{
logger.LogInformation("CB: Cancelling subscription for organization ({OrganizationID})", organization.Id);
var subscription = await stripeAdapter.SubscriptionGetAsync(organization.GatewaySubscriptionId);
if (subscription is
{
Status:
StripeConstants.SubscriptionStatus.Active or
StripeConstants.SubscriptionStatus.PastDue or
StripeConstants.SubscriptionStatus.Trialing
})
{
await stripeAdapter.SubscriptionUpdateAsync(organization.GatewaySubscriptionId,
new SubscriptionUpdateOptions { CancelAtPeriodEnd = false });
subscription = await stripeAdapter.SubscriptionCancelAsync(organization.GatewaySubscriptionId,
new SubscriptionCancelOptions
{
CancellationDetails = new SubscriptionCancellationDetailsOptions
{
Comment = _cancellationComment
},
InvoiceNow = true,
Prorate = true,
Expand = ["latest_invoice", "test_clock"]
});
logger.LogInformation("CB: Cancelled subscription for organization ({OrganizationID})", organization.Id);
var now = subscription.TestClock?.FrozenTime ?? DateTime.UtcNow;
var trialing = subscription.TrialEnd.HasValue && subscription.TrialEnd.Value > now;
if (!trialing && subscription is { Status: StripeConstants.SubscriptionStatus.Canceled, CancellationDetails.Comment: _cancellationComment })
{
var latestInvoice = subscription.LatestInvoice;
if (latestInvoice.Status == "draft")
{
await stripeAdapter.InvoiceFinalizeInvoiceAsync(latestInvoice.Id,
new InvoiceFinalizeOptions { AutoAdvance = true });
logger.LogInformation("CB: Finalized prorated invoice for organization ({OrganizationID})", organization.Id);
}
}
}
else
{
logger.LogInformation(
"CB: Did not need to cancel subscription for organization ({OrganizationID}) as it was inactive",
organization.Id);
}
await migrationTrackerCache.UpdateTrackingStatus(providerId, organization.Id,
ClientMigrationProgress.SubscriptionEnded);
}
private async Task UpdateOrganizationAsync(Guid providerId, Organization organization)
{
logger.LogInformation("CB: Bringing organization ({OrganizationID}) under provider management",
organization.Id);
var plan = StaticStore.GetPlan(organization.Plan.Contains("Teams") ? PlanType.TeamsMonthly : PlanType.EnterpriseMonthly);
ResetOrganizationPlan(organization, plan);
organization.MaxStorageGb = plan.PasswordManager.BaseStorageGb;
organization.GatewaySubscriptionId = null;
organization.ExpirationDate = null;
organization.MaxAutoscaleSeats = null;
organization.Status = OrganizationStatusType.Managed;
await organizationRepository.ReplaceAsync(organization);
logger.LogInformation("CB: Brought organization ({OrganizationID}) under provider management",
organization.Id);
await migrationTrackerCache.UpdateTrackingStatus(providerId, organization.Id,
ClientMigrationProgress.Completed);
}
#endregion
#region Reverse
private async Task RemoveMigrationRecordAsync(Guid providerId, Organization organization)
{
logger.LogInformation("CB: Removing migration record for organization ({OrganizationID})", organization.Id);
var migrationRecord = await clientOrganizationMigrationRecordRepository.GetByOrganizationId(organization.Id);
if (migrationRecord != null)
{
await clientOrganizationMigrationRecordRepository.DeleteAsync(migrationRecord);
logger.LogInformation(
"CB: Removed migration record for organization ({OrganizationID})",
organization.Id);
}
else
{
logger.LogInformation("CB: Did not remove migration record for organization ({OrganizationID}) as it does not exist", organization.Id);
}
await migrationTrackerCache.UpdateTrackingStatus(providerId, organization.Id, ClientMigrationProgress.Reversed);
}
private async Task RecreateSubscriptionAsync(Guid providerId, Organization organization)
{
logger.LogInformation("CB: Recreating subscription for organization ({OrganizationID})", organization.Id);
if (!string.IsNullOrEmpty(organization.GatewaySubscriptionId))
{
if (string.IsNullOrEmpty(organization.GatewayCustomerId))
{
logger.LogError(
"CB: Cannot recreate subscription for organization ({OrganizationID}) as it does not have a Stripe customer",
organization.Id);
throw new Exception();
}
var customer = await stripeAdapter.CustomerGetAsync(organization.GatewayCustomerId,
new CustomerGetOptions { Expand = ["default_source", "invoice_settings.default_payment_method"] });
var collectionMethod =
customer.DefaultSource != null ||
customer.InvoiceSettings?.DefaultPaymentMethod != null ||
customer.Metadata.ContainsKey(Utilities.BraintreeCustomerIdKey)
? StripeConstants.CollectionMethod.ChargeAutomatically
: StripeConstants.CollectionMethod.SendInvoice;
var plan = StaticStore.GetPlan(organization.PlanType);
var items = new List<SubscriptionItemOptions>
{
new ()
{
Price = plan.PasswordManager.StripeSeatPlanId,
Quantity = organization.Seats
}
};
if (organization.MaxStorageGb.HasValue && plan.PasswordManager.BaseStorageGb.HasValue && organization.MaxStorageGb.Value > plan.PasswordManager.BaseStorageGb.Value)
{
var additionalStorage = organization.MaxStorageGb.Value - plan.PasswordManager.BaseStorageGb.Value;
items.Add(new SubscriptionItemOptions
{
Price = plan.PasswordManager.StripeStoragePlanId,
Quantity = additionalStorage
});
}
var subscriptionCreateOptions = new SubscriptionCreateOptions
{
AutomaticTax = new SubscriptionAutomaticTaxOptions
{
Enabled = true
},
Customer = customer.Id,
CollectionMethod = collectionMethod,
DaysUntilDue = collectionMethod == StripeConstants.CollectionMethod.SendInvoice ? 30 : null,
Items = items,
Metadata = new Dictionary<string, string>
{
[organization.GatewayIdField()] = organization.Id.ToString()
},
OffSession = true,
ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations,
TrialPeriodDays = plan.TrialPeriodDays
};
var subscription = await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
organization.GatewaySubscriptionId = subscription.Id;
await organizationRepository.ReplaceAsync(organization);
logger.LogInformation("CB: Recreated subscription for organization ({OrganizationID})", organization.Id);
}
else
{
logger.LogInformation(
"CB: Did not recreate subscription for organization ({OrganizationID}) as it already exists",
organization.Id);
}
await migrationTrackerCache.UpdateTrackingStatus(providerId, organization.Id,
ClientMigrationProgress.RecreatedSubscription);
}
private async Task ReverseOrganizationUpdateAsync(Guid providerId, Organization organization)
{
var migrationRecord = await clientOrganizationMigrationRecordRepository.GetByOrganizationId(organization.Id);
if (migrationRecord == null)
{
logger.LogError(
"CB: Cannot reverse migration for organization ({OrganizationID}) as it does not have a migration record",
organization.Id);
throw new Exception();
}
var plan = StaticStore.GetPlan(migrationRecord.PlanType);
ResetOrganizationPlan(organization, plan);
organization.MaxStorageGb = migrationRecord.MaxStorageGb;
organization.ExpirationDate = migrationRecord.ExpirationDate;
organization.MaxAutoscaleSeats = migrationRecord.MaxAutoscaleSeats;
organization.Status = migrationRecord.Status;
await organizationRepository.ReplaceAsync(organization);
logger.LogInformation("CB: Reversed organization ({OrganizationID}) updates",
organization.Id);
await migrationTrackerCache.UpdateTrackingStatus(providerId, organization.Id,
ClientMigrationProgress.ResetOrganization);
}
#endregion
#region Shared
private static void ResetOrganizationPlan(Organization organization, Plan plan)
{
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;
}
#endregion
}

View File

@ -0,0 +1,385 @@
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.Constants;
using Bit.Core.Billing.Entities;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Migration.Models;
using Bit.Core.Billing.Repositories;
using Bit.Core.Billing.Services;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Microsoft.Extensions.Logging;
using Stripe;
namespace Bit.Core.Billing.Migration.Services.Implementations;
public class ProviderMigrator(
IClientOrganizationMigrationRecordRepository clientOrganizationMigrationRecordRepository,
IOrganizationMigrator organizationMigrator,
ILogger<ProviderMigrator> logger,
IMigrationTrackerCache migrationTrackerCache,
IOrganizationRepository organizationRepository,
IPaymentService paymentService,
IProviderBillingService providerBillingService,
IProviderOrganizationRepository providerOrganizationRepository,
IProviderRepository providerRepository,
IProviderPlanRepository providerPlanRepository,
IStripeAdapter stripeAdapter) : IProviderMigrator
{
public async Task Migrate(Guid providerId)
{
var provider = await GetProviderAsync(providerId);
if (provider == null)
{
return;
}
logger.LogInformation("CB: Starting migration for provider ({ProviderID})", providerId);
await migrationTrackerCache.StartTracker(provider);
await MigrateClientsAsync(providerId);
await ConfigureTeamsPlanAsync(providerId);
await ConfigureEnterprisePlanAsync(providerId);
await SetupCustomerAsync(provider);
await SetupSubscriptionAsync(provider);
await ApplyCreditAsync(provider);
await UpdateProviderAsync(provider);
}
public async Task<ProviderMigrationResult> GetResult(Guid providerId)
{
var providerTracker = await migrationTrackerCache.GetTracker(providerId);
if (providerTracker == null)
{
return null;
}
var clientTrackers = await Task.WhenAll(providerTracker.OrganizationIds.Select(organizationId =>
migrationTrackerCache.GetTracker(providerId, organizationId)));
var migrationRecordLookup = new Dictionary<Guid, ClientOrganizationMigrationRecord>();
foreach (var clientTracker in clientTrackers)
{
var migrationRecord =
await clientOrganizationMigrationRecordRepository.GetByOrganizationId(clientTracker.OrganizationId);
migrationRecordLookup.Add(clientTracker.OrganizationId, migrationRecord);
}
return new ProviderMigrationResult
{
ProviderId = providerTracker.ProviderId,
ProviderName = providerTracker.ProviderName,
Result = providerTracker.Progress.ToString(),
Clients = clientTrackers.Select(tracker =>
{
var foundMigrationRecord = migrationRecordLookup.TryGetValue(tracker.OrganizationId, out var migrationRecord);
return new ClientMigrationResult
{
OrganizationId = tracker.OrganizationId,
OrganizationName = tracker.OrganizationName,
Result = tracker.Progress.ToString(),
PreviousState = foundMigrationRecord ? new ClientPreviousState(migrationRecord) : null
};
}).ToList(),
};
}
#region Steps
private async Task MigrateClientsAsync(Guid providerId)
{
logger.LogInformation("CB: Migrating clients for provider ({ProviderID})", providerId);
var organizations = await GetEnabledClientsAsync(providerId);
var organizationIds = organizations.Select(organization => organization.Id);
await migrationTrackerCache.SetOrganizationIds(providerId, organizationIds);
foreach (var organization in organizations)
{
var tracker = await migrationTrackerCache.GetTracker(providerId, organization.Id);
if (tracker is not { Progress: ClientMigrationProgress.Completed })
{
await organizationMigrator.Migrate(providerId, organization);
}
}
logger.LogInformation("CB: Migrated clients for provider ({ProviderID})", providerId);
await migrationTrackerCache.UpdateTrackingStatus(providerId,
ProviderMigrationProgress.ClientsMigrated);
}
private async Task ConfigureTeamsPlanAsync(Guid providerId)
{
logger.LogInformation("CB: Configuring Teams plan for provider ({ProviderID})", providerId);
var organizations = await GetEnabledClientsAsync(providerId);
var teamsSeats = organizations
.Where(IsTeams)
.Sum(client => client.Seats) ?? 0;
var teamsProviderPlan = (await providerPlanRepository.GetByProviderId(providerId))
.FirstOrDefault(providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly);
if (teamsProviderPlan == null)
{
await providerPlanRepository.CreateAsync(new ProviderPlan
{
ProviderId = providerId,
PlanType = PlanType.TeamsMonthly,
SeatMinimum = teamsSeats,
PurchasedSeats = 0,
AllocatedSeats = teamsSeats
});
logger.LogInformation("CB: Created Teams plan for provider ({ProviderID}) with a seat minimum of {Seats}",
providerId, teamsSeats);
}
else
{
logger.LogInformation("CB: Teams plan already exists for provider ({ProviderID}), updating seat minimum", providerId);
teamsProviderPlan.SeatMinimum = teamsSeats;
teamsProviderPlan.AllocatedSeats = teamsSeats;
await providerPlanRepository.ReplaceAsync(teamsProviderPlan);
logger.LogInformation("CB: Updated Teams plan for provider ({ProviderID}) to seat minimum of {Seats}",
providerId, teamsProviderPlan.SeatMinimum);
}
await migrationTrackerCache.UpdateTrackingStatus(providerId, ProviderMigrationProgress.TeamsPlanConfigured);
}
private async Task ConfigureEnterprisePlanAsync(Guid providerId)
{
logger.LogInformation("CB: Configuring Enterprise plan for provider ({ProviderID})", providerId);
var organizations = await GetEnabledClientsAsync(providerId);
var enterpriseSeats = organizations
.Where(IsEnterprise)
.Sum(client => client.Seats) ?? 0;
var enterpriseProviderPlan = (await providerPlanRepository.GetByProviderId(providerId))
.FirstOrDefault(providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly);
if (enterpriseProviderPlan == null)
{
await providerPlanRepository.CreateAsync(new ProviderPlan
{
ProviderId = providerId,
PlanType = PlanType.EnterpriseMonthly,
SeatMinimum = enterpriseSeats,
PurchasedSeats = 0,
AllocatedSeats = enterpriseSeats
});
logger.LogInformation("CB: Created Enterprise plan for provider ({ProviderID}) with a seat minimum of {Seats}",
providerId, enterpriseSeats);
}
else
{
logger.LogInformation("CB: Enterprise plan already exists for provider ({ProviderID}), updating seat minimum", providerId);
enterpriseProviderPlan.SeatMinimum = enterpriseSeats;
enterpriseProviderPlan.AllocatedSeats = enterpriseSeats;
await providerPlanRepository.ReplaceAsync(enterpriseProviderPlan);
logger.LogInformation("CB: Updated Enterprise plan for provider ({ProviderID}) to seat minimum of {Seats}",
providerId, enterpriseProviderPlan.SeatMinimum);
}
await migrationTrackerCache.UpdateTrackingStatus(providerId, ProviderMigrationProgress.EnterprisePlanConfigured);
}
private async Task SetupCustomerAsync(Provider provider)
{
if (string.IsNullOrEmpty(provider.GatewayCustomerId))
{
var organizations = await GetEnabledClientsAsync(provider.Id);
var sampleOrganization = organizations.FirstOrDefault(organization => !string.IsNullOrEmpty(organization.GatewayCustomerId));
if (sampleOrganization == null)
{
logger.LogInformation(
"CB: Could not find sample organization for provider ({ProviderID}) that has a Stripe customer",
provider.Id);
return;
}
var taxInfo = await paymentService.GetTaxInfoAsync(sampleOrganization);
var customer = await providerBillingService.SetupCustomer(provider, taxInfo);
await stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions
{
Coupon = StripeConstants.CouponIDs.MSPDiscount35
});
provider.GatewayCustomerId = customer.Id;
await providerRepository.ReplaceAsync(provider);
logger.LogInformation("CB: Setup Stripe customer for provider ({ProviderID})", provider.Id);
}
else
{
logger.LogInformation("CB: Stripe customer already exists for provider ({ProviderID})", provider.Id);
}
await migrationTrackerCache.UpdateTrackingStatus(provider.Id, ProviderMigrationProgress.CustomerSetup);
}
private async Task SetupSubscriptionAsync(Provider provider)
{
if (string.IsNullOrEmpty(provider.GatewaySubscriptionId))
{
if (!string.IsNullOrEmpty(provider.GatewayCustomerId))
{
var subscription = await providerBillingService.SetupSubscription(provider);
provider.GatewaySubscriptionId = subscription.Id;
await providerRepository.ReplaceAsync(provider);
logger.LogInformation("CB: Setup Stripe subscription for provider ({ProviderID})", provider.Id);
}
else
{
logger.LogInformation(
"CB: Could not set up Stripe subscription for provider ({ProviderID}) with no Stripe customer",
provider.Id);
return;
}
}
else
{
logger.LogInformation("CB: Stripe subscription already exists for provider ({ProviderID})", provider.Id);
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
var enterpriseSeatMinimum = providerPlans
.FirstOrDefault(providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly)?
.SeatMinimum ?? 0;
var teamsSeatMinimum = providerPlans
.FirstOrDefault(providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly)?
.SeatMinimum ?? 0;
await providerBillingService.UpdateSeatMinimums(provider, enterpriseSeatMinimum, teamsSeatMinimum);
logger.LogInformation(
"CB: Updated Stripe subscription for provider ({ProviderID}) with current seat minimums", provider.Id);
}
await migrationTrackerCache.UpdateTrackingStatus(provider.Id, ProviderMigrationProgress.SubscriptionSetup);
}
private async Task ApplyCreditAsync(Provider provider)
{
var organizations = await GetEnabledClientsAsync(provider.Id);
var organizationCustomers =
await Task.WhenAll(organizations.Select(organization => stripeAdapter.CustomerGetAsync(organization.GatewayCustomerId)));
var organizationCancellationCredit = organizationCustomers.Sum(customer => customer.Balance);
var legacyOrganizations = organizations.Where(organization =>
organization.PlanType is
PlanType.EnterpriseAnnually2020 or
PlanType.EnterpriseMonthly2020 or
PlanType.TeamsAnnually2020 or
PlanType.TeamsMonthly2020);
var legacyOrganizationCredit = legacyOrganizations.Sum(organization => organization.Seats ?? 0);
await stripeAdapter.CustomerUpdateAsync(provider.GatewayCustomerId, new CustomerUpdateOptions
{
Balance = organizationCancellationCredit + legacyOrganizationCredit
});
logger.LogInformation("CB: Applied {Credit} credit to provider ({ProviderID})", organizationCancellationCredit, provider.Id);
await migrationTrackerCache.UpdateTrackingStatus(provider.Id, ProviderMigrationProgress.CreditApplied);
}
private async Task UpdateProviderAsync(Provider provider)
{
provider.Status = ProviderStatusType.Billable;
await providerRepository.ReplaceAsync(provider);
logger.LogInformation("CB: Completed migration for provider ({ProviderID})", provider.Id);
await migrationTrackerCache.UpdateTrackingStatus(provider.Id, ProviderMigrationProgress.Completed);
}
#endregion
#region Utilities
private async Task<List<Organization>> GetEnabledClientsAsync(Guid providerId)
{
var providerOrganizations = await providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId);
return (await Task.WhenAll(providerOrganizations.Select(providerOrganization =>
organizationRepository.GetByIdAsync(providerOrganization.OrganizationId))))
.Where(organization => organization.Enabled)
.ToList();
}
private async Task<Provider> GetProviderAsync(Guid providerId)
{
var provider = await providerRepository.GetByIdAsync(providerId);
if (provider == null)
{
logger.LogWarning("CB: Cannot migrate provider ({ProviderID}) as it does not exist", providerId);
return null;
}
if (provider.Type != ProviderType.Msp)
{
logger.LogWarning("CB: Cannot migrate provider ({ProviderID}) as it is not an MSP", providerId);
return null;
}
if (provider.Status == ProviderStatusType.Created)
{
return provider;
}
logger.LogWarning("CB: Cannot migrate provider ({ProviderID}) as it is not in the 'Created' state", providerId);
return null;
}
private static bool IsEnterprise(Organization organization) => organization.Plan.Contains("Enterprise");
private static bool IsTeams(Organization organization) => organization.Plan.Contains("Teams");
#endregion
}

View File

@ -0,0 +1,49 @@
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Business;
namespace Bit.Core.Billing.Models.Sales;
#nullable enable
public class PremiumUserSale
{
private PremiumUserSale() { }
public required User User { get; set; }
public required CustomerSetup CustomerSetup { get; set; }
public short? Storage { get; set; }
public void Deconstruct(
out User user,
out CustomerSetup customerSetup,
out short? storage)
{
user = User;
customerSetup = CustomerSetup;
storage = Storage;
}
public static PremiumUserSale From(
User user,
PaymentMethodType paymentMethodType,
string paymentMethodToken,
TaxInfo taxInfo,
short? storage)
{
var tokenizedPaymentSource = new TokenizedPaymentSource(paymentMethodType, paymentMethodToken);
var taxInformation = TaxInformation.From(taxInfo);
return new PremiumUserSale
{
User = user,
CustomerSetup = new CustomerSetup
{
TokenizedPaymentSource = tokenizedPaymentSource,
TaxInformation = taxInformation
},
Storage = storage
};
}
}

View File

@ -1,4 +1,5 @@
using Stripe; using Bit.Core.Models.Business;
using Stripe;
namespace Bit.Core.Billing.Models; namespace Bit.Core.Billing.Models;
@ -11,6 +12,15 @@ public record TaxInformation(
string City, string City,
string State) string State)
{ {
public static TaxInformation From(TaxInfo taxInfo) => new(
taxInfo.BillingAddressCountry,
taxInfo.BillingAddressPostalCode,
taxInfo.TaxIdNumber,
taxInfo.BillingAddressLine1,
taxInfo.BillingAddressLine2,
taxInfo.BillingAddressCity,
taxInfo.BillingAddressState);
public (AddressOptions, List<CustomerTaxIdDataOptions>) GetStripeOptions() public (AddressOptions, List<CustomerTaxIdDataOptions>) GetStripeOptions()
{ {
var address = new AddressOptions var address = new AddressOptions

View File

@ -0,0 +1,10 @@
using Bit.Core.Billing.Entities;
using Bit.Core.Repositories;
namespace Bit.Core.Billing.Repositories;
public interface IClientOrganizationMigrationRecordRepository : IRepository<ClientOrganizationMigrationRecord, Guid>
{
Task<ClientOrganizationMigrationRecord> GetByOrganizationId(Guid organizationId);
Task<ICollection<ClientOrganizationMigrationRecord>> GetByProviderId(Guid providerId);
}

View File

@ -9,7 +9,7 @@ namespace Bit.Core.Billing.Services;
public interface IOrganizationBillingService public interface IOrganizationBillingService
{ {
/// <summary> /// <summary>
/// <para>Establishes the billing configuration for a Bitwarden <see cref="Organization"/> using the provided <paramref name="sale"/>.</para> /// <para>Establishes the Stripe entities necessary for a Bitwarden <see cref="Organization"/> using the provided <paramref name="sale"/>.</para>
/// <para> /// <para>
/// The method first checks to see if the /// The method first checks to see if the
/// provided <see cref="OrganizationSale.Organization"/> already has a Stripe <see cref="Stripe.Customer"/> using the <see cref="Organization.GatewayCustomerId"/>. /// provided <see cref="OrganizationSale.Organization"/> already has a Stripe <see cref="Stripe.Customer"/> using the <see cref="Organization.GatewayCustomerId"/>.
@ -17,7 +17,7 @@ public interface IOrganizationBillingService
/// for the created or existing customer using the provided <see cref="OrganizationSale.SubscriptionSetup"/>. /// for the created or existing customer using the provided <see cref="OrganizationSale.SubscriptionSetup"/>.
/// </para> /// </para>
/// </summary> /// </summary>
/// <param name="sale">The purchase details necessary to establish the Stripe entities responsible for billing the organization.</param> /// <param name="sale">The data required to establish the Stripe entities responsible for billing the organization.</param>
/// <example> /// <example>
/// <code> /// <code>
/// var sale = OrganizationSale.From(organization, organizationSignup); /// var sale = OrganizationSale.From(organization, organizationSignup);

View File

@ -0,0 +1,30 @@
using Bit.Core.Billing.Models.Sales;
using Bit.Core.Entities;
namespace Bit.Core.Billing.Services;
public interface IPremiumUserBillingService
{
/// <summary>
/// <para>Establishes the Stripe entities necessary for a Bitwarden <see cref="User"/> using the provided <paramref name="sale"/>.</para>
/// <para>
/// The method first checks to see if the
/// provided <see cref="PremiumUserSale.User"/> already has a Stripe <see cref="Stripe.Customer"/> using the <see cref="User.GatewayCustomerId"/>.
/// If it doesn't, the method creates one using the <paramref name="sale"/>'s <see cref="PremiumUserSale.CustomerSetup"/>. The method then creates a Stripe <see cref="Stripe.Subscription"/>
/// for the created or existing customer while appending the provided <paramref name="sale"/>'s <see cref="PremiumUserSale.Storage"/>.
/// </para>
/// </summary>
/// <param name="sale">The data required to establish the Stripe entities responsible for billing the premium user.</param>
/// <example>
/// <code>
/// var sale = PremiumUserSale.From(
/// user,
/// paymentMethodType,
/// paymentMethodToken,
/// taxInfo,
/// storage);
/// await premiumUserBillingService.Finalize(sale);
/// </code>
/// </example>
Task Finalize(PremiumUserSale sale);
}

View File

@ -34,11 +34,9 @@ public class OrganizationBillingService(
{ {
var (organization, customerSetup, subscriptionSetup) = sale; var (organization, customerSetup, subscriptionSetup) = sale;
List<string> expand = ["tax"]; var customer = string.IsNullOrEmpty(organization.GatewayCustomerId) && customerSetup != null
? await CreateCustomerAsync(organization, customerSetup)
var customer = customerSetup != null : await subscriberService.GetCustomerOrThrow(organization, new CustomerGetOptions { Expand = ["tax"] });
? await CreateCustomerAsync(organization, customerSetup, expand)
: await subscriberService.GetCustomerOrThrow(organization, new CustomerGetOptions { Expand = expand });
var subscription = await CreateSubscriptionAsync(organization.Id, customer, subscriptionSetup); var subscription = await CreateSubscriptionAsync(organization.Id, customer, subscriptionSetup);
@ -111,31 +109,31 @@ public class OrganizationBillingService(
private async Task<Customer> CreateCustomerAsync( private async Task<Customer> CreateCustomerAsync(
Organization organization, Organization organization,
CustomerSetup customerSetup, CustomerSetup customerSetup)
List<string>? expand = null)
{ {
var organizationDisplayName = organization.DisplayName(); var displayName = organization.DisplayName();
var customerCreateOptions = new CustomerCreateOptions var customerCreateOptions = new CustomerCreateOptions
{ {
Coupon = customerSetup.Coupon, Coupon = customerSetup.Coupon,
Description = organization.DisplayBusinessName(), Description = organization.DisplayBusinessName(),
Email = organization.BillingEmail, Email = organization.BillingEmail,
Expand = expand, Expand = ["tax"],
InvoiceSettings = new CustomerInvoiceSettingsOptions InvoiceSettings = new CustomerInvoiceSettingsOptions
{ {
CustomFields = [ CustomFields = [
new CustomerInvoiceSettingsCustomFieldOptions new CustomerInvoiceSettingsCustomFieldOptions
{ {
Name = organization.SubscriberType(), Name = organization.SubscriberType(),
Value = organizationDisplayName.Length <= 30 Value = displayName.Length <= 30
? organizationDisplayName ? displayName
: organizationDisplayName[..30] : displayName[..30]
}] }]
}, },
Metadata = new Dictionary<string, string> Metadata = new Dictionary<string, string>
{ {
{ "region", globalSettings.BaseServiceUri.CloudRegion } ["organizationId"] = organization.Id.ToString(),
["region"] = globalSettings.BaseServiceUri.CloudRegion
} }
}; };
@ -174,46 +172,41 @@ public class OrganizationBillingService(
}; };
customerCreateOptions.TaxIdData = taxIdData; customerCreateOptions.TaxIdData = taxIdData;
var (type, token) = customerSetup.TokenizedPaymentSource; var (paymentMethodType, paymentMethodToken) = customerSetup.TokenizedPaymentSource;
// ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault // ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault
switch (type) switch (paymentMethodType)
{ {
case PaymentMethodType.BankAccount: case PaymentMethodType.BankAccount:
{ {
var setupIntent = var setupIntent =
(await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = token })) (await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = paymentMethodToken }))
.FirstOrDefault(); .FirstOrDefault();
if (setupIntent == null) if (setupIntent == null)
{ {
logger.LogError("Cannot create customer for organization ({OrganizationID}) without a setup intent for their bank account", organization.Id); logger.LogError("Cannot create customer for organization ({OrganizationID}) without a setup intent for their bank account", organization.Id);
throw new BillingException(); throw new BillingException();
} }
await setupIntentCache.Set(organization.Id, setupIntent.Id); await setupIntentCache.Set(organization.Id, setupIntent.Id);
break; break;
} }
case PaymentMethodType.Card: case PaymentMethodType.Card:
{ {
customerCreateOptions.PaymentMethod = token; customerCreateOptions.PaymentMethod = paymentMethodToken;
customerCreateOptions.InvoiceSettings.DefaultPaymentMethod = token; customerCreateOptions.InvoiceSettings.DefaultPaymentMethod = paymentMethodToken;
break; break;
} }
case PaymentMethodType.PayPal: case PaymentMethodType.PayPal:
{ {
braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(organization, token); braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(organization, paymentMethodToken);
customerCreateOptions.Metadata[BraintreeCustomerIdKey] = braintreeCustomerId; customerCreateOptions.Metadata[BraintreeCustomerIdKey] = braintreeCustomerId;
break; break;
} }
default: default:
{ {
logger.LogError("Cannot create customer for organization ({OrganizationID}) using payment method type ({PaymentMethodType}) as it is not supported", organization.Id, type.ToString()); logger.LogError("Cannot create customer for organization ({OrganizationID}) using payment method type ({PaymentMethodType}) as it is not supported", organization.Id, paymentMethodType.ToString());
throw new BillingException(); throw new BillingException();
} }
} }
@ -227,7 +220,6 @@ public class OrganizationBillingService(
StripeConstants.ErrorCodes.CustomerTaxLocationInvalid) StripeConstants.ErrorCodes.CustomerTaxLocationInvalid)
{ {
await Revert(); await Revert();
throw new BadRequestException( throw new BadRequestException(
"Your location wasn't recognized. Please ensure your country and postal code are valid."); "Your location wasn't recognized. Please ensure your country and postal code are valid.");
} }
@ -235,7 +227,6 @@ public class OrganizationBillingService(
StripeConstants.ErrorCodes.TaxIdInvalid) StripeConstants.ErrorCodes.TaxIdInvalid)
{ {
await Revert(); await Revert();
throw new BadRequestException( throw new BadRequestException(
"Your tax ID wasn't recognized for your selected country. Please ensure your country and tax ID are valid."); "Your tax ID wasn't recognized for your selected country. Please ensure your country and tax ID are valid.");
} }
@ -257,7 +248,7 @@ public class OrganizationBillingService(
await setupIntentCache.Remove(organization.Id); await setupIntentCache.Remove(organization.Id);
break; break;
} }
case PaymentMethodType.PayPal: case PaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId):
{ {
await braintreeGateway.Customer.DeleteAsync(braintreeCustomerId); await braintreeGateway.Customer.DeleteAsync(braintreeCustomerId);
break; break;

View File

@ -0,0 +1,260 @@
using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Models.Sales;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
using Braintree;
using Microsoft.Extensions.Logging;
using Stripe;
using Customer = Stripe.Customer;
using Subscription = Stripe.Subscription;
namespace Bit.Core.Billing.Services.Implementations;
using static Utilities;
public class PremiumUserBillingService(
IBraintreeGateway braintreeGateway,
IGlobalSettings globalSettings,
ILogger<PremiumUserBillingService> logger,
ISetupIntentCache setupIntentCache,
IStripeAdapter stripeAdapter,
ISubscriberService subscriberService,
IUserRepository userRepository) : IPremiumUserBillingService
{
public async Task Finalize(PremiumUserSale sale)
{
var (user, customerSetup, storage) = sale;
List<string> expand = ["tax"];
var customer = string.IsNullOrEmpty(user.GatewayCustomerId)
? await CreateCustomerAsync(user, customerSetup)
: await subscriberService.GetCustomerOrThrow(user, new CustomerGetOptions { Expand = expand });
var subscription = await CreateSubscriptionAsync(user.Id, customer, storage);
switch (customerSetup.TokenizedPaymentSource)
{
case { Type: PaymentMethodType.PayPal }
when subscription.Status == StripeConstants.SubscriptionStatus.Incomplete:
case { Type: not PaymentMethodType.PayPal }
when subscription.Status == StripeConstants.SubscriptionStatus.Active:
{
user.Premium = true;
user.PremiumExpirationDate = subscription.CurrentPeriodEnd;
break;
}
}
user.Gateway = GatewayType.Stripe;
user.GatewayCustomerId = customer.Id;
user.GatewaySubscriptionId = subscription.Id;
await userRepository.ReplaceAsync(user);
}
private async Task<Customer> CreateCustomerAsync(
User user,
CustomerSetup customerSetup)
{
if (customerSetup.TokenizedPaymentSource is not
{
Type: PaymentMethodType.BankAccount or PaymentMethodType.Card or PaymentMethodType.PayPal,
Token: not null and not ""
})
{
logger.LogError(
"Cannot create customer for user ({UserID}) without a valid payment source", user.Id);
throw new BillingException();
}
if (customerSetup.TaxInformation is not { Country: not null and not "", PostalCode: not null and not "" })
{
logger.LogError(
"Cannot create customer for user ({UserID}) without valid tax information", user.Id);
throw new BillingException();
}
var (address, taxIdData) = customerSetup.TaxInformation.GetStripeOptions();
var subscriberName = user.SubscriberName();
var customerCreateOptions = new CustomerCreateOptions
{
Address = address,
Description = user.Name,
Email = user.Email,
Expand = ["tax"],
InvoiceSettings = new CustomerInvoiceSettingsOptions
{
CustomFields =
[
new CustomerInvoiceSettingsCustomFieldOptions
{
Name = user.SubscriberType(),
Value = subscriberName.Length <= 30
? subscriberName
: subscriberName[..30]
}
]
},
Metadata = new Dictionary<string, string>
{
["region"] = globalSettings.BaseServiceUri.CloudRegion,
["userId"] = user.Id.ToString()
},
Tax = new CustomerTaxOptions
{
ValidateLocation = StripeConstants.ValidateTaxLocationTiming.Immediately
},
TaxIdData = taxIdData
};
var (paymentMethodType, paymentMethodToken) = customerSetup.TokenizedPaymentSource;
var braintreeCustomerId = "";
// ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault
switch (paymentMethodType)
{
case PaymentMethodType.BankAccount:
{
var setupIntent =
(await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = paymentMethodToken }))
.FirstOrDefault();
if (setupIntent == null)
{
logger.LogError("Cannot create customer for user ({UserID}) without a setup intent for their bank account", user.Id);
throw new BillingException();
}
await setupIntentCache.Set(user.Id, setupIntent.Id);
break;
}
case PaymentMethodType.Card:
{
customerCreateOptions.PaymentMethod = paymentMethodToken;
customerCreateOptions.InvoiceSettings.DefaultPaymentMethod = paymentMethodToken;
break;
}
case PaymentMethodType.PayPal:
{
braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(user, paymentMethodToken);
customerCreateOptions.Metadata[BraintreeCustomerIdKey] = braintreeCustomerId;
break;
}
default:
{
logger.LogError("Cannot create customer for user ({UserID}) using payment method type ({PaymentMethodType}) as it is not supported", user.Id, paymentMethodType.ToString());
throw new BillingException();
}
}
try
{
return await stripeAdapter.CustomerCreateAsync(customerCreateOptions);
}
catch (StripeException stripeException) when (stripeException.StripeError?.Code ==
StripeConstants.ErrorCodes.CustomerTaxLocationInvalid)
{
await Revert();
throw new BadRequestException(
"Your location wasn't recognized. Please ensure your country and postal code are valid.");
}
catch (StripeException stripeException) when (stripeException.StripeError?.Code ==
StripeConstants.ErrorCodes.TaxIdInvalid)
{
await Revert();
throw new BadRequestException(
"Your tax ID wasn't recognized for your selected country. Please ensure your country and tax ID are valid.");
}
catch
{
await Revert();
throw;
}
async Task Revert()
{
// ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault
switch (customerSetup.TokenizedPaymentSource!.Type)
{
case PaymentMethodType.BankAccount:
{
await setupIntentCache.Remove(user.Id);
break;
}
case PaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId):
{
await braintreeGateway.Customer.DeleteAsync(braintreeCustomerId);
break;
}
}
}
}
private async Task<Subscription> CreateSubscriptionAsync(
Guid userId,
Customer customer,
int? storage)
{
var subscriptionItemOptionsList = new List<SubscriptionItemOptions>
{
new ()
{
Price = "premium-annually",
Quantity = 1
}
};
if (storage is > 0)
{
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{
Price = "storage-gb-annually",
Quantity = storage
});
}
var usingPayPal = customer.Metadata?.ContainsKey(BraintreeCustomerIdKey) ?? false;
var subscriptionCreateOptions = new SubscriptionCreateOptions
{
AutomaticTax = new SubscriptionAutomaticTaxOptions
{
Enabled = customer.Tax?.AutomaticTax == StripeConstants.AutomaticTaxStatus.Supported,
},
CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically,
Customer = customer.Id,
Items = subscriptionItemOptionsList,
Metadata = new Dictionary<string, string>
{
["userId"] = userId.ToString()
},
PaymentBehavior = usingPayPal
? StripeConstants.PaymentBehavior.DefaultIncomplete
: null,
OffSession = true
};
var subscription = await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
if (usingPayPal)
{
await stripeAdapter.InvoiceUpdateAsync(subscription.LatestInvoiceId, new InvoiceUpdateOptions
{
AutoAdvance = false
});
}
return subscription;
}
}

View File

@ -110,7 +110,6 @@ public static class FeatureFlagKeys
public const string AC2101UpdateTrialInitiationEmail = "AC-2101-update-trial-initiation-email"; public const string AC2101UpdateTrialInitiationEmail = "AC-2101-update-trial-initiation-email";
public const string EnableConsolidatedBilling = "enable-consolidated-billing"; public const string EnableConsolidatedBilling = "enable-consolidated-billing";
public const string AC1795_UpdatedSubscriptionStatusSection = "AC-1795_updated-subscription-status-section"; public const string AC1795_UpdatedSubscriptionStatusSection = "AC-1795_updated-subscription-status-section";
public const string EnableDeleteProvider = "AC-1218-delete-provider";
public const string EmailVerification = "email-verification"; public const string EmailVerification = "email-verification";
public const string EmailVerificationDisableTimingDelays = "email-verification-disable-timing-delays"; public const string EmailVerificationDisableTimingDelays = "email-verification-disable-timing-delays";
public const string AnhFcmv1Migration = "anh-fcmv1-migration"; public const string AnhFcmv1Migration = "anh-fcmv1-migration";
@ -124,7 +123,6 @@ public static class FeatureFlagKeys
public const string InlineMenuFieldQualification = "inline-menu-field-qualification"; public const string InlineMenuFieldQualification = "inline-menu-field-qualification";
public const string TwoFactorComponentRefactor = "two-factor-component-refactor"; public const string TwoFactorComponentRefactor = "two-factor-component-refactor";
public const string InlineMenuPositioningImprovements = "inline-menu-positioning-improvements"; public const string InlineMenuPositioningImprovements = "inline-menu-positioning-improvements";
public const string AC2828_ProviderPortalMembersPage = "AC-2828_provider-portal-members-page";
public const string ProviderClientVaultPrivacyBanner = "ac-2833-provider-client-vault-privacy-banner"; public const string ProviderClientVaultPrivacyBanner = "ac-2833-provider-client-vault-privacy-banner";
public const string DeviceTrustLogging = "pm-8285-device-trust-logging"; public const string DeviceTrustLogging = "pm-8285-device-trust-logging";
public const string AuthenticatorTwoFactorToken = "authenticator-2fa-token"; public const string AuthenticatorTwoFactorToken = "authenticator-2fa-token";
@ -143,6 +141,9 @@ public static class FeatureFlagKeys
public const string CipherKeyEncryption = "cipher-key-encryption"; public const string CipherKeyEncryption = "cipher-key-encryption";
public const string EnableNewCardCombinedExpiryAutofill = "enable-new-card-combined-expiry-autofill"; public const string EnableNewCardCombinedExpiryAutofill = "enable-new-card-combined-expiry-autofill";
public const string StorageReseedRefactor = "storage-reseed-refactor"; public const string StorageReseedRefactor = "storage-reseed-refactor";
public const string TrialPayment = "PM-8163-trial-payment";
public const string Pm3478RefactorOrganizationUserApi = "pm-3478-refactor-organizationuser-api";
public const string RemoveServerVersionHeader = "remove-server-version-header";
public static List<string> GetAllKeys() public static List<string> GetAllKeys()
{ {

View File

@ -21,8 +21,8 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="AspNetCoreRateLimit.Redis" Version="2.0.0" /> <PackageReference Include="AspNetCoreRateLimit.Redis" Version="2.0.0" />
<PackageReference Include="AWSSDK.SimpleEmail" Version="3.7.401.11" /> <PackageReference Include="AWSSDK.SimpleEmail" Version="3.7.401.16" />
<PackageReference Include="AWSSDK.SQS" Version="3.7.400.21" /> <PackageReference Include="AWSSDK.SQS" Version="3.7.400.26" />
<PackageReference Include="Azure.Data.Tables" Version="12.9.0" /> <PackageReference Include="Azure.Data.Tables" Version="12.9.0" />
<PackageReference Include="Azure.Extensions.AspNetCore.DataProtection.Blobs" Version="1.3.4" /> <PackageReference Include="Azure.Extensions.AspNetCore.DataProtection.Blobs" Version="1.3.4" />
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="8.0.8" /> <PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="8.0.8" />
@ -34,9 +34,9 @@
<PackageReference Include="DnsClient" Version="1.8.0" /> <PackageReference Include="DnsClient" Version="1.8.0" />
<PackageReference Include="Fido2.AspNet" Version="3.0.1" /> <PackageReference Include="Fido2.AspNet" Version="3.0.1" />
<PackageReference Include="Handlebars.Net" Version="2.1.6" /> <PackageReference Include="Handlebars.Net" Version="2.1.6" />
<PackageReference Include="MailKit" Version="4.7.1.1" /> <PackageReference Include="MailKit" Version="4.8.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.8" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.8" />
<PackageReference Include="Microsoft.Azure.Cosmos" Version="3.43.0" /> <PackageReference Include="Microsoft.Azure.Cosmos" Version="3.43.1" />
<PackageReference Include="Microsoft.Azure.NotificationHubs" Version="4.2.0" /> <PackageReference Include="Microsoft.Azure.NotificationHubs" Version="4.2.0" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="5.2.2" /> <PackageReference Include="Microsoft.Data.SqlClient" Version="5.2.2" />
<PackageReference Include="Microsoft.Extensions.Caching.Cosmos" Version="1.6.1" /> <PackageReference Include="Microsoft.Extensions.Caching.Cosmos" Version="1.6.1" />
@ -54,8 +54,8 @@
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Serilog.Sinks.SyslogMessages" Version="3.0.2" /> <PackageReference Include="Serilog.Sinks.SyslogMessages" Version="3.0.2" />
<PackageReference Include="AspNetCoreRateLimit" Version="5.0.0" /> <PackageReference Include="AspNetCoreRateLimit" Version="5.0.0" />
<PackageReference Include="Braintree" Version="5.26.0" /> <PackageReference Include="Braintree" Version="5.27.0" />
<PackageReference Include="Stripe.net" Version="45.13.0" /> <PackageReference Include="Stripe.net" Version="45.14.0" />
<PackageReference Include="Otp.NET" Version="1.4.0" /> <PackageReference Include="Otp.NET" Version="1.4.0" />
<PackageReference Include="YubicoDotNetClient" Version="1.2.0" /> <PackageReference Include="YubicoDotNetClient" Version="1.2.0" />
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="8.0.8" /> <PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="8.0.8" />

View File

@ -0,0 +1,68 @@
#nullable enable
using Bit.Core.Context;
using Bit.Core.NotificationCenter.Entities;
using Microsoft.AspNetCore.Authorization;
namespace Bit.Core.NotificationCenter.Authorization;
public class NotificationAuthorizationHandler : AuthorizationHandler<NotificationOperationsRequirement, Notification>
{
private readonly ICurrentContext _currentContext;
public NotificationAuthorizationHandler(ICurrentContext currentContext)
{
_currentContext = currentContext;
}
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context,
NotificationOperationsRequirement requirement,
Notification notification)
{
if (!_currentContext.UserId.HasValue)
{
return;
}
var authorized = requirement switch
{
not null when requirement == NotificationOperations.Read => CanRead(notification),
not null when requirement == NotificationOperations.Create => await CanCreate(notification),
not null when requirement == NotificationOperations.Update => await CanUpdate(notification),
_ => throw new ArgumentException("Unsupported operation requirement type provided.", nameof(requirement))
};
if (authorized)
{
context.Succeed(requirement);
}
}
private bool CanRead(Notification notification)
{
var userMatching = !notification.UserId.HasValue || notification.UserId.Value == _currentContext.UserId!.Value;
var organizationMatching = !notification.OrganizationId.HasValue ||
_currentContext.GetOrganization(notification.OrganizationId.Value) != null;
return notification.Global || (userMatching && organizationMatching);
}
private async Task<bool> CanCreate(Notification notification)
{
var organizationPermissionsMatching = !notification.OrganizationId.HasValue ||
await _currentContext.AccessReports(notification.OrganizationId.Value);
var userNoOrganizationMatching = !notification.UserId.HasValue || notification.OrganizationId.HasValue ||
notification.UserId.Value == _currentContext.UserId!.Value;
return !notification.Global && organizationPermissionsMatching && userNoOrganizationMatching;
}
private async Task<bool> CanUpdate(Notification notification)
{
var organizationPermissionsMatching = !notification.OrganizationId.HasValue ||
await _currentContext.AccessReports(notification.OrganizationId.Value);
var userNoOrganizationMatching = !notification.UserId.HasValue || notification.OrganizationId.HasValue ||
notification.UserId.Value == _currentContext.UserId!.Value;
return !notification.Global && organizationPermissionsMatching && userNoOrganizationMatching;
}
}

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