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:
commit
cb9865a88a
@ -3,7 +3,7 @@
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"swashbuckle.aspnetcore.cli": {
|
||||
"version": "6.7.3",
|
||||
"version": "6.8.0",
|
||||
"commands": ["swagger"]
|
||||
},
|
||||
"dotnet-ef": {
|
||||
|
@ -30,7 +30,7 @@ jobs:
|
||||
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
|
||||
|
||||
- name: Check out branch
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
|
||||
with:
|
||||
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' }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
14
.github/workflows/build.yml
vendored
14
.github/workflows/build.yml
vendored
@ -19,7 +19,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
|
||||
@ -68,7 +68,7 @@ jobs:
|
||||
node: true
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
|
||||
@ -173,7 +173,7 @@ jobs:
|
||||
dotnet: true
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
|
||||
|
||||
- name: Check branch to publish
|
||||
env:
|
||||
@ -263,7 +263,7 @@ jobs:
|
||||
-d ${{ matrix.base_path }}/${{ matrix.project_name }}/obj/build-output/publish
|
||||
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@5cd11c3a4ced054e52742c5fd54dca954e0edd85 # v6.7.0
|
||||
uses: docker/build-push-action@32945a339266b759abcbdc89316275140b0fc960 # v6.8.0
|
||||
with:
|
||||
context: ${{ matrix.base_path }}/${{ matrix.project_name }}
|
||||
file: ${{ matrix.base_path }}/${{ matrix.project_name }}/Dockerfile
|
||||
@ -282,7 +282,7 @@ jobs:
|
||||
output-format: sarif
|
||||
|
||||
- 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:
|
||||
sarif_file: ${{ steps.container-scan.outputs.sarif }}
|
||||
|
||||
@ -292,7 +292,7 @@ jobs:
|
||||
needs: build-docker
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
|
||||
@ -467,7 +467,7 @@ jobs:
|
||||
- win-x64
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
|
||||
|
2
.github/workflows/cleanup-rc-branch.yml
vendored
2
.github/workflows/cleanup-rc-branch.yml
vendored
@ -24,7 +24,7 @@ jobs:
|
||||
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
|
||||
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
|
||||
with:
|
||||
ref: main
|
||||
token: ${{ steps.retrieve-bot-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
|
||||
|
2
.github/workflows/code-references.yml
vendored
2
.github/workflows/code-references.yml
vendored
@ -33,7 +33,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
|
||||
|
||||
- name: Collect
|
||||
id: collect
|
||||
|
102
.github/workflows/container-registry-purge.yml
vendored
102
.github/workflows/container-registry-purge.yml
vendored
@ -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 }}
|
2
.github/workflows/protect-files.yml
vendored
2
.github/workflows/protect-files.yml
vendored
@ -29,7 +29,7 @@ jobs:
|
||||
label: "DB-migrations-changed"
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
|
2
.github/workflows/publish.yml
vendored
2
.github/workflows/publish.yml
vendored
@ -99,7 +99,7 @@ jobs:
|
||||
echo "Github Release Option: $RELEASE_OPTION"
|
||||
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
|
||||
|
||||
- name: Set up project name
|
||||
id: setup
|
||||
|
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@ -37,7 +37,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
|
||||
|
||||
- name: Check release version
|
||||
id: version
|
||||
|
6
.github/workflows/scan.yml
vendored
6
.github/workflows/scan.yml
vendored
@ -26,7 +26,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
@ -46,7 +46,7 @@ jobs:
|
||||
--output-path . ${{ env.INCREMENTAL }}
|
||||
|
||||
- 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:
|
||||
sarif_file: cx_result.sarif
|
||||
|
||||
@ -66,7 +66,7 @@ jobs:
|
||||
distribution: "zulu"
|
||||
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
4
.github/workflows/test-database.yml
vendored
4
.github/workflows/test-database.yml
vendored
@ -36,7 +36,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
|
||||
@ -147,7 +147,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
|
||||
|
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@ -46,7 +46,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
|
||||
|
4
.github/workflows/version-bump.yml
vendored
4
.github/workflows/version-bump.yml
vendored
@ -39,7 +39,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Check out branch
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
|
||||
|
||||
- name: Check if RC branch exists
|
||||
if: ${{ inputs.cut_rc_branch == true }}
|
||||
@ -230,7 +230,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Check out branch
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
|
||||
with:
|
||||
ref: main
|
||||
|
||||
|
@ -4,6 +4,7 @@ using Bit.Core.AdminConsole.Models.OrganizationConnectionConfigs;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Scim.Context;
|
||||
|
||||
@ -11,6 +12,32 @@ public class ScimContext : IScimContext
|
||||
{
|
||||
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 ScimConfig ScimConfiguration { get; set; }
|
||||
public Guid? OrganizationId { get; set; }
|
||||
@ -55,10 +82,18 @@ public class ScimContext : IScimContext
|
||||
RequestScimProvider = ScimProviderType.Okta;
|
||||
}
|
||||
}
|
||||
|
||||
if (RequestScimProvider == ScimProviderType.Default &&
|
||||
httpContext.Request.Headers.ContainsKey("Adscimversion"))
|
||||
{
|
||||
RequestScimProvider = ScimProviderType.AzureAd;
|
||||
}
|
||||
|
||||
var ipAddress = CoreHelpers.GetIpAddress(httpContext, globalSettings);
|
||||
if (RequestScimProvider == ScimProviderType.Default &&
|
||||
_pingIpAddresses.Contains(ipAddress))
|
||||
{
|
||||
RequestScimProvider = ScimProviderType.Ping;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -43,7 +43,8 @@ public class PutGroupCommand : IPutGroupCommand
|
||||
|
||||
private async Task UpdateGroupMembersAsync(Group group, ScimGroupRequestModel model)
|
||||
{
|
||||
if (_scimContext.RequestScimProvider != ScimProviderType.Okta)
|
||||
if (_scimContext.RequestScimProvider != ScimProviderType.Okta &&
|
||||
_scimContext.RequestScimProvider != ScimProviderType.Ping)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
@ -20,15 +20,16 @@ public class GetUsersListQuery : IGetUsersListQuery
|
||||
string externalIdFilter = null;
|
||||
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("@"))
|
||||
{
|
||||
emailFilter = usernameFilter;
|
||||
}
|
||||
}
|
||||
else if (filter.StartsWith("externalId eq "))
|
||||
else if (filterLower.StartsWith("externalid eq "))
|
||||
{
|
||||
externalIdFilter = filter.Substring(14).Trim('"');
|
||||
}
|
||||
|
@ -9,7 +9,7 @@
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</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="NSubstitute" Version="$(NSubstituteVersion)" />
|
||||
<PackageReference Include="xunit" Version="$(XUnitVersion)" />
|
||||
|
@ -14,6 +14,10 @@
|
||||
<ProjectReference Include="..\Core\Core.csproj" />
|
||||
<ProjectReference Include="..\..\util\SqliteMigrations\SqliteMigrations.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Folder Include="Billing\Controllers\" />
|
||||
<Folder Include="Billing\Models\" />
|
||||
</ItemGroup>
|
||||
|
||||
<Choose>
|
||||
<When Condition="!$(DefineConstants.Contains('OSS'))">
|
||||
|
@ -367,7 +367,7 @@ public class ProvidersController : Controller
|
||||
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");
|
||||
}
|
||||
|
@ -174,18 +174,15 @@
|
||||
|
||||
<div class="d-flex mt-4">
|
||||
<button type="submit" class="btn btn-primary" form="edit-form">Save</button>
|
||||
@if (FeatureService.IsEnabled(FeatureFlagKeys.EnableDeleteProvider))
|
||||
{
|
||||
<div class="ml-auto d-flex">
|
||||
<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>
|
||||
<div class="ml-auto d-flex">
|
||||
<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 class="btn btn-outline-danger ml-2" onclick="openDeleteModal(@Model.ProviderOrganizations.Count())">Delete</button>
|
||||
<button id="deleteBtn" hidden="hidden" data-toggle="modal" data-target="#DeleteModal"></button>
|
||||
<button class="btn btn-outline-danger ml-2" onclick="openDeleteModal(@Model.ProviderOrganizations.Count())">Delete</button>
|
||||
<button id="deleteBtn" hidden="hidden" data-toggle="modal" data-target="#DeleteModal"></button>
|
||||
|
||||
<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>
|
||||
}
|
||||
|
@ -20,9 +20,10 @@
|
||||
|
||||
function deleteProvider(id) {
|
||||
const providerName = $('#DeleteModal input#provider-name').val();
|
||||
const encodedProviderName = encodeURIComponent(providerName);
|
||||
$.ajax({
|
||||
type: "POST",
|
||||
url: `@Url.Action("Delete", "Providers")?id=${id}&providerName=${providerName}`,
|
||||
url: `@Url.Action("Delete", "Providers")?id=${id}&providerName=${encodedProviderName}`,
|
||||
dataType: 'json',
|
||||
contentType: false,
|
||||
processData: false,
|
||||
|
83
src/Admin/Billing/Controllers/MigrateProvidersController.cs
Normal file
83
src/Admin/Billing/Controllers/MigrateProvidersController.cs
Normal 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()
|
||||
: [];
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
10
src/Admin/Billing/Models/MigrateProvidersRequestModel.cs
Normal file
10
src/Admin/Billing/Models/MigrateProvidersRequestModel.cs
Normal 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; }
|
||||
}
|
@ -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_'.");
|
||||
}
|
||||
}
|
||||
}
|
@ -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; }
|
||||
}
|
@ -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
|
||||
}
|
39
src/Admin/Billing/Views/MigrateProviders/Details.cshtml
Normal file
39
src/Admin/Billing/Views/MigrateProviders/Details.cshtml
Normal 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>
|
46
src/Admin/Billing/Views/MigrateProviders/Index.cshtml
Normal file
46
src/Admin/Billing/Views/MigrateProviders/Index.cshtml
Normal 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>
|
28
src/Admin/Billing/Views/MigrateProviders/Results.cshtml
Normal file
28
src/Admin/Billing/Views/MigrateProviders/Results.cshtml
Normal 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>
|
25
src/Admin/Billing/Views/ProcessStripeEvents/Index.cshtml
Normal file
25
src/Admin/Billing/Views/ProcessStripeEvents/Index.cshtml
Normal 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>
|
49
src/Admin/Billing/Views/ProcessStripeEvents/Results.cshtml
Normal file
49
src/Admin/Billing/Views/ProcessStripeEvents/Results.cshtml
Normal 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>
|
5
src/Admin/Billing/Views/_ViewImports.cshtml
Normal file
5
src/Admin/Billing/Views/_ViewImports.cshtml
Normal 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"
|
3
src/Admin/Billing/Views/_ViewStart.cshtml
Normal file
3
src/Admin/Billing/Views/_ViewStart.cshtml
Normal file
@ -0,0 +1,3 @@
|
||||
@{
|
||||
Layout = "_Layout";
|
||||
}
|
@ -1,11 +1,11 @@
|
||||
using Bit.Admin.Enums;
|
||||
#nullable enable
|
||||
|
||||
using Bit.Admin.Enums;
|
||||
using Bit.Admin.Models;
|
||||
using Bit.Admin.Services;
|
||||
using Bit.Admin.Utilities;
|
||||
using Bit.Core;
|
||||
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
@ -24,9 +24,9 @@ public class UsersController : Controller
|
||||
private readonly IPaymentService _paymentService;
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
private readonly IAccessControlService _accessControlService;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IUserService _userService;
|
||||
|
||||
public UsersController(
|
||||
IUserRepository userRepository,
|
||||
@ -34,18 +34,18 @@ public class UsersController : Controller
|
||||
IPaymentService paymentService,
|
||||
GlobalSettings globalSettings,
|
||||
IAccessControlService accessControlService,
|
||||
ICurrentContext currentContext,
|
||||
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
|
||||
IFeatureService featureService,
|
||||
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery)
|
||||
IUserService userService)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
_cipherRepository = cipherRepository;
|
||||
_paymentService = paymentService;
|
||||
_globalSettings = globalSettings;
|
||||
_accessControlService = accessControlService;
|
||||
_currentContext = currentContext;
|
||||
_featureService = featureService;
|
||||
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
|
||||
_featureService = featureService;
|
||||
_userService = userService;
|
||||
}
|
||||
|
||||
[RequirePermission(Permission.User_List_View)]
|
||||
@ -64,19 +64,26 @@ public class UsersController : Controller
|
||||
var skip = (page - 1) * count;
|
||||
var users = await _userRepository.SearchAsync(email, skip, count);
|
||||
|
||||
var userModels = new List<UserViewModel>();
|
||||
|
||||
if (_featureService.IsEnabled(FeatureFlagKeys.MembersTwoFAQueryOptimization))
|
||||
{
|
||||
var user2Fa = (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)
|
||||
var twoFactorAuthLookup = (await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(users.Select(u => u.Id))).ToList();
|
||||
|
||||
userModels = UserViewModel.MapViewModels(users, twoFactorAuthLookup).ToList();
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var user in users)
|
||||
{
|
||||
TempData["UsersTwoFactorIsEnabled"] = user2Fa;
|
||||
var isTwoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user);
|
||||
userModels.Add(UserViewModel.MapViewModel(user, isTwoFactorEnabled));
|
||||
}
|
||||
}
|
||||
|
||||
return View(new UsersModel
|
||||
{
|
||||
Items = users as List<User>,
|
||||
Items = userModels,
|
||||
Email = string.IsNullOrWhiteSpace(email) ? null : email,
|
||||
Page = page,
|
||||
Count = count,
|
||||
@ -87,13 +94,17 @@ public class UsersController : Controller
|
||||
public async Task<IActionResult> View(Guid id)
|
||||
{
|
||||
var user = await _userRepository.GetByIdAsync(id);
|
||||
|
||||
if (user == null)
|
||||
{
|
||||
return RedirectToAction("Index");
|
||||
}
|
||||
|
||||
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)]
|
||||
@ -108,7 +119,8 @@ public class UsersController : Controller
|
||||
var ciphers = await _cipherRepository.GetManyByUserIdAsync(id);
|
||||
var billingInfo = await _paymentService.GetBillingAsync(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]
|
||||
|
@ -47,5 +47,7 @@ public enum Permission
|
||||
Tools_GenerateLicenseFile,
|
||||
Tools_ManageTaxRates,
|
||||
Tools_ManageStripeSubscriptions,
|
||||
Tools_CreateEditTransaction
|
||||
Tools_CreateEditTransaction,
|
||||
Tools_ProcessStripeEvents,
|
||||
Tools_MigrateProviders
|
||||
}
|
||||
|
@ -7,18 +7,23 @@ using Bit.Core.Vault.Entities;
|
||||
|
||||
namespace Bit.Admin.Models;
|
||||
|
||||
public class UserEditModel : UserViewModel
|
||||
public class UserEditModel
|
||||
{
|
||||
public UserEditModel() { }
|
||||
public UserEditModel()
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
public UserEditModel(
|
||||
User user,
|
||||
bool isTwoFactorEnabled,
|
||||
IEnumerable<Cipher> ciphers,
|
||||
BillingInfo billingInfo,
|
||||
BillingHistoryInfo billingHistoryInfo,
|
||||
GlobalSettings globalSettings)
|
||||
: base(user, ciphers)
|
||||
{
|
||||
User = UserViewModel.MapViewModel(user, isTwoFactorEnabled, ciphers);
|
||||
|
||||
BillingInfo = billingInfo;
|
||||
BillingHistoryInfo = billingHistoryInfo;
|
||||
BraintreeMerchantId = globalSettings.Braintree.MerchantId;
|
||||
@ -35,32 +40,32 @@ public class UserEditModel : UserViewModel
|
||||
PremiumExpirationDate = user.PremiumExpirationDate;
|
||||
}
|
||||
|
||||
public BillingInfo BillingInfo { get; set; }
|
||||
public BillingHistoryInfo BillingHistoryInfo { get; set; }
|
||||
public UserViewModel User { get; init; }
|
||||
public BillingInfo BillingInfo { get; init; }
|
||||
public BillingHistoryInfo BillingHistoryInfo { get; init; }
|
||||
public string RandomLicenseKey => CoreHelpers.SecureRandomString(20);
|
||||
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")]
|
||||
public string Name { get; set; }
|
||||
public string Name { get; init; }
|
||||
[Required]
|
||||
[Display(Name = "Email")]
|
||||
public string Email { get; set; }
|
||||
public string Email { get; init; }
|
||||
[Display(Name = "Email Verified")]
|
||||
public bool EmailVerified { get; set; }
|
||||
public bool EmailVerified { get; init; }
|
||||
[Display(Name = "Premium")]
|
||||
public bool Premium { get; set; }
|
||||
public bool Premium { get; init; }
|
||||
[Display(Name = "Max. Storage GB")]
|
||||
public short? MaxStorageGb { get; set; }
|
||||
public short? MaxStorageGb { get; init; }
|
||||
[Display(Name = "Gateway")]
|
||||
public Core.Enums.GatewayType? Gateway { get; set; }
|
||||
public Core.Enums.GatewayType? Gateway { get; init; }
|
||||
[Display(Name = "Gateway Customer Id")]
|
||||
public string GatewayCustomerId { get; set; }
|
||||
public string GatewayCustomerId { get; init; }
|
||||
[Display(Name = "Gateway Subscription Id")]
|
||||
public string GatewaySubscriptionId { get; set; }
|
||||
public string GatewaySubscriptionId { get; init; }
|
||||
[Display(Name = "License Key")]
|
||||
public string LicenseKey { get; set; }
|
||||
public string LicenseKey { get; init; }
|
||||
[Display(Name = "Premium Expiration Date")]
|
||||
public DateTime? PremiumExpirationDate { get; set; }
|
||||
|
||||
public DateTime? PremiumExpirationDate { get; init; }
|
||||
}
|
||||
|
@ -1,18 +1,131 @@
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Vault.Entities;
|
||||
|
||||
namespace Bit.Admin.Models;
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
public User User { get; set; }
|
||||
public int CipherCount { get; set; }
|
||||
public static IEnumerable<UserViewModel> MapViewModels(
|
||||
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;
|
||||
}
|
||||
|
@ -1,8 +1,6 @@
|
||||
using Bit.Core.Entities;
|
||||
namespace Bit.Admin.Models;
|
||||
|
||||
namespace Bit.Admin.Models;
|
||||
|
||||
public class UsersModel : PagedModel<User>
|
||||
public class UsersModel : PagedModel<UserViewModel>
|
||||
{
|
||||
public string Email { get; set; }
|
||||
public string Action { get; set; }
|
||||
|
@ -10,6 +10,7 @@ using Microsoft.AspNetCore.Mvc.Razor;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Bit.Admin.Services;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Migration;
|
||||
|
||||
#if !OSS
|
||||
using Bit.Commercial.Core.Utilities;
|
||||
@ -88,7 +89,10 @@ public class Startup
|
||||
services.AddBaseServices(globalSettings);
|
||||
services.AddDefaultServices(globalSettings);
|
||||
services.AddScoped<IAccessControlService, AccessControlService>();
|
||||
services.AddDistributedCache(globalSettings);
|
||||
services.AddBillingOperations();
|
||||
services.AddHttpClient();
|
||||
services.AddProviderMigration();
|
||||
|
||||
#if OSS
|
||||
services.AddOosServices();
|
||||
@ -108,6 +112,7 @@ public class Startup
|
||||
{
|
||||
o.ViewLocationFormats.Add("/Auth/Views/{1}/{0}.cshtml");
|
||||
o.ViewLocationFormats.Add("/AdminConsole/Views/{1}/{0}.cshtml");
|
||||
o.ViewLocationFormats.Add("/Billing/Views/{1}/{0}.cshtml");
|
||||
});
|
||||
|
||||
// Jobs service
|
||||
|
@ -161,7 +161,9 @@ public static class RolePermissionMapping
|
||||
Permission.Tools_GenerateLicenseFile,
|
||||
Permission.Tools_ManageTaxRates,
|
||||
Permission.Tools_ManageStripeSubscriptions,
|
||||
Permission.Tools_CreateEditTransaction
|
||||
Permission.Tools_CreateEditTransaction,
|
||||
Permission.Tools_ProcessStripeEvents,
|
||||
Permission.Tools_MigrateProviders
|
||||
}
|
||||
},
|
||||
{ "sales", new List<Permission>
|
||||
|
@ -14,6 +14,8 @@
|
||||
var canGenerateLicense = AccessControlService.UserHasPermission(Permission.Tools_GenerateLicenseFile);
|
||||
var canManageTaxRates = AccessControlService.UserHasPermission(Permission.Tools_ManageTaxRates);
|
||||
var canManageStripeSubscriptions = AccessControlService.UserHasPermission(Permission.Tools_ManageStripeSubscriptions);
|
||||
var canProcessStripeEvents = AccessControlService.UserHasPermission(Permission.Tools_ProcessStripeEvents);
|
||||
var canMigrateProviders = AccessControlService.UserHasPermission(Permission.Tools_MigrateProviders);
|
||||
|
||||
var canViewTools = canChargeBraintree || canCreateTransaction || canPromoteAdmin ||
|
||||
canGenerateLicense || canManageTaxRates || canManageStripeSubscriptions;
|
||||
@ -107,6 +109,18 @@
|
||||
Manage Stripe Subscriptions
|
||||
</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>
|
||||
</li>
|
||||
}
|
||||
|
@ -86,7 +86,7 @@
|
||||
@if (canViewUserInformation)
|
||||
{
|
||||
<h2>User Information</h2>
|
||||
@await Html.PartialAsync("_ViewInformation", Model)
|
||||
@await Html.PartialAsync("_ViewInformation", Model.User)
|
||||
}
|
||||
@if (canViewBillingInformation)
|
||||
{
|
||||
|
@ -1,6 +1,4 @@
|
||||
@model UsersModel
|
||||
@inject Bit.Core.Services.IUserService userService
|
||||
@inject Bit.Core.Services.IFeatureService featureService
|
||||
@{
|
||||
ViewData["Title"] = "Users";
|
||||
}
|
||||
@ -16,100 +14,88 @@
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Email</th>
|
||||
<th style="width: 150px;">Created</th>
|
||||
<th style="width: 170px; min-width: 170px;">Details</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Email</th>
|
||||
<th style="width: 150px;">Created</th>
|
||||
<th style="width: 170px; min-width: 170px;">Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@if(!Model.Items.Any())
|
||||
@if (!Model.Items.Any())
|
||||
{
|
||||
<tr>
|
||||
<td colspan="4">No results to list.</td>
|
||||
</tr>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (var user in Model.Items)
|
||||
{
|
||||
<tr>
|
||||
<td colspan="4">No results to list.</td>
|
||||
<td>
|
||||
<a asp-action="@Model.Action" asp-route-id="@user.Id">@user.Email</a>
|
||||
</td>
|
||||
<td>
|
||||
<span title="@user.CreationDate.ToString()">
|
||||
@user.CreationDate.ToShortDateString()
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
@if (user.Premium)
|
||||
{
|
||||
<i class="fa fa-star fa-lg fa-fw"
|
||||
title="Premium, expires @(user.PremiumExpirationDate?.ToShortDateString() ?? "-")">
|
||||
</i>
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="fa fa-star-o fa-lg fa-fw text-muted" title="Not Premium"></i>
|
||||
}
|
||||
@if (user.MaxStorageGb.HasValue && user.MaxStorageGb > 1)
|
||||
{
|
||||
<i class="fa fa-plus-square fa-lg fa-fw"
|
||||
title="Additional Storage, @(user.MaxStorageGb - 1) GB">
|
||||
</i>
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="fa fa-plus-square-o fa-lg fa-fw text-muted"
|
||||
title="No Additional Storage">
|
||||
</i>
|
||||
}
|
||||
@if (user.EmailVerified)
|
||||
{
|
||||
<i class="fa fa-check-circle fa-lg fa-fw" title="Email Verified"></i>
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="fa fa-times-circle-o fa-lg fa-fw text-muted" title="Email Not Verified"></i>
|
||||
}
|
||||
@if (user.TwoFactorEnabled)
|
||||
{
|
||||
<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>
|
||||
</tr>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach(var user in Model.Items)
|
||||
{
|
||||
<tr>
|
||||
<td>
|
||||
<a asp-action="@Model.Action" asp-route-id="@user.Id">@user.Email</a>
|
||||
</td>
|
||||
<td>
|
||||
<span title="@user.CreationDate.ToString()">
|
||||
@user.CreationDate.ToShortDateString()
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
@if(user.Premium)
|
||||
{
|
||||
<i class="fa fa-star fa-lg fa-fw"
|
||||
title="Premium, expires @(user.PremiumExpirationDate?.ToShortDateString() ?? "-")"></i>
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="fa fa-star-o fa-lg fa-fw text-muted" title="Not Premium"></i>
|
||||
}
|
||||
@if(user.MaxStorageGb.HasValue && user.MaxStorageGb > 1)
|
||||
{
|
||||
<i class="fa fa-plus-square fa-lg fa-fw"
|
||||
title="Additional Storage, @(user.MaxStorageGb - 1) GB"></i>
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="fa fa-plus-square-o fa-lg fa-fw text-muted"
|
||||
title="No Additional Storage"></i>
|
||||
}
|
||||
@if(user.EmailVerified)
|
||||
{
|
||||
<i class="fa fa-check-circle fa-lg fa-fw" title="Email Verified"></i>
|
||||
}
|
||||
else
|
||||
{
|
||||
<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))
|
||||
{
|
||||
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>
|
||||
}
|
||||
else
|
||||
{
|
||||
<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>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
<ul class="pagination">
|
||||
@if(Model.PreviousPage.HasValue)
|
||||
@if (Model.PreviousPage.HasValue)
|
||||
{
|
||||
<li class="page-item">
|
||||
<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>
|
||||
}
|
||||
else
|
||||
@ -118,11 +104,13 @@
|
||||
<a class="page-link" href="#" tabindex="-1">Previous</a>
|
||||
</li>
|
||||
}
|
||||
@if(Model.NextPage.HasValue)
|
||||
@if (Model.NextPage.HasValue)
|
||||
{
|
||||
<li class="page-item">
|
||||
<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>
|
||||
}
|
||||
else
|
||||
|
@ -1,13 +1,13 @@
|
||||
@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>
|
||||
@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?')">
|
||||
<button class="btn btn-danger" type="submit">Delete</button>
|
||||
</form>
|
||||
|
@ -1,43 +1,42 @@
|
||||
@model UserViewModel
|
||||
@inject Bit.Core.Services.IUserService userService
|
||||
<dl class="row">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<dd class="col-sm-8 col-lg-9">@Model.CipherCount</dd>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
|
@ -13,7 +13,8 @@
|
||||
"internalApi": "http://localhost:4000",
|
||||
"internalVault": "https://localhost:8080",
|
||||
"internalSso": "http://localhost:51822",
|
||||
"internalScim": "http://localhost:44559"
|
||||
"internalScim": "http://localhost:44559",
|
||||
"internalBilling": "http://localhost:44519"
|
||||
},
|
||||
"mail": {
|
||||
"smtp": {
|
||||
|
@ -1,12 +1,13 @@
|
||||
using Bit.Api.AdminConsole.Models.Request.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.Response;
|
||||
using Bit.Api.Vault.AuthorizationHandlers.Collections;
|
||||
using Bit.Api.Vault.AuthorizationHandlers.OrganizationUsers;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
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.Repositories;
|
||||
using Bit.Core.Auth.Enums;
|
||||
@ -44,7 +45,6 @@ public class OrganizationUsersController : Controller
|
||||
private readonly ICountNewSmSeatsRequiredQuery _countNewSmSeatsRequiredQuery;
|
||||
private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand;
|
||||
private readonly IUpdateOrganizationUserCommand _updateOrganizationUserCommand;
|
||||
private readonly IUpdateOrganizationUserGroupsCommand _updateOrganizationUserGroupsCommand;
|
||||
private readonly IAcceptOrgUserCommand _acceptOrgUserCommand;
|
||||
private readonly IAuthorizationService _authorizationService;
|
||||
private readonly IApplicationCacheService _applicationCacheService;
|
||||
@ -52,6 +52,7 @@ public class OrganizationUsersController : Controller
|
||||
private readonly ISsoConfigRepository _ssoConfigRepository;
|
||||
private readonly IOrganizationUserUserDetailsQuery _organizationUserUserDetailsQuery;
|
||||
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
|
||||
private readonly IDeleteManagedOrganizationUserAccountCommand _deleteManagedOrganizationUserAccountCommand;
|
||||
|
||||
|
||||
public OrganizationUsersController(
|
||||
@ -66,14 +67,14 @@ public class OrganizationUsersController : Controller
|
||||
ICountNewSmSeatsRequiredQuery countNewSmSeatsRequiredQuery,
|
||||
IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand,
|
||||
IUpdateOrganizationUserCommand updateOrganizationUserCommand,
|
||||
IUpdateOrganizationUserGroupsCommand updateOrganizationUserGroupsCommand,
|
||||
IAcceptOrgUserCommand acceptOrgUserCommand,
|
||||
IAuthorizationService authorizationService,
|
||||
IApplicationCacheService applicationCacheService,
|
||||
IFeatureService featureService,
|
||||
ISsoConfigRepository ssoConfigRepository,
|
||||
IOrganizationUserUserDetailsQuery organizationUserUserDetailsQuery,
|
||||
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery)
|
||||
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
|
||||
IDeleteManagedOrganizationUserAccountCommand deleteManagedOrganizationUserAccountCommand)
|
||||
{
|
||||
_organizationRepository = organizationRepository;
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
@ -86,7 +87,6 @@ public class OrganizationUsersController : Controller
|
||||
_countNewSmSeatsRequiredQuery = countNewSmSeatsRequiredQuery;
|
||||
_updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand;
|
||||
_updateOrganizationUserCommand = updateOrganizationUserCommand;
|
||||
_updateOrganizationUserGroupsCommand = updateOrganizationUserGroupsCommand;
|
||||
_acceptOrgUserCommand = acceptOrgUserCommand;
|
||||
_authorizationService = authorizationService;
|
||||
_applicationCacheService = applicationCacheService;
|
||||
@ -94,6 +94,7 @@ public class OrganizationUsersController : Controller
|
||||
_ssoConfigRepository = ssoConfigRepository;
|
||||
_organizationUserUserDetailsQuery = organizationUserUserDetailsQuery;
|
||||
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
|
||||
_deleteManagedOrganizationUserAccountCommand = deleteManagedOrganizationUserAccountCommand;
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
@ -115,11 +116,27 @@ public class OrganizationUsersController : Controller
|
||||
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("")]
|
||||
public async Task<ListResponseModel<OrganizationUserUserDetailsResponseModel>> Get(Guid orgId, bool includeGroups = false, bool includeCollections = false)
|
||||
{
|
||||
var authorized = (await _authorizationService.AuthorizeAsync(
|
||||
User, OrganizationUserOperations.ReadAll(orgId))).Succeeded;
|
||||
User, new OrganizationScope(orgId), OrganizationUserUserDetailsOperations.ReadAll)).Succeeded;
|
||||
if (!authorized)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
@ -528,6 +545,59 @@ public class OrganizationUsersController : Controller
|
||||
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")]
|
||||
[HttpPut("{id}/revoke")]
|
||||
public async Task RevokeAsync(Guid orgId, Guid id)
|
||||
|
@ -171,6 +171,21 @@ public class OrganizationsController : Controller
|
||||
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}")]
|
||||
[HttpPost("{id}")]
|
||||
public async Task<OrganizationResponseModel> Put(string id, [FromBody] OrganizationUpdateRequestModel model)
|
||||
|
@ -14,42 +14,63 @@ public class OrganizationCreateRequestModel : IValidatableObject
|
||||
[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; }
|
||||
@ -99,16 +120,19 @@ public class OrganizationCreateRequestModel : IValidatableObject
|
||||
{
|
||||
yield return new ValidationResult("Payment required.", new string[] { nameof(PaymentToken) });
|
||||
}
|
||||
|
||||
if (PlanType != PlanType.Free && !PaymentMethodType.HasValue)
|
||||
{
|
||||
yield return new ValidationResult("Payment method type required.",
|
||||
new string[] { nameof(PaymentMethodType) });
|
||||
}
|
||||
|
||||
if (PlanType != PlanType.Free && string.IsNullOrWhiteSpace(BillingAddressCountry))
|
||||
{
|
||||
yield return new ValidationResult("Country required.",
|
||||
new string[] { nameof(BillingAddressCountry) });
|
||||
}
|
||||
|
||||
if (PlanType != PlanType.Free && BillingAddressCountry == "US" &&
|
||||
string.IsNullOrWhiteSpace(BillingAddressPostalCode))
|
||||
{
|
||||
@ -117,3 +141,4 @@ public class OrganizationCreateRequestModel : IValidatableObject
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -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; }
|
||||
}
|
@ -84,6 +84,29 @@ public class OrganizationUserDetailsResponseModel : OrganizationUserResponseMode
|
||||
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 OrganizationUserUserDetailsResponseModel(OrganizationUserUserDetails organizationUser,
|
||||
|
@ -35,7 +35,7 @@
|
||||
<PackageReference Include="AspNetCore.HealthChecks.SqlServer" Version="8.0.0" />
|
||||
<PackageReference Include="AspNetCore.HealthChecks.Uris" Version="8.0.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>
|
||||
|
||||
</Project>
|
||||
|
@ -443,10 +443,11 @@ public class AccountsController : Controller
|
||||
|
||||
var twoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user);
|
||||
var hasPremiumFromOrg = await _userService.HasPremiumFromOrganization(user);
|
||||
var managedByOrganizationId = await GetManagedByOrganizationIdAsync(user);
|
||||
|
||||
var response = new ProfileResponseModel(user, organizationUserDetails, providerUserDetails,
|
||||
providerUserOrganizationDetails, twoFactorEnabled,
|
||||
hasPremiumFromOrg);
|
||||
hasPremiumFromOrg, managedByOrganizationId);
|
||||
return response;
|
||||
}
|
||||
|
||||
@ -471,7 +472,12 @@ public class AccountsController : Controller
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@ -485,7 +491,12 @@ public class AccountsController : Controller
|
||||
throw new UnauthorizedAccessException();
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@ -633,7 +644,12 @@ public class AccountsController : Controller
|
||||
BillingAddressCountry = model.Country,
|
||||
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
|
||||
{
|
||||
UserProfile = profile,
|
||||
@ -920,4 +936,15 @@ public class AccountsController : Controller
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
@ -14,7 +14,8 @@ public class ProfileResponseModel : ResponseModel
|
||||
IEnumerable<ProviderUserProviderDetails> providerUserDetails,
|
||||
IEnumerable<ProviderUserOrganizationDetails> providerUserOrganizationDetails,
|
||||
bool twoFactorEnabled,
|
||||
bool premiumFromOrganization) : base("profile")
|
||||
bool premiumFromOrganization,
|
||||
Guid? managedByOrganizationId) : base("profile")
|
||||
{
|
||||
if (user == null)
|
||||
{
|
||||
@ -40,6 +41,7 @@ public class ProfileResponseModel : ResponseModel
|
||||
Providers = providerUserDetails?.Select(p => new ProfileProviderResponseModel(p));
|
||||
ProviderOrganizations =
|
||||
providerUserOrganizationDetails?.Select(po => new ProfileProviderOrganizationResponseModel(po));
|
||||
ManagedByOrganizationId = managedByOrganizationId;
|
||||
}
|
||||
|
||||
public ProfileResponseModel() : base("profile")
|
||||
@ -61,6 +63,7 @@ public class ProfileResponseModel : ResponseModel
|
||||
public bool UsesKeyConnector { get; set; }
|
||||
public string AvatarColor { get; set; }
|
||||
public DateTime CreationDate { get; set; }
|
||||
public Guid? ManagedByOrganizationId { get; set; }
|
||||
public IEnumerable<ProfileOrganizationResponseModel> Organizations { get; set; }
|
||||
public IEnumerable<ProfileProviderResponseModel> Providers { get; set; }
|
||||
public IEnumerable<ProfileProviderOrganizationResponseModel> ProviderOrganizations { get; set; }
|
||||
|
@ -1,6 +1,5 @@
|
||||
using Bit.Api.Vault.AuthorizationHandlers.Collections;
|
||||
using Bit.Api.Vault.AuthorizationHandlers.Groups;
|
||||
using Bit.Api.Vault.AuthorizationHandlers.OrganizationUsers;
|
||||
using Bit.Core.IdentityServer;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Utilities;
|
||||
@ -100,6 +99,5 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<IAuthorizationHandler, BulkCollectionAuthorizationHandler>();
|
||||
services.AddScoped<IAuthorizationHandler, CollectionAuthorizationHandler>();
|
||||
services.AddScoped<IAuthorizationHandler, GroupAuthorizationHandler>();
|
||||
services.AddScoped<IAuthorizationHandler, OrganizationUserAuthorizationHandler>();
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
using Bit.Api.Vault.Models.Response;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
@ -6,6 +7,7 @@ using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
@ -30,6 +32,7 @@ public class SyncController : Controller
|
||||
private readonly IPolicyRepository _policyRepository;
|
||||
private readonly ISendRepository _sendRepository;
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
private readonly IFeatureService _featureService;
|
||||
|
||||
public SyncController(
|
||||
IUserService userService,
|
||||
@ -41,7 +44,8 @@ public class SyncController : Controller
|
||||
IProviderUserRepository providerUserRepository,
|
||||
IPolicyRepository policyRepository,
|
||||
ISendRepository sendRepository,
|
||||
GlobalSettings globalSettings)
|
||||
GlobalSettings globalSettings,
|
||||
IFeatureService featureService)
|
||||
{
|
||||
_userService = userService;
|
||||
_folderRepository = folderRepository;
|
||||
@ -53,6 +57,7 @@ public class SyncController : Controller
|
||||
_policyRepository = policyRepository;
|
||||
_sendRepository = sendRepository;
|
||||
_globalSettings = globalSettings;
|
||||
_featureService = featureService;
|
||||
}
|
||||
|
||||
[HttpGet("")]
|
||||
@ -90,9 +95,23 @@ public class SyncController : Controller
|
||||
|
||||
var userTwoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user);
|
||||
var userHasPremiumFromOrganization = await _userService.HasPremiumFromOrganization(user);
|
||||
var response = new SyncResponseModel(_globalSettings, user, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationUserDetails,
|
||||
providerUserDetails, providerUserOrganizationDetails, folders, collections, ciphers,
|
||||
collectionCiphersGroupDict, excludeDomains, policies, sends);
|
||||
var managedByOrganizationId = await GetManagedByOrganizationIdAsync(user, organizationUserDetails);
|
||||
|
||||
var response = new SyncResponseModel(_globalSettings, user, userTwoFactorEnabled, userHasPremiumFromOrganization,
|
||||
managedByOrganizationId, organizationUserDetails, providerUserDetails, providerUserOrganizationDetails,
|
||||
folders, collections, ciphers, collectionCiphersGroupDict, excludeDomains, policies, sends);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
@ -21,6 +21,7 @@ public class SyncResponseModel : ResponseModel
|
||||
User user,
|
||||
bool userTwoFactorEnabled,
|
||||
bool userHasPremiumFromOrganization,
|
||||
Guid? managedByOrganizationId,
|
||||
IEnumerable<OrganizationUserOrganizationDetails> organizationUserDetails,
|
||||
IEnumerable<ProviderUserProviderDetails> providerUserDetails,
|
||||
IEnumerable<ProviderUserOrganizationDetails> providerUserOrganizationDetails,
|
||||
@ -34,7 +35,7 @@ public class SyncResponseModel : ResponseModel
|
||||
: base("sync")
|
||||
{
|
||||
Profile = new ProfileResponseModel(user, organizationUserDetails, providerUserDetails,
|
||||
providerUserOrganizationDetails, userTwoFactorEnabled, userHasPremiumFromOrganization);
|
||||
providerUserOrganizationDetails, userTwoFactorEnabled, userHasPremiumFromOrganization, managedByOrganizationId);
|
||||
Folders = folders.Select(f => new FolderResponseModel(f));
|
||||
Ciphers = ciphers.Select(c => new CipherDetailsResponseModel(c, globalSettings, collectionCiphersDict));
|
||||
Collections = collections?.Select(
|
||||
|
68
src/Billing/Controllers/RecoveryController.cs
Normal file
68
src/Billing/Controllers/RecoveryController.cs
Normal 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
|
||||
};
|
||||
}
|
9
src/Billing/Models/Recovery/EventsRequestBody.cs
Normal file
9
src/Billing/Models/Recovery/EventsRequestBody.cs
Normal 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; }
|
||||
}
|
31
src/Billing/Models/Recovery/EventsResponseBody.cs
Normal file
31
src/Billing/Models/Recovery/EventsResponseBody.cs
Normal 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; }
|
||||
}
|
@ -16,6 +16,12 @@ public interface IStripeFacade
|
||||
RequestOptions requestOptions = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<Event> GetEvent(
|
||||
string eventId,
|
||||
EventGetOptions eventGetOptions = null,
|
||||
RequestOptions requestOptions = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<Invoice> GetInvoice(
|
||||
string invoiceId,
|
||||
InvoiceGetOptions invoiceGetOptions = null,
|
||||
|
@ -2,34 +2,63 @@
|
||||
|
||||
namespace Bit.Billing.Services.Implementations;
|
||||
|
||||
public class InvoiceCreatedHandler : IInvoiceCreatedHandler
|
||||
public class InvoiceCreatedHandler(
|
||||
ILogger<InvoiceCreatedHandler> logger,
|
||||
IStripeEventService stripeEventService,
|
||||
IStripeEventUtilityService stripeEventUtilityService,
|
||||
IProviderEventService providerEventService)
|
||||
: IInvoiceCreatedHandler
|
||||
{
|
||||
private readonly IStripeEventService _stripeEventService;
|
||||
private readonly IStripeEventUtilityService _stripeEventUtilityService;
|
||||
private readonly IProviderEventService _providerEventService;
|
||||
|
||||
public InvoiceCreatedHandler(
|
||||
IStripeEventService stripeEventService,
|
||||
IStripeEventUtilityService stripeEventUtilityService,
|
||||
IProviderEventService providerEventService)
|
||||
{
|
||||
_stripeEventService = stripeEventService;
|
||||
_stripeEventUtilityService = stripeEventUtilityService;
|
||||
_providerEventService = providerEventService;
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// <param name="parsedEvent"></param>
|
||||
public async Task HandleAsync(Event parsedEvent)
|
||||
{
|
||||
var invoice = await _stripeEventService.GetInvoice(parsedEvent, true);
|
||||
if (_stripeEventUtilityService.ShouldAttemptToPayInvoice(invoice))
|
||||
try
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -206,6 +206,12 @@ public class StripeEventUtilityService : IStripeEventUtilityService
|
||||
transaction.PaymentMethodType = PaymentMethodType.Card;
|
||||
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)
|
||||
{
|
||||
var achDebit = charge.PaymentMethodDetails.AchDebit;
|
||||
|
@ -6,6 +6,7 @@ public class StripeFacade : IStripeFacade
|
||||
{
|
||||
private readonly ChargeService _chargeService = new();
|
||||
private readonly CustomerService _customerService = new();
|
||||
private readonly EventService _eventService = new();
|
||||
private readonly InvoiceService _invoiceService = new();
|
||||
private readonly PaymentMethodService _paymentMethodService = new();
|
||||
private readonly SubscriptionService _subscriptionService = new();
|
||||
@ -19,6 +20,13 @@ public class StripeFacade : IStripeFacade
|
||||
CancellationToken cancellationToken = default) =>
|
||||
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(
|
||||
string customerId,
|
||||
CustomerGetOptions customerGetOptions = null,
|
||||
|
@ -32,12 +32,14 @@ public class SubscriptionDeletedHandler : ISubscriptionDeletedHandler
|
||||
var (organizationId, userId, providerId) = _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata);
|
||||
var subCanceled = subscription.Status == StripeSubscriptionStatus.Canceled;
|
||||
|
||||
const string providerMigrationCancellationComment = "Cancelled as part of provider migration to Consolidated Billing";
|
||||
|
||||
if (!subCanceled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (organizationId.HasValue)
|
||||
if (organizationId.HasValue && subscription is not { CancellationDetails.Comment: providerMigrationCancellationComment })
|
||||
{
|
||||
await _organizationService.DisableAsync(organizationId.Value, subscription.CurrentPeriodEnd);
|
||||
}
|
||||
|
@ -94,6 +94,7 @@ public class Organization : ITableObject<Guid>, IStorableSubscriber, IRevisable,
|
||||
/// they have Can Manage permissions for.
|
||||
/// </summary>
|
||||
public bool LimitCollectionCreationDeletion { get; set; }
|
||||
|
||||
/// <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 false, users generally need collection-level permissions to read/write a collection or its items.
|
||||
|
@ -9,4 +9,5 @@ public enum ScimProviderType : byte
|
||||
JumpCloud = 4,
|
||||
GoogleWorkspace = 5,
|
||||
Rippling = 6,
|
||||
Ping = 7,
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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) };
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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) };
|
||||
}
|
@ -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);
|
||||
await _referenceEventService.RaiseEventAsync(
|
||||
new ReferenceEvent(ReferenceEventType.AdjustSeats, organization, _currentContext)
|
||||
@ -590,10 +595,20 @@ public class OrganizationService : IOrganizationService
|
||||
}
|
||||
else
|
||||
{
|
||||
await _paymentService.PurchaseOrganizationAsync(organization, signup.PaymentMethodType.Value,
|
||||
signup.PaymentToken, plan, signup.AdditionalStorageGb, signup.AdditionalSeats,
|
||||
signup.PremiumAccessAddon, signup.TaxInfo, signup.IsFromProvider, signup.AdditionalSmSeats.GetValueOrDefault(),
|
||||
signup.AdditionalServiceAccounts.GetValueOrDefault(), signup.IsFromSecretsManagerTrial);
|
||||
if (signup.PaymentMethodType != null)
|
||||
{
|
||||
await _paymentService.PurchaseOrganizationAsync(organization, signup.PaymentMethodType.Value,
|
||||
signup.PaymentToken, plan, signup.AdditionalStorageGb, signup.AdditionalSeats,
|
||||
signup.PremiumAccessAddon, signup.TaxInfo, signup.IsFromProvider, signup.AdditionalSmSeats.GetValueOrDefault(),
|
||||
signup.AdditionalServiceAccounts.GetValueOrDefault(), signup.IsFromSecretsManagerTrial);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _paymentService.PurchaseOrganizationNoPaymentMethod(organization, plan, signup.AdditionalSeats,
|
||||
signup.PremiumAccessAddon, signup.AdditionalSmSeats.GetValueOrDefault(),
|
||||
signup.AdditionalServiceAccounts.GetValueOrDefault(), signup.IsFromSecretsManagerTrial);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@ -1176,12 +1191,7 @@ public class OrganizationService : IOrganizationService
|
||||
var currentOrganization = await _organizationRepository.GetByIdAsync(organization.Id);
|
||||
|
||||
// Revert autoscaling
|
||||
if (initialSeatCount.HasValue && currentOrganization.Seats.HasValue && currentOrganization.Seats.Value != initialSeatCount.Value)
|
||||
{
|
||||
await AdjustSeatsAsync(organization, initialSeatCount.Value - currentOrganization.Seats.Value);
|
||||
}
|
||||
|
||||
// Revert SmSeat autoscaling
|
||||
// Do this first so that SmSeats never exceed PM seats (due to current billing requirements)
|
||||
if (initialSmSeatCount.HasValue && currentOrganization.SmSeats.HasValue &&
|
||||
currentOrganization.SmSeats.Value != initialSmSeatCount.Value)
|
||||
{
|
||||
@ -1192,6 +1202,11 @@ public class OrganizationService : IOrganizationService
|
||||
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);
|
||||
}
|
||||
|
||||
|
@ -28,6 +28,11 @@ public static class StripeConstants
|
||||
public const string TaxIdInvalid = "tax_id_invalid";
|
||||
}
|
||||
|
||||
public static class PaymentBehavior
|
||||
{
|
||||
public const string DefaultIncomplete = "default_incomplete";
|
||||
}
|
||||
|
||||
public static class PaymentMethodTypes
|
||||
{
|
||||
public const string Card = "card";
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
@ -12,6 +12,7 @@ public static class ServiceCollectionExtensions
|
||||
public static void AddBillingOperations(this IServiceCollection services)
|
||||
{
|
||||
services.AddTransient<IOrganizationBillingService, OrganizationBillingService>();
|
||||
services.AddTransient<IPremiumUserBillingService, PremiumUserBillingService>();
|
||||
services.AddTransient<ISetupIntentCache, SetupIntentDistributedCache>();
|
||||
services.AddTransient<ISubscriberService, SubscriberService>();
|
||||
}
|
||||
|
23
src/Core/Billing/Migration/Models/ClientMigrationTracker.cs
Normal file
23
src/Core/Billing/Migration/Models/ClientMigrationTracker.cs
Normal 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;
|
||||
}
|
45
src/Core/Billing/Migration/Models/ProviderMigrationResult.cs
Normal file
45
src/Core/Billing/Migration/Models/ProviderMigrationResult.cs
Normal 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; }
|
||||
}
|
@ -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;
|
||||
}
|
15
src/Core/Billing/Migration/ServiceCollectionExtensions.cs
Normal file
15
src/Core/Billing/Migration/ServiceCollectionExtensions.cs
Normal 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>();
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
|
||||
namespace Bit.Core.Billing.Migration.Services;
|
||||
|
||||
public interface IOrganizationMigrator
|
||||
{
|
||||
Task Migrate(Guid providerId, Organization organization);
|
||||
}
|
10
src/Core/Billing/Migration/Services/IProviderMigrator.cs
Normal file
10
src/Core/Billing/Migration/Services/IProviderMigrator.cs
Normal 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);
|
||||
}
|
@ -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)
|
||||
});
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
@ -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
|
||||
}
|
49
src/Core/Billing/Models/Sales/PremiumUserSale.cs
Normal file
49
src/Core/Billing/Models/Sales/PremiumUserSale.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
using Stripe;
|
||||
using Bit.Core.Models.Business;
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Core.Billing.Models;
|
||||
|
||||
@ -11,6 +12,15 @@ public record TaxInformation(
|
||||
string City,
|
||||
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()
|
||||
{
|
||||
var address = new AddressOptions
|
||||
|
@ -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);
|
||||
}
|
@ -9,7 +9,7 @@ namespace Bit.Core.Billing.Services;
|
||||
public interface IOrganizationBillingService
|
||||
{
|
||||
/// <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>
|
||||
/// 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"/>.
|
||||
@ -17,7 +17,7 @@ public interface IOrganizationBillingService
|
||||
/// for the created or existing customer using the provided <see cref="OrganizationSale.SubscriptionSetup"/>.
|
||||
/// </para>
|
||||
/// </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>
|
||||
/// <code>
|
||||
/// var sale = OrganizationSale.From(organization, organizationSignup);
|
||||
|
30
src/Core/Billing/Services/IPremiumUserBillingService.cs
Normal file
30
src/Core/Billing/Services/IPremiumUserBillingService.cs
Normal 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);
|
||||
}
|
@ -34,11 +34,9 @@ public class OrganizationBillingService(
|
||||
{
|
||||
var (organization, customerSetup, subscriptionSetup) = sale;
|
||||
|
||||
List<string> expand = ["tax"];
|
||||
|
||||
var customer = customerSetup != null
|
||||
? await CreateCustomerAsync(organization, customerSetup, expand)
|
||||
: await subscriberService.GetCustomerOrThrow(organization, new CustomerGetOptions { Expand = expand });
|
||||
var customer = string.IsNullOrEmpty(organization.GatewayCustomerId) && customerSetup != null
|
||||
? await CreateCustomerAsync(organization, customerSetup)
|
||||
: await subscriberService.GetCustomerOrThrow(organization, new CustomerGetOptions { Expand = ["tax"] });
|
||||
|
||||
var subscription = await CreateSubscriptionAsync(organization.Id, customer, subscriptionSetup);
|
||||
|
||||
@ -111,31 +109,31 @@ public class OrganizationBillingService(
|
||||
|
||||
private async Task<Customer> CreateCustomerAsync(
|
||||
Organization organization,
|
||||
CustomerSetup customerSetup,
|
||||
List<string>? expand = null)
|
||||
CustomerSetup customerSetup)
|
||||
{
|
||||
var organizationDisplayName = organization.DisplayName();
|
||||
var displayName = organization.DisplayName();
|
||||
|
||||
var customerCreateOptions = new CustomerCreateOptions
|
||||
{
|
||||
Coupon = customerSetup.Coupon,
|
||||
Description = organization.DisplayBusinessName(),
|
||||
Email = organization.BillingEmail,
|
||||
Expand = expand,
|
||||
Expand = ["tax"],
|
||||
InvoiceSettings = new CustomerInvoiceSettingsOptions
|
||||
{
|
||||
CustomFields = [
|
||||
new CustomerInvoiceSettingsCustomFieldOptions
|
||||
{
|
||||
Name = organization.SubscriberType(),
|
||||
Value = organizationDisplayName.Length <= 30
|
||||
? organizationDisplayName
|
||||
: organizationDisplayName[..30]
|
||||
Value = displayName.Length <= 30
|
||||
? displayName
|
||||
: displayName[..30]
|
||||
}]
|
||||
},
|
||||
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;
|
||||
|
||||
var (type, token) = customerSetup.TokenizedPaymentSource;
|
||||
var (paymentMethodType, paymentMethodToken) = customerSetup.TokenizedPaymentSource;
|
||||
|
||||
// ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault
|
||||
switch (type)
|
||||
switch (paymentMethodType)
|
||||
{
|
||||
case PaymentMethodType.BankAccount:
|
||||
{
|
||||
var setupIntent =
|
||||
(await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = token }))
|
||||
(await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = paymentMethodToken }))
|
||||
.FirstOrDefault();
|
||||
|
||||
if (setupIntent == null)
|
||||
{
|
||||
logger.LogError("Cannot create customer for organization ({OrganizationID}) without a setup intent for their bank account", organization.Id);
|
||||
|
||||
throw new BillingException();
|
||||
}
|
||||
|
||||
await setupIntentCache.Set(organization.Id, setupIntent.Id);
|
||||
|
||||
break;
|
||||
}
|
||||
case PaymentMethodType.Card:
|
||||
{
|
||||
customerCreateOptions.PaymentMethod = token;
|
||||
customerCreateOptions.InvoiceSettings.DefaultPaymentMethod = token;
|
||||
customerCreateOptions.PaymentMethod = paymentMethodToken;
|
||||
customerCreateOptions.InvoiceSettings.DefaultPaymentMethod = paymentMethodToken;
|
||||
break;
|
||||
}
|
||||
case PaymentMethodType.PayPal:
|
||||
{
|
||||
braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(organization, token);
|
||||
|
||||
braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(organization, paymentMethodToken);
|
||||
customerCreateOptions.Metadata[BraintreeCustomerIdKey] = braintreeCustomerId;
|
||||
|
||||
break;
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
@ -227,7 +220,6 @@ public class OrganizationBillingService(
|
||||
StripeConstants.ErrorCodes.CustomerTaxLocationInvalid)
|
||||
{
|
||||
await Revert();
|
||||
|
||||
throw new BadRequestException(
|
||||
"Your location wasn't recognized. Please ensure your country and postal code are valid.");
|
||||
}
|
||||
@ -235,7 +227,6 @@ public class OrganizationBillingService(
|
||||
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.");
|
||||
}
|
||||
@ -257,7 +248,7 @@ public class OrganizationBillingService(
|
||||
await setupIntentCache.Remove(organization.Id);
|
||||
break;
|
||||
}
|
||||
case PaymentMethodType.PayPal:
|
||||
case PaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId):
|
||||
{
|
||||
await braintreeGateway.Customer.DeleteAsync(braintreeCustomerId);
|
||||
break;
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -110,7 +110,6 @@ public static class FeatureFlagKeys
|
||||
public const string AC2101UpdateTrialInitiationEmail = "AC-2101-update-trial-initiation-email";
|
||||
public const string EnableConsolidatedBilling = "enable-consolidated-billing";
|
||||
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 EmailVerificationDisableTimingDelays = "email-verification-disable-timing-delays";
|
||||
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 TwoFactorComponentRefactor = "two-factor-component-refactor";
|
||||
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 DeviceTrustLogging = "pm-8285-device-trust-logging";
|
||||
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 EnableNewCardCombinedExpiryAutofill = "enable-new-card-combined-expiry-autofill";
|
||||
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()
|
||||
{
|
||||
|
@ -21,8 +21,8 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AspNetCoreRateLimit.Redis" Version="2.0.0" />
|
||||
<PackageReference Include="AWSSDK.SimpleEmail" Version="3.7.401.11" />
|
||||
<PackageReference Include="AWSSDK.SQS" Version="3.7.400.21" />
|
||||
<PackageReference Include="AWSSDK.SimpleEmail" Version="3.7.401.16" />
|
||||
<PackageReference Include="AWSSDK.SQS" Version="3.7.400.26" />
|
||||
<PackageReference Include="Azure.Data.Tables" Version="12.9.0" />
|
||||
<PackageReference Include="Azure.Extensions.AspNetCore.DataProtection.Blobs" Version="1.3.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="8.0.8" />
|
||||
@ -34,9 +34,9 @@
|
||||
<PackageReference Include="DnsClient" Version="1.8.0" />
|
||||
<PackageReference Include="Fido2.AspNet" Version="3.0.1" />
|
||||
<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.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.Data.SqlClient" Version="5.2.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Cosmos" Version="1.6.1" />
|
||||
@ -54,8 +54,8 @@
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="Serilog.Sinks.SyslogMessages" Version="3.0.2" />
|
||||
<PackageReference Include="AspNetCoreRateLimit" Version="5.0.0" />
|
||||
<PackageReference Include="Braintree" Version="5.26.0" />
|
||||
<PackageReference Include="Stripe.net" Version="45.13.0" />
|
||||
<PackageReference Include="Braintree" Version="5.27.0" />
|
||||
<PackageReference Include="Stripe.net" Version="45.14.0" />
|
||||
<PackageReference Include="Otp.NET" Version="1.4.0" />
|
||||
<PackageReference Include="YubicoDotNetClient" Version="1.2.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="8.0.8" />
|
||||
|
@ -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
Loading…
Reference in New Issue
Block a user