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,
|
"isRoot": true,
|
||||||
"tools": {
|
"tools": {
|
||||||
"swashbuckle.aspnetcore.cli": {
|
"swashbuckle.aspnetcore.cli": {
|
||||||
"version": "6.7.3",
|
"version": "6.8.0",
|
||||||
"commands": ["swagger"]
|
"commands": ["swagger"]
|
||||||
},
|
},
|
||||||
"dotnet-ef": {
|
"dotnet-ef": {
|
||||||
|
@ -30,7 +30,7 @@ jobs:
|
|||||||
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
|
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
|
||||||
|
|
||||||
- name: Check out branch
|
- name: Check out branch
|
||||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
|
||||||
with:
|
with:
|
||||||
token: ${{ steps.retrieve-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
|
token: ${{ steps.retrieve-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
|
||||||
|
|
||||||
@ -54,7 +54,7 @@ jobs:
|
|||||||
if: ${{ needs.setup.outputs.copy_finalization_scripts == 'true' }}
|
if: ${{ needs.setup.outputs.copy_finalization_scripts == 'true' }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
|
14
.github/workflows/build.yml
vendored
14
.github/workflows/build.yml
vendored
@ -19,7 +19,7 @@ jobs:
|
|||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
|
||||||
|
|
||||||
- name: Set up .NET
|
- name: Set up .NET
|
||||||
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
|
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
|
||||||
@ -68,7 +68,7 @@ jobs:
|
|||||||
node: true
|
node: true
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
|
||||||
|
|
||||||
- name: Set up .NET
|
- name: Set up .NET
|
||||||
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
|
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
|
||||||
@ -173,7 +173,7 @@ jobs:
|
|||||||
dotnet: true
|
dotnet: true
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
|
||||||
|
|
||||||
- name: Check branch to publish
|
- name: Check branch to publish
|
||||||
env:
|
env:
|
||||||
@ -263,7 +263,7 @@ jobs:
|
|||||||
-d ${{ matrix.base_path }}/${{ matrix.project_name }}/obj/build-output/publish
|
-d ${{ matrix.base_path }}/${{ matrix.project_name }}/obj/build-output/publish
|
||||||
|
|
||||||
- name: Build Docker image
|
- name: Build Docker image
|
||||||
uses: docker/build-push-action@5cd11c3a4ced054e52742c5fd54dca954e0edd85 # v6.7.0
|
uses: docker/build-push-action@32945a339266b759abcbdc89316275140b0fc960 # v6.8.0
|
||||||
with:
|
with:
|
||||||
context: ${{ matrix.base_path }}/${{ matrix.project_name }}
|
context: ${{ matrix.base_path }}/${{ matrix.project_name }}
|
||||||
file: ${{ matrix.base_path }}/${{ matrix.project_name }}/Dockerfile
|
file: ${{ matrix.base_path }}/${{ matrix.project_name }}/Dockerfile
|
||||||
@ -282,7 +282,7 @@ jobs:
|
|||||||
output-format: sarif
|
output-format: sarif
|
||||||
|
|
||||||
- name: Upload Grype results to GitHub
|
- name: Upload Grype results to GitHub
|
||||||
uses: github/codeql-action/upload-sarif@294a9d92911152fe08befb9ec03e240add280cb3 # v3.26.8
|
uses: github/codeql-action/upload-sarif@461ef6c76dfe95d5c364de2f431ddbd31a417628 # v3.26.9
|
||||||
with:
|
with:
|
||||||
sarif_file: ${{ steps.container-scan.outputs.sarif }}
|
sarif_file: ${{ steps.container-scan.outputs.sarif }}
|
||||||
|
|
||||||
@ -292,7 +292,7 @@ jobs:
|
|||||||
needs: build-docker
|
needs: build-docker
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
|
||||||
|
|
||||||
- name: Set up .NET
|
- name: Set up .NET
|
||||||
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
|
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
|
||||||
@ -467,7 +467,7 @@ jobs:
|
|||||||
- win-x64
|
- win-x64
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
|
||||||
|
|
||||||
- name: Set up .NET
|
- name: Set up .NET
|
||||||
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
|
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
|
||||||
|
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"
|
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
|
||||||
|
|
||||||
- name: Checkout main
|
- name: Checkout main
|
||||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
|
||||||
with:
|
with:
|
||||||
ref: main
|
ref: main
|
||||||
token: ${{ steps.retrieve-bot-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
|
token: ${{ steps.retrieve-bot-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
|
||||||
|
2
.github/workflows/code-references.yml
vendored
2
.github/workflows/code-references.yml
vendored
@ -33,7 +33,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repository
|
- name: Check out repository
|
||||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
|
||||||
|
|
||||||
- name: Collect
|
- name: Collect
|
||||||
id: collect
|
id: collect
|
||||||
|
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"
|
label: "DB-migrations-changed"
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
|
||||||
with:
|
with:
|
||||||
fetch-depth: 2
|
fetch-depth: 2
|
||||||
|
|
||||||
|
2
.github/workflows/publish.yml
vendored
2
.github/workflows/publish.yml
vendored
@ -99,7 +99,7 @@ jobs:
|
|||||||
echo "Github Release Option: $RELEASE_OPTION"
|
echo "Github Release Option: $RELEASE_OPTION"
|
||||||
|
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
|
||||||
|
|
||||||
- name: Set up project name
|
- name: Set up project name
|
||||||
id: setup
|
id: setup
|
||||||
|
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@ -37,7 +37,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
|
||||||
|
|
||||||
- name: Check release version
|
- name: Check release version
|
||||||
id: version
|
id: version
|
||||||
|
6
.github/workflows/scan.yml
vendored
6
.github/workflows/scan.yml
vendored
@ -26,7 +26,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha }}
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
|
||||||
@ -46,7 +46,7 @@ jobs:
|
|||||||
--output-path . ${{ env.INCREMENTAL }}
|
--output-path . ${{ env.INCREMENTAL }}
|
||||||
|
|
||||||
- name: Upload Checkmarx results to GitHub
|
- name: Upload Checkmarx results to GitHub
|
||||||
uses: github/codeql-action/upload-sarif@294a9d92911152fe08befb9ec03e240add280cb3 # v3.26.8
|
uses: github/codeql-action/upload-sarif@461ef6c76dfe95d5c364de2f431ddbd31a417628 # v3.26.9
|
||||||
with:
|
with:
|
||||||
sarif_file: cx_result.sarif
|
sarif_file: cx_result.sarif
|
||||||
|
|
||||||
@ -66,7 +66,7 @@ jobs:
|
|||||||
distribution: "zulu"
|
distribution: "zulu"
|
||||||
|
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
ref: ${{ github.event.pull_request.head.sha }}
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
4
.github/workflows/test-database.yml
vendored
4
.github/workflows/test-database.yml
vendored
@ -36,7 +36,7 @@ jobs:
|
|||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
|
||||||
|
|
||||||
- name: Set up .NET
|
- name: Set up .NET
|
||||||
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
|
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
|
||||||
@ -147,7 +147,7 @@ jobs:
|
|||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
|
||||||
|
|
||||||
- name: Set up .NET
|
- name: Set up .NET
|
||||||
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
|
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
|
||||||
|
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@ -46,7 +46,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
|
||||||
|
|
||||||
- name: Set up .NET
|
- name: Set up .NET
|
||||||
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
|
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
|
||||||
|
4
.github/workflows/version-bump.yml
vendored
4
.github/workflows/version-bump.yml
vendored
@ -39,7 +39,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Check out branch
|
- name: Check out branch
|
||||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
|
||||||
|
|
||||||
- name: Check if RC branch exists
|
- name: Check if RC branch exists
|
||||||
if: ${{ inputs.cut_rc_branch == true }}
|
if: ${{ inputs.cut_rc_branch == true }}
|
||||||
@ -230,7 +230,7 @@ jobs:
|
|||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Check out branch
|
- name: Check out branch
|
||||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
|
||||||
with:
|
with:
|
||||||
ref: main
|
ref: main
|
||||||
|
|
||||||
|
@ -4,6 +4,7 @@ using Bit.Core.AdminConsole.Models.OrganizationConnectionConfigs;
|
|||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
using Bit.Core.Settings;
|
using Bit.Core.Settings;
|
||||||
|
using Bit.Core.Utilities;
|
||||||
|
|
||||||
namespace Bit.Scim.Context;
|
namespace Bit.Scim.Context;
|
||||||
|
|
||||||
@ -11,6 +12,32 @@ public class ScimContext : IScimContext
|
|||||||
{
|
{
|
||||||
private bool _builtHttpContext;
|
private bool _builtHttpContext;
|
||||||
|
|
||||||
|
// See IP list from Ping in docs: https://support.pingidentity.com/s/article/PingOne-IP-Addresses
|
||||||
|
private static readonly HashSet<string> _pingIpAddresses =
|
||||||
|
[
|
||||||
|
"18.217.152.87",
|
||||||
|
"52.14.10.143",
|
||||||
|
"13.58.49.148",
|
||||||
|
"34.211.92.81",
|
||||||
|
"54.214.158.219",
|
||||||
|
"34.218.98.164",
|
||||||
|
"15.223.133.47",
|
||||||
|
"3.97.84.38",
|
||||||
|
"15.223.19.71",
|
||||||
|
"3.97.98.120",
|
||||||
|
"52.60.115.173",
|
||||||
|
"3.97.202.223",
|
||||||
|
"18.184.65.93",
|
||||||
|
"52.57.244.92",
|
||||||
|
"18.195.7.252",
|
||||||
|
"108.128.67.71",
|
||||||
|
"34.246.158.102",
|
||||||
|
"108.128.250.27",
|
||||||
|
"52.63.103.92",
|
||||||
|
"13.54.131.18",
|
||||||
|
"52.62.204.36"
|
||||||
|
];
|
||||||
|
|
||||||
public ScimProviderType RequestScimProvider { get; set; } = ScimProviderType.Default;
|
public ScimProviderType RequestScimProvider { get; set; } = ScimProviderType.Default;
|
||||||
public ScimConfig ScimConfiguration { get; set; }
|
public ScimConfig ScimConfiguration { get; set; }
|
||||||
public Guid? OrganizationId { get; set; }
|
public Guid? OrganizationId { get; set; }
|
||||||
@ -55,10 +82,18 @@ public class ScimContext : IScimContext
|
|||||||
RequestScimProvider = ScimProviderType.Okta;
|
RequestScimProvider = ScimProviderType.Okta;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (RequestScimProvider == ScimProviderType.Default &&
|
if (RequestScimProvider == ScimProviderType.Default &&
|
||||||
httpContext.Request.Headers.ContainsKey("Adscimversion"))
|
httpContext.Request.Headers.ContainsKey("Adscimversion"))
|
||||||
{
|
{
|
||||||
RequestScimProvider = ScimProviderType.AzureAd;
|
RequestScimProvider = ScimProviderType.AzureAd;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var ipAddress = CoreHelpers.GetIpAddress(httpContext, globalSettings);
|
||||||
|
if (RequestScimProvider == ScimProviderType.Default &&
|
||||||
|
_pingIpAddresses.Contains(ipAddress))
|
||||||
|
{
|
||||||
|
RequestScimProvider = ScimProviderType.Ping;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -43,7 +43,8 @@ public class PutGroupCommand : IPutGroupCommand
|
|||||||
|
|
||||||
private async Task UpdateGroupMembersAsync(Group group, ScimGroupRequestModel model)
|
private async Task UpdateGroupMembersAsync(Group group, ScimGroupRequestModel model)
|
||||||
{
|
{
|
||||||
if (_scimContext.RequestScimProvider != ScimProviderType.Okta)
|
if (_scimContext.RequestScimProvider != ScimProviderType.Okta &&
|
||||||
|
_scimContext.RequestScimProvider != ScimProviderType.Ping)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -20,15 +20,16 @@ public class GetUsersListQuery : IGetUsersListQuery
|
|||||||
string externalIdFilter = null;
|
string externalIdFilter = null;
|
||||||
if (!string.IsNullOrWhiteSpace(filter))
|
if (!string.IsNullOrWhiteSpace(filter))
|
||||||
{
|
{
|
||||||
if (filter.StartsWith("userName eq "))
|
var filterLower = filter.ToLowerInvariant();
|
||||||
|
if (filterLower.StartsWith("username eq "))
|
||||||
{
|
{
|
||||||
usernameFilter = filter.Substring(12).Trim('"').ToLowerInvariant();
|
usernameFilter = filterLower.Substring(12).Trim('"');
|
||||||
if (usernameFilter.Contains("@"))
|
if (usernameFilter.Contains("@"))
|
||||||
{
|
{
|
||||||
emailFilter = usernameFilter;
|
emailFilter = usernameFilter;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (filter.StartsWith("externalId eq "))
|
else if (filterLower.StartsWith("externalid eq "))
|
||||||
{
|
{
|
||||||
externalIdFilter = filter.Substring(14).Trim('"');
|
externalIdFilter = filter.Substring(14).Trim('"');
|
||||||
}
|
}
|
||||||
|
@ -9,7 +9,7 @@
|
|||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.1" />
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.8" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="$(MicrosoftNetTestSdkVersion)" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="$(MicrosoftNetTestSdkVersion)" />
|
||||||
<PackageReference Include="NSubstitute" Version="$(NSubstituteVersion)" />
|
<PackageReference Include="NSubstitute" Version="$(NSubstituteVersion)" />
|
||||||
<PackageReference Include="xunit" Version="$(XUnitVersion)" />
|
<PackageReference Include="xunit" Version="$(XUnitVersion)" />
|
||||||
|
@ -14,6 +14,10 @@
|
|||||||
<ProjectReference Include="..\Core\Core.csproj" />
|
<ProjectReference Include="..\Core\Core.csproj" />
|
||||||
<ProjectReference Include="..\..\util\SqliteMigrations\SqliteMigrations.csproj" />
|
<ProjectReference Include="..\..\util\SqliteMigrations\SqliteMigrations.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<Folder Include="Billing\Controllers\" />
|
||||||
|
<Folder Include="Billing\Models\" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<Choose>
|
<Choose>
|
||||||
<When Condition="!$(DefineConstants.Contains('OSS'))">
|
<When Condition="!$(DefineConstants.Contains('OSS'))">
|
||||||
|
@ -367,7 +367,7 @@ public class ProvidersController : Controller
|
|||||||
return BadRequest("Provider does not exist");
|
return BadRequest("Provider does not exist");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.Equals(providerName.Trim(), provider.Name, StringComparison.OrdinalIgnoreCase))
|
if (!string.Equals(providerName.Trim(), provider.DisplayName(), StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
return BadRequest("Invalid provider name");
|
return BadRequest("Invalid provider name");
|
||||||
}
|
}
|
||||||
|
@ -174,8 +174,6 @@
|
|||||||
|
|
||||||
<div class="d-flex mt-4">
|
<div class="d-flex mt-4">
|
||||||
<button type="submit" class="btn btn-primary" form="edit-form">Save</button>
|
<button type="submit" class="btn btn-primary" form="edit-form">Save</button>
|
||||||
@if (FeatureService.IsEnabled(FeatureFlagKeys.EnableDeleteProvider))
|
|
||||||
{
|
|
||||||
<div class="ml-auto d-flex">
|
<div class="ml-auto d-flex">
|
||||||
<button class="btn btn-danger" onclick="openRequestDeleteModal(@Model.ProviderOrganizations.Count())">Request Delete</button>
|
<button class="btn btn-danger" onclick="openRequestDeleteModal(@Model.ProviderOrganizations.Count())">Request Delete</button>
|
||||||
<button id="requestDeletionBtn" hidden="hidden" data-toggle="modal" data-target="#requestDeletionModal"></button>
|
<button id="requestDeletionBtn" hidden="hidden" data-toggle="modal" data-target="#requestDeletionModal"></button>
|
||||||
@ -186,6 +184,5 @@
|
|||||||
<button id="linkAccWarningBtn" hidden="hidden" data-toggle="modal" data-target="#linkedWarningModal"></button>
|
<button id="linkAccWarningBtn" hidden="hidden" data-toggle="modal" data-target="#linkedWarningModal"></button>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
@ -20,9 +20,10 @@
|
|||||||
|
|
||||||
function deleteProvider(id) {
|
function deleteProvider(id) {
|
||||||
const providerName = $('#DeleteModal input#provider-name').val();
|
const providerName = $('#DeleteModal input#provider-name').val();
|
||||||
|
const encodedProviderName = encodeURIComponent(providerName);
|
||||||
$.ajax({
|
$.ajax({
|
||||||
type: "POST",
|
type: "POST",
|
||||||
url: `@Url.Action("Delete", "Providers")?id=${id}&providerName=${providerName}`,
|
url: `@Url.Action("Delete", "Providers")?id=${id}&providerName=${encodedProviderName}`,
|
||||||
dataType: 'json',
|
dataType: 'json',
|
||||||
contentType: false,
|
contentType: false,
|
||||||
processData: false,
|
processData: false,
|
||||||
|
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.Models;
|
||||||
using Bit.Admin.Services;
|
using Bit.Admin.Services;
|
||||||
using Bit.Admin.Utilities;
|
using Bit.Admin.Utilities;
|
||||||
using Bit.Core;
|
using Bit.Core;
|
||||||
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
||||||
using Bit.Core.Context;
|
|
||||||
using Bit.Core.Entities;
|
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
using Bit.Core.Services;
|
using Bit.Core.Services;
|
||||||
using Bit.Core.Settings;
|
using Bit.Core.Settings;
|
||||||
@ -24,9 +24,9 @@ public class UsersController : Controller
|
|||||||
private readonly IPaymentService _paymentService;
|
private readonly IPaymentService _paymentService;
|
||||||
private readonly GlobalSettings _globalSettings;
|
private readonly GlobalSettings _globalSettings;
|
||||||
private readonly IAccessControlService _accessControlService;
|
private readonly IAccessControlService _accessControlService;
|
||||||
private readonly ICurrentContext _currentContext;
|
|
||||||
private readonly IFeatureService _featureService;
|
|
||||||
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
|
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
|
||||||
|
private readonly IFeatureService _featureService;
|
||||||
|
private readonly IUserService _userService;
|
||||||
|
|
||||||
public UsersController(
|
public UsersController(
|
||||||
IUserRepository userRepository,
|
IUserRepository userRepository,
|
||||||
@ -34,18 +34,18 @@ public class UsersController : Controller
|
|||||||
IPaymentService paymentService,
|
IPaymentService paymentService,
|
||||||
GlobalSettings globalSettings,
|
GlobalSettings globalSettings,
|
||||||
IAccessControlService accessControlService,
|
IAccessControlService accessControlService,
|
||||||
ICurrentContext currentContext,
|
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
|
||||||
IFeatureService featureService,
|
IFeatureService featureService,
|
||||||
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery)
|
IUserService userService)
|
||||||
{
|
{
|
||||||
_userRepository = userRepository;
|
_userRepository = userRepository;
|
||||||
_cipherRepository = cipherRepository;
|
_cipherRepository = cipherRepository;
|
||||||
_paymentService = paymentService;
|
_paymentService = paymentService;
|
||||||
_globalSettings = globalSettings;
|
_globalSettings = globalSettings;
|
||||||
_accessControlService = accessControlService;
|
_accessControlService = accessControlService;
|
||||||
_currentContext = currentContext;
|
|
||||||
_featureService = featureService;
|
|
||||||
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
|
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
|
||||||
|
_featureService = featureService;
|
||||||
|
_userService = userService;
|
||||||
}
|
}
|
||||||
|
|
||||||
[RequirePermission(Permission.User_List_View)]
|
[RequirePermission(Permission.User_List_View)]
|
||||||
@ -64,19 +64,26 @@ public class UsersController : Controller
|
|||||||
var skip = (page - 1) * count;
|
var skip = (page - 1) * count;
|
||||||
var users = await _userRepository.SearchAsync(email, skip, count);
|
var users = await _userRepository.SearchAsync(email, skip, count);
|
||||||
|
|
||||||
|
var userModels = new List<UserViewModel>();
|
||||||
|
|
||||||
if (_featureService.IsEnabled(FeatureFlagKeys.MembersTwoFAQueryOptimization))
|
if (_featureService.IsEnabled(FeatureFlagKeys.MembersTwoFAQueryOptimization))
|
||||||
{
|
{
|
||||||
var user2Fa = (await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(users.Select(u => u.Id))).ToList();
|
var twoFactorAuthLookup = (await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(users.Select(u => u.Id))).ToList();
|
||||||
// TempDataSerializer is having an issue serializing an empty IEnumerable<Tuple<T1,T2>>, do not set if empty.
|
|
||||||
if (user2Fa.Count != 0)
|
userModels = UserViewModel.MapViewModels(users, twoFactorAuthLookup).ToList();
|
||||||
|
}
|
||||||
|
else
|
||||||
{
|
{
|
||||||
TempData["UsersTwoFactorIsEnabled"] = user2Fa;
|
foreach (var user in users)
|
||||||
|
{
|
||||||
|
var isTwoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user);
|
||||||
|
userModels.Add(UserViewModel.MapViewModel(user, isTwoFactorEnabled));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return View(new UsersModel
|
return View(new UsersModel
|
||||||
{
|
{
|
||||||
Items = users as List<User>,
|
Items = userModels,
|
||||||
Email = string.IsNullOrWhiteSpace(email) ? null : email,
|
Email = string.IsNullOrWhiteSpace(email) ? null : email,
|
||||||
Page = page,
|
Page = page,
|
||||||
Count = count,
|
Count = count,
|
||||||
@ -87,13 +94,17 @@ public class UsersController : Controller
|
|||||||
public async Task<IActionResult> View(Guid id)
|
public async Task<IActionResult> View(Guid id)
|
||||||
{
|
{
|
||||||
var user = await _userRepository.GetByIdAsync(id);
|
var user = await _userRepository.GetByIdAsync(id);
|
||||||
|
|
||||||
if (user == null)
|
if (user == null)
|
||||||
{
|
{
|
||||||
return RedirectToAction("Index");
|
return RedirectToAction("Index");
|
||||||
}
|
}
|
||||||
|
|
||||||
var ciphers = await _cipherRepository.GetManyByUserIdAsync(id);
|
var ciphers = await _cipherRepository.GetManyByUserIdAsync(id);
|
||||||
return View(new UserViewModel(user, ciphers));
|
|
||||||
|
var isTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user);
|
||||||
|
|
||||||
|
return View(UserViewModel.MapViewModel(user, isTwoFactorEnabled, ciphers));
|
||||||
}
|
}
|
||||||
|
|
||||||
[SelfHosted(NotSelfHostedOnly = true)]
|
[SelfHosted(NotSelfHostedOnly = true)]
|
||||||
@ -108,7 +119,8 @@ public class UsersController : Controller
|
|||||||
var ciphers = await _cipherRepository.GetManyByUserIdAsync(id);
|
var ciphers = await _cipherRepository.GetManyByUserIdAsync(id);
|
||||||
var billingInfo = await _paymentService.GetBillingAsync(user);
|
var billingInfo = await _paymentService.GetBillingAsync(user);
|
||||||
var billingHistoryInfo = await _paymentService.GetBillingHistoryAsync(user);
|
var billingHistoryInfo = await _paymentService.GetBillingHistoryAsync(user);
|
||||||
return View(new UserEditModel(user, ciphers, billingInfo, billingHistoryInfo, _globalSettings));
|
var isTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user);
|
||||||
|
return View(new UserEditModel(user, isTwoFactorEnabled, ciphers, billingInfo, billingHistoryInfo, _globalSettings));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
|
@ -47,5 +47,7 @@ public enum Permission
|
|||||||
Tools_GenerateLicenseFile,
|
Tools_GenerateLicenseFile,
|
||||||
Tools_ManageTaxRates,
|
Tools_ManageTaxRates,
|
||||||
Tools_ManageStripeSubscriptions,
|
Tools_ManageStripeSubscriptions,
|
||||||
Tools_CreateEditTransaction
|
Tools_CreateEditTransaction,
|
||||||
|
Tools_ProcessStripeEvents,
|
||||||
|
Tools_MigrateProviders
|
||||||
}
|
}
|
||||||
|
@ -7,18 +7,23 @@ using Bit.Core.Vault.Entities;
|
|||||||
|
|
||||||
namespace Bit.Admin.Models;
|
namespace Bit.Admin.Models;
|
||||||
|
|
||||||
public class UserEditModel : UserViewModel
|
public class UserEditModel
|
||||||
{
|
{
|
||||||
public UserEditModel() { }
|
public UserEditModel()
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
public UserEditModel(
|
public UserEditModel(
|
||||||
User user,
|
User user,
|
||||||
|
bool isTwoFactorEnabled,
|
||||||
IEnumerable<Cipher> ciphers,
|
IEnumerable<Cipher> ciphers,
|
||||||
BillingInfo billingInfo,
|
BillingInfo billingInfo,
|
||||||
BillingHistoryInfo billingHistoryInfo,
|
BillingHistoryInfo billingHistoryInfo,
|
||||||
GlobalSettings globalSettings)
|
GlobalSettings globalSettings)
|
||||||
: base(user, ciphers)
|
|
||||||
{
|
{
|
||||||
|
User = UserViewModel.MapViewModel(user, isTwoFactorEnabled, ciphers);
|
||||||
|
|
||||||
BillingInfo = billingInfo;
|
BillingInfo = billingInfo;
|
||||||
BillingHistoryInfo = billingHistoryInfo;
|
BillingHistoryInfo = billingHistoryInfo;
|
||||||
BraintreeMerchantId = globalSettings.Braintree.MerchantId;
|
BraintreeMerchantId = globalSettings.Braintree.MerchantId;
|
||||||
@ -35,32 +40,32 @@ public class UserEditModel : UserViewModel
|
|||||||
PremiumExpirationDate = user.PremiumExpirationDate;
|
PremiumExpirationDate = user.PremiumExpirationDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
public BillingInfo BillingInfo { get; set; }
|
public UserViewModel User { get; init; }
|
||||||
public BillingHistoryInfo BillingHistoryInfo { get; set; }
|
public BillingInfo BillingInfo { get; init; }
|
||||||
|
public BillingHistoryInfo BillingHistoryInfo { get; init; }
|
||||||
public string RandomLicenseKey => CoreHelpers.SecureRandomString(20);
|
public string RandomLicenseKey => CoreHelpers.SecureRandomString(20);
|
||||||
public string OneYearExpirationDate => DateTime.Now.AddYears(1).ToString("yyyy-MM-ddTHH:mm");
|
public string OneYearExpirationDate => DateTime.Now.AddYears(1).ToString("yyyy-MM-ddTHH:mm");
|
||||||
public string BraintreeMerchantId { get; set; }
|
public string BraintreeMerchantId { get; init; }
|
||||||
|
|
||||||
[Display(Name = "Name")]
|
[Display(Name = "Name")]
|
||||||
public string Name { get; set; }
|
public string Name { get; init; }
|
||||||
[Required]
|
[Required]
|
||||||
[Display(Name = "Email")]
|
[Display(Name = "Email")]
|
||||||
public string Email { get; set; }
|
public string Email { get; init; }
|
||||||
[Display(Name = "Email Verified")]
|
[Display(Name = "Email Verified")]
|
||||||
public bool EmailVerified { get; set; }
|
public bool EmailVerified { get; init; }
|
||||||
[Display(Name = "Premium")]
|
[Display(Name = "Premium")]
|
||||||
public bool Premium { get; set; }
|
public bool Premium { get; init; }
|
||||||
[Display(Name = "Max. Storage GB")]
|
[Display(Name = "Max. Storage GB")]
|
||||||
public short? MaxStorageGb { get; set; }
|
public short? MaxStorageGb { get; init; }
|
||||||
[Display(Name = "Gateway")]
|
[Display(Name = "Gateway")]
|
||||||
public Core.Enums.GatewayType? Gateway { get; set; }
|
public Core.Enums.GatewayType? Gateway { get; init; }
|
||||||
[Display(Name = "Gateway Customer Id")]
|
[Display(Name = "Gateway Customer Id")]
|
||||||
public string GatewayCustomerId { get; set; }
|
public string GatewayCustomerId { get; init; }
|
||||||
[Display(Name = "Gateway Subscription Id")]
|
[Display(Name = "Gateway Subscription Id")]
|
||||||
public string GatewaySubscriptionId { get; set; }
|
public string GatewaySubscriptionId { get; init; }
|
||||||
[Display(Name = "License Key")]
|
[Display(Name = "License Key")]
|
||||||
public string LicenseKey { get; set; }
|
public string LicenseKey { get; init; }
|
||||||
[Display(Name = "Premium Expiration Date")]
|
[Display(Name = "Premium Expiration Date")]
|
||||||
public DateTime? PremiumExpirationDate { get; set; }
|
public DateTime? PremiumExpirationDate { get; init; }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -1,18 +1,131 @@
|
|||||||
using Bit.Core.Entities;
|
using Bit.Core.Entities;
|
||||||
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.Vault.Entities;
|
using Bit.Core.Vault.Entities;
|
||||||
|
|
||||||
namespace Bit.Admin.Models;
|
namespace Bit.Admin.Models;
|
||||||
|
|
||||||
public class UserViewModel
|
public class UserViewModel
|
||||||
{
|
{
|
||||||
public UserViewModel() { }
|
public Guid Id { get; }
|
||||||
|
public string Name { get; }
|
||||||
|
public string Email { get; }
|
||||||
|
public DateTime CreationDate { get; }
|
||||||
|
public DateTime? PremiumExpirationDate { get; }
|
||||||
|
public bool Premium { get; }
|
||||||
|
public short? MaxStorageGb { get; }
|
||||||
|
public bool EmailVerified { get; }
|
||||||
|
public bool TwoFactorEnabled { get; }
|
||||||
|
public DateTime AccountRevisionDate { get; }
|
||||||
|
public DateTime RevisionDate { get; }
|
||||||
|
public DateTime? LastEmailChangeDate { get; }
|
||||||
|
public DateTime? LastKdfChangeDate { get; }
|
||||||
|
public DateTime? LastKeyRotationDate { get; }
|
||||||
|
public DateTime? LastPasswordChangeDate { get; }
|
||||||
|
public GatewayType? Gateway { get; }
|
||||||
|
public string GatewayCustomerId { get; }
|
||||||
|
public string GatewaySubscriptionId { get; }
|
||||||
|
public string LicenseKey { get; }
|
||||||
|
public int CipherCount { get; set; }
|
||||||
|
|
||||||
public UserViewModel(User user, IEnumerable<Cipher> ciphers)
|
public UserViewModel(Guid id,
|
||||||
|
string name,
|
||||||
|
string email,
|
||||||
|
DateTime creationDate,
|
||||||
|
DateTime? premiumExpirationDate,
|
||||||
|
bool premium,
|
||||||
|
short? maxStorageGb,
|
||||||
|
bool emailVerified,
|
||||||
|
bool twoFactorEnabled,
|
||||||
|
DateTime accountRevisionDate,
|
||||||
|
DateTime revisionDate,
|
||||||
|
DateTime? lastEmailChangeDate,
|
||||||
|
DateTime? lastKdfChangeDate,
|
||||||
|
DateTime? lastKeyRotationDate,
|
||||||
|
DateTime? lastPasswordChangeDate,
|
||||||
|
GatewayType? gateway,
|
||||||
|
string gatewayCustomerId,
|
||||||
|
string gatewaySubscriptionId,
|
||||||
|
string licenseKey,
|
||||||
|
IEnumerable<Cipher> ciphers)
|
||||||
{
|
{
|
||||||
User = user;
|
Id = id;
|
||||||
|
Name = name;
|
||||||
|
Email = email;
|
||||||
|
CreationDate = creationDate;
|
||||||
|
PremiumExpirationDate = premiumExpirationDate;
|
||||||
|
Premium = premium;
|
||||||
|
MaxStorageGb = maxStorageGb;
|
||||||
|
EmailVerified = emailVerified;
|
||||||
|
TwoFactorEnabled = twoFactorEnabled;
|
||||||
|
AccountRevisionDate = accountRevisionDate;
|
||||||
|
RevisionDate = revisionDate;
|
||||||
|
LastEmailChangeDate = lastEmailChangeDate;
|
||||||
|
LastKdfChangeDate = lastKdfChangeDate;
|
||||||
|
LastKeyRotationDate = lastKeyRotationDate;
|
||||||
|
LastPasswordChangeDate = lastPasswordChangeDate;
|
||||||
|
Gateway = gateway;
|
||||||
|
GatewayCustomerId = gatewayCustomerId;
|
||||||
|
GatewaySubscriptionId = gatewaySubscriptionId;
|
||||||
|
LicenseKey = licenseKey;
|
||||||
CipherCount = ciphers.Count();
|
CipherCount = ciphers.Count();
|
||||||
}
|
}
|
||||||
|
|
||||||
public User User { get; set; }
|
public static IEnumerable<UserViewModel> MapViewModels(
|
||||||
public int CipherCount { get; set; }
|
IEnumerable<User> users,
|
||||||
|
IEnumerable<(Guid userId, bool twoFactorIsEnabled)> lookup) =>
|
||||||
|
users.Select(user => MapViewModel(user, lookup));
|
||||||
|
|
||||||
|
public static UserViewModel MapViewModel(User user,
|
||||||
|
IEnumerable<(Guid userId, bool twoFactorIsEnabled)> lookup) =>
|
||||||
|
new(
|
||||||
|
user.Id,
|
||||||
|
user.Name,
|
||||||
|
user.Email,
|
||||||
|
user.CreationDate,
|
||||||
|
user.PremiumExpirationDate,
|
||||||
|
user.Premium,
|
||||||
|
user.MaxStorageGb,
|
||||||
|
user.EmailVerified,
|
||||||
|
IsTwoFactorEnabled(user, lookup),
|
||||||
|
user.AccountRevisionDate,
|
||||||
|
user.RevisionDate,
|
||||||
|
user.LastEmailChangeDate,
|
||||||
|
user.LastKdfChangeDate,
|
||||||
|
user.LastKeyRotationDate,
|
||||||
|
user.LastPasswordChangeDate,
|
||||||
|
user.Gateway,
|
||||||
|
user.GatewayCustomerId ?? string.Empty,
|
||||||
|
user.GatewaySubscriptionId ?? string.Empty,
|
||||||
|
user.LicenseKey ?? string.Empty,
|
||||||
|
Array.Empty<Cipher>());
|
||||||
|
|
||||||
|
public static UserViewModel MapViewModel(User user, bool isTwoFactorEnabled) =>
|
||||||
|
MapViewModel(user, isTwoFactorEnabled, Array.Empty<Cipher>());
|
||||||
|
|
||||||
|
public static UserViewModel MapViewModel(User user, bool isTwoFactorEnabled, IEnumerable<Cipher> ciphers) =>
|
||||||
|
new(
|
||||||
|
user.Id,
|
||||||
|
user.Name,
|
||||||
|
user.Email,
|
||||||
|
user.CreationDate,
|
||||||
|
user.PremiumExpirationDate,
|
||||||
|
user.Premium,
|
||||||
|
user.MaxStorageGb,
|
||||||
|
user.EmailVerified,
|
||||||
|
isTwoFactorEnabled,
|
||||||
|
user.AccountRevisionDate,
|
||||||
|
user.RevisionDate,
|
||||||
|
user.LastEmailChangeDate,
|
||||||
|
user.LastKdfChangeDate,
|
||||||
|
user.LastKeyRotationDate,
|
||||||
|
user.LastPasswordChangeDate,
|
||||||
|
user.Gateway,
|
||||||
|
user.GatewayCustomerId ?? string.Empty,
|
||||||
|
user.GatewaySubscriptionId ?? string.Empty,
|
||||||
|
user.LicenseKey ?? string.Empty,
|
||||||
|
ciphers);
|
||||||
|
|
||||||
|
public static bool IsTwoFactorEnabled(User user,
|
||||||
|
IEnumerable<(Guid userId, bool twoFactorIsEnabled)> twoFactorIsEnabledLookup) =>
|
||||||
|
twoFactorIsEnabledLookup.FirstOrDefault(x => x.userId == user.Id).twoFactorIsEnabled;
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,6 @@
|
|||||||
using Bit.Core.Entities;
|
namespace Bit.Admin.Models;
|
||||||
|
|
||||||
namespace Bit.Admin.Models;
|
public class UsersModel : PagedModel<UserViewModel>
|
||||||
|
|
||||||
public class UsersModel : PagedModel<User>
|
|
||||||
{
|
{
|
||||||
public string Email { get; set; }
|
public string Email { get; set; }
|
||||||
public string Action { get; set; }
|
public string Action { get; set; }
|
||||||
|
@ -10,6 +10,7 @@ using Microsoft.AspNetCore.Mvc.Razor;
|
|||||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
using Bit.Admin.Services;
|
using Bit.Admin.Services;
|
||||||
using Bit.Core.Billing.Extensions;
|
using Bit.Core.Billing.Extensions;
|
||||||
|
using Bit.Core.Billing.Migration;
|
||||||
|
|
||||||
#if !OSS
|
#if !OSS
|
||||||
using Bit.Commercial.Core.Utilities;
|
using Bit.Commercial.Core.Utilities;
|
||||||
@ -88,7 +89,10 @@ public class Startup
|
|||||||
services.AddBaseServices(globalSettings);
|
services.AddBaseServices(globalSettings);
|
||||||
services.AddDefaultServices(globalSettings);
|
services.AddDefaultServices(globalSettings);
|
||||||
services.AddScoped<IAccessControlService, AccessControlService>();
|
services.AddScoped<IAccessControlService, AccessControlService>();
|
||||||
|
services.AddDistributedCache(globalSettings);
|
||||||
services.AddBillingOperations();
|
services.AddBillingOperations();
|
||||||
|
services.AddHttpClient();
|
||||||
|
services.AddProviderMigration();
|
||||||
|
|
||||||
#if OSS
|
#if OSS
|
||||||
services.AddOosServices();
|
services.AddOosServices();
|
||||||
@ -108,6 +112,7 @@ public class Startup
|
|||||||
{
|
{
|
||||||
o.ViewLocationFormats.Add("/Auth/Views/{1}/{0}.cshtml");
|
o.ViewLocationFormats.Add("/Auth/Views/{1}/{0}.cshtml");
|
||||||
o.ViewLocationFormats.Add("/AdminConsole/Views/{1}/{0}.cshtml");
|
o.ViewLocationFormats.Add("/AdminConsole/Views/{1}/{0}.cshtml");
|
||||||
|
o.ViewLocationFormats.Add("/Billing/Views/{1}/{0}.cshtml");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Jobs service
|
// Jobs service
|
||||||
|
@ -161,7 +161,9 @@ public static class RolePermissionMapping
|
|||||||
Permission.Tools_GenerateLicenseFile,
|
Permission.Tools_GenerateLicenseFile,
|
||||||
Permission.Tools_ManageTaxRates,
|
Permission.Tools_ManageTaxRates,
|
||||||
Permission.Tools_ManageStripeSubscriptions,
|
Permission.Tools_ManageStripeSubscriptions,
|
||||||
Permission.Tools_CreateEditTransaction
|
Permission.Tools_CreateEditTransaction,
|
||||||
|
Permission.Tools_ProcessStripeEvents,
|
||||||
|
Permission.Tools_MigrateProviders
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ "sales", new List<Permission>
|
{ "sales", new List<Permission>
|
||||||
|
@ -14,6 +14,8 @@
|
|||||||
var canGenerateLicense = AccessControlService.UserHasPermission(Permission.Tools_GenerateLicenseFile);
|
var canGenerateLicense = AccessControlService.UserHasPermission(Permission.Tools_GenerateLicenseFile);
|
||||||
var canManageTaxRates = AccessControlService.UserHasPermission(Permission.Tools_ManageTaxRates);
|
var canManageTaxRates = AccessControlService.UserHasPermission(Permission.Tools_ManageTaxRates);
|
||||||
var canManageStripeSubscriptions = AccessControlService.UserHasPermission(Permission.Tools_ManageStripeSubscriptions);
|
var canManageStripeSubscriptions = AccessControlService.UserHasPermission(Permission.Tools_ManageStripeSubscriptions);
|
||||||
|
var canProcessStripeEvents = AccessControlService.UserHasPermission(Permission.Tools_ProcessStripeEvents);
|
||||||
|
var canMigrateProviders = AccessControlService.UserHasPermission(Permission.Tools_MigrateProviders);
|
||||||
|
|
||||||
var canViewTools = canChargeBraintree || canCreateTransaction || canPromoteAdmin ||
|
var canViewTools = canChargeBraintree || canCreateTransaction || canPromoteAdmin ||
|
||||||
canGenerateLicense || canManageTaxRates || canManageStripeSubscriptions;
|
canGenerateLicense || canManageTaxRates || canManageStripeSubscriptions;
|
||||||
@ -107,6 +109,18 @@
|
|||||||
Manage Stripe Subscriptions
|
Manage Stripe Subscriptions
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
|
@if (canProcessStripeEvents)
|
||||||
|
{
|
||||||
|
<a class="dropdown-item" asp-controller="ProcessStripeEvents" asp-action="Index">
|
||||||
|
Process Stripe Events
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
@if (canMigrateProviders)
|
||||||
|
{
|
||||||
|
<a class="dropdown-item" asp-controller="MigrateProviders" asp-action="index">
|
||||||
|
Migrate Providers
|
||||||
|
</a>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
|
@ -86,7 +86,7 @@
|
|||||||
@if (canViewUserInformation)
|
@if (canViewUserInformation)
|
||||||
{
|
{
|
||||||
<h2>User Information</h2>
|
<h2>User Information</h2>
|
||||||
@await Html.PartialAsync("_ViewInformation", Model)
|
@await Html.PartialAsync("_ViewInformation", Model.User)
|
||||||
}
|
}
|
||||||
@if (canViewBillingInformation)
|
@if (canViewBillingInformation)
|
||||||
{
|
{
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
@model UsersModel
|
@model UsersModel
|
||||||
@inject Bit.Core.Services.IUserService userService
|
|
||||||
@inject Bit.Core.Services.IFeatureService featureService
|
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = "Users";
|
ViewData["Title"] = "Users";
|
||||||
}
|
}
|
||||||
@ -46,7 +44,8 @@
|
|||||||
@if (user.Premium)
|
@if (user.Premium)
|
||||||
{
|
{
|
||||||
<i class="fa fa-star fa-lg fa-fw"
|
<i class="fa fa-star fa-lg fa-fw"
|
||||||
title="Premium, expires @(user.PremiumExpirationDate?.ToShortDateString() ?? "-")"></i>
|
title="Premium, expires @(user.PremiumExpirationDate?.ToShortDateString() ?? "-")">
|
||||||
|
</i>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@ -55,12 +54,14 @@
|
|||||||
@if (user.MaxStorageGb.HasValue && user.MaxStorageGb > 1)
|
@if (user.MaxStorageGb.HasValue && user.MaxStorageGb > 1)
|
||||||
{
|
{
|
||||||
<i class="fa fa-plus-square fa-lg fa-fw"
|
<i class="fa fa-plus-square fa-lg fa-fw"
|
||||||
title="Additional Storage, @(user.MaxStorageGb - 1) GB"></i>
|
title="Additional Storage, @(user.MaxStorageGb - 1) GB">
|
||||||
|
</i>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<i class="fa fa-plus-square-o fa-lg fa-fw text-muted"
|
<i class="fa fa-plus-square-o fa-lg fa-fw text-muted"
|
||||||
title="No Additional Storage"></i>
|
title="No Additional Storage">
|
||||||
|
</i>
|
||||||
}
|
}
|
||||||
@if (user.EmailVerified)
|
@if (user.EmailVerified)
|
||||||
{
|
{
|
||||||
@ -70,12 +71,7 @@
|
|||||||
{
|
{
|
||||||
<i class="fa fa-times-circle-o fa-lg fa-fw text-muted" title="Email Not Verified"></i>
|
<i class="fa fa-times-circle-o fa-lg fa-fw text-muted" title="Email Not Verified"></i>
|
||||||
}
|
}
|
||||||
@if (featureService.IsEnabled(Bit.Core.FeatureFlagKeys.MembersTwoFAQueryOptimization))
|
@if (user.TwoFactorEnabled)
|
||||||
{
|
|
||||||
var usersTwoFactorIsEnabled = TempData["UsersTwoFactorIsEnabled"] as IEnumerable<(Guid userId, bool twoFactorIsEnabled)>;
|
|
||||||
var matchingUser2Fa = usersTwoFactorIsEnabled?.FirstOrDefault(tuple => tuple.userId == user.Id);
|
|
||||||
|
|
||||||
@if(matchingUser2Fa is { twoFactorIsEnabled: true })
|
|
||||||
{
|
{
|
||||||
<i class="fa fa-lock fa-lg fa-fw" title="2FA Enabled"></i>
|
<i class="fa fa-lock fa-lg fa-fw" title="2FA Enabled"></i>
|
||||||
}
|
}
|
||||||
@ -83,18 +79,6 @@
|
|||||||
{
|
{
|
||||||
<i class="fa fa-unlock fa-lg fa-fw text-muted" title="2FA Not Enabled"></i>
|
<i class="fa fa-unlock fa-lg fa-fw text-muted" title="2FA Not Enabled"></i>
|
||||||
}
|
}
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
@if(await userService.TwoFactorIsEnabledAsync(user))
|
|
||||||
{
|
|
||||||
<i class="fa fa-lock fa-lg fa-fw" title="2FA Enabled"></i>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<i class="fa fa-unlock fa-lg fa-fw text-muted" title="2FA Not Enabled"></i>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
@ -109,7 +93,9 @@
|
|||||||
{
|
{
|
||||||
<li class="page-item">
|
<li class="page-item">
|
||||||
<a class="page-link" asp-action="Index" asp-route-page="@Model.PreviousPage.Value"
|
<a class="page-link" asp-action="Index" asp-route-page="@Model.PreviousPage.Value"
|
||||||
asp-route-count="@Model.Count" asp-route-email="@Model.Email">Previous</a>
|
asp-route-count="@Model.Count" asp-route-email="@Model.Email">
|
||||||
|
Previous
|
||||||
|
</a>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@ -122,7 +108,9 @@
|
|||||||
{
|
{
|
||||||
<li class="page-item">
|
<li class="page-item">
|
||||||
<a class="page-link" asp-action="Index" asp-route-page="@Model.NextPage.Value"
|
<a class="page-link" asp-action="Index" asp-route-page="@Model.NextPage.Value"
|
||||||
asp-route-count="@Model.Count" asp-route-email="@Model.Email">Next</a>
|
asp-route-count="@Model.Count" asp-route-email="@Model.Email">
|
||||||
|
Next
|
||||||
|
</a>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
@ -1,13 +1,13 @@
|
|||||||
@model UserViewModel
|
@model UserViewModel
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = "User: " + Model.User.Email;
|
ViewData["Title"] = "User: " + Model.Email;
|
||||||
}
|
}
|
||||||
|
|
||||||
<h1>User <small>@Model.User.Email</small></h1>
|
<h1>User <small>@Model.Email</small></h1>
|
||||||
|
|
||||||
<h2>Information</h2>
|
<h2>Information</h2>
|
||||||
@await Html.PartialAsync("_ViewInformation", Model)
|
@await Html.PartialAsync("_ViewInformation", Model)
|
||||||
<form asp-action="Delete" asp-route-id="@Model.User.Id"
|
<form asp-action="Delete" asp-route-id="@Model.Id"
|
||||||
onsubmit="return confirm('Are you sure you want to delete this user?')">
|
onsubmit="return confirm('Are you sure you want to delete this user?')">
|
||||||
<button class="btn btn-danger" type="submit">Delete</button>
|
<button class="btn btn-danger" type="submit">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
|
@ -1,43 +1,42 @@
|
|||||||
@model UserViewModel
|
@model UserViewModel
|
||||||
@inject Bit.Core.Services.IUserService userService
|
|
||||||
<dl class="row">
|
<dl class="row">
|
||||||
<dt class="col-sm-4 col-lg-3">Id</dt>
|
<dt class="col-sm-4 col-lg-3">Id</dt>
|
||||||
<dd class="col-sm-8 col-lg-9"><code>@Model.User.Id</code></dd>
|
<dd class="col-sm-8 col-lg-9"><code>@Model.Id</code></dd>
|
||||||
|
|
||||||
<dt class="col-sm-4 col-lg-3">Premium</dt>
|
<dt class="col-sm-4 col-lg-3">Premium</dt>
|
||||||
<dd class="col-sm-8 col-lg-9">@(Model.User.Premium ? "Yes" : "No")</dd>
|
<dd class="col-sm-8 col-lg-9">@(Model.Premium ? "Yes" : "No")</dd>
|
||||||
|
|
||||||
<dt class="col-sm-4 col-lg-3">Premium Expires</dt>
|
<dt class="col-sm-4 col-lg-3">Premium Expires</dt>
|
||||||
<dd class="col-sm-8 col-lg-9">@(Model.User.PremiumExpirationDate?.ToString() ?? "-")</dd>
|
<dd class="col-sm-8 col-lg-9">@(Model.PremiumExpirationDate?.ToString() ?? "-")</dd>
|
||||||
|
|
||||||
<dt class="col-sm-4 col-lg-3">Email Verified</dt>
|
<dt class="col-sm-4 col-lg-3">Email Verified</dt>
|
||||||
<dd class="col-sm-8 col-lg-9">@(Model.User.EmailVerified ? "Yes" : "No")</dd>
|
<dd class="col-sm-8 col-lg-9">@(Model.EmailVerified ? "Yes" : "No")</dd>
|
||||||
|
|
||||||
<dt class="col-sm-4 col-lg-3">Using 2FA</dt>
|
<dt class="col-sm-4 col-lg-3">Using 2FA</dt>
|
||||||
<dd class="col-sm-8 col-lg-9">@(await userService.TwoFactorIsEnabledAsync(Model.User) ? "Yes" : "No")</dd>
|
<dd class="col-sm-8 col-lg-9">@(Model.TwoFactorEnabled ? "Yes" : "No")</dd>
|
||||||
|
|
||||||
<dt class="col-sm-4 col-lg-3">Items</dt>
|
<dt class="col-sm-4 col-lg-3">Items</dt>
|
||||||
<dd class="col-sm-8 col-lg-9">@Model.CipherCount</dd>
|
<dd class="col-sm-8 col-lg-9">@Model.CipherCount</dd>
|
||||||
|
|
||||||
<dt class="col-sm-4 col-lg-3">Vault Modified</dt>
|
<dt class="col-sm-4 col-lg-3">Vault Modified</dt>
|
||||||
<dd class="col-sm-8 col-lg-9">@Model.User.AccountRevisionDate.ToString()</dd>
|
<dd class="col-sm-8 col-lg-9">@Model.AccountRevisionDate.ToString()</dd>
|
||||||
|
|
||||||
<dt class="col-sm-4 col-lg-3">Created</dt>
|
<dt class="col-sm-4 col-lg-3">Created</dt>
|
||||||
<dd class="col-sm-8 col-lg-9">@Model.User.CreationDate.ToString()</dd>
|
<dd class="col-sm-8 col-lg-9">@Model.CreationDate.ToString()</dd>
|
||||||
|
|
||||||
<dt class="col-sm-4 col-lg-3">Modified</dt>
|
<dt class="col-sm-4 col-lg-3">Modified</dt>
|
||||||
<dd class="col-sm-8 col-lg-9">@Model.User.RevisionDate.ToString()</dd>
|
<dd class="col-sm-8 col-lg-9">@Model.RevisionDate.ToString()</dd>
|
||||||
|
|
||||||
<dt class="col-sm-4 col-lg-3">Last Email Address Change</dt>
|
<dt class="col-sm-4 col-lg-3">Last Email Address Change</dt>
|
||||||
<dd class="col-sm-8 col-lg-9">@(Model.User.LastEmailChangeDate?.ToString() ?? "-")</dd>
|
<dd class="col-sm-8 col-lg-9">@(Model.LastEmailChangeDate?.ToString() ?? "-")</dd>
|
||||||
|
|
||||||
<dt class="col-sm-4 col-lg-3">Last KDF Change</dt>
|
<dt class="col-sm-4 col-lg-3">Last KDF Change</dt>
|
||||||
<dd class="col-sm-8 col-lg-9">@(Model.User.LastKdfChangeDate?.ToString() ?? "-")</dd>
|
<dd class="col-sm-8 col-lg-9">@(Model.LastKdfChangeDate?.ToString() ?? "-")</dd>
|
||||||
|
|
||||||
<dt class="col-sm-4 col-lg-3">Last Key Rotation</dt>
|
<dt class="col-sm-4 col-lg-3">Last Key Rotation</dt>
|
||||||
<dd class="col-sm-8 col-lg-9">@(Model.User.LastKeyRotationDate?.ToString() ?? "-")</dd>
|
<dd class="col-sm-8 col-lg-9">@(Model.LastKeyRotationDate?.ToString() ?? "-")</dd>
|
||||||
|
|
||||||
<dt class="col-sm-4 col-lg-3">Last Password Change</dt>
|
<dt class="col-sm-4 col-lg-3">Last Password Change</dt>
|
||||||
<dd class="col-sm-8 col-lg-9">@(Model.User.LastPasswordChangeDate?.ToString() ?? "-")</dd>
|
<dd class="col-sm-8 col-lg-9">@(Model.LastPasswordChangeDate?.ToString() ?? "-")</dd>
|
||||||
|
|
||||||
</dl>
|
</dl>
|
||||||
|
@ -13,7 +13,8 @@
|
|||||||
"internalApi": "http://localhost:4000",
|
"internalApi": "http://localhost:4000",
|
||||||
"internalVault": "https://localhost:8080",
|
"internalVault": "https://localhost:8080",
|
||||||
"internalSso": "http://localhost:51822",
|
"internalSso": "http://localhost:51822",
|
||||||
"internalScim": "http://localhost:44559"
|
"internalScim": "http://localhost:44559",
|
||||||
|
"internalBilling": "http://localhost:44519"
|
||||||
},
|
},
|
||||||
"mail": {
|
"mail": {
|
||||||
"smtp": {
|
"smtp": {
|
||||||
|
@ -1,12 +1,13 @@
|
|||||||
using Bit.Api.AdminConsole.Models.Request.Organizations;
|
using Bit.Api.AdminConsole.Models.Request.Organizations;
|
||||||
using Bit.Api.AdminConsole.Models.Response.Organizations;
|
using Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||||
|
using Bit.Api.Auth.Models.Request.Accounts;
|
||||||
using Bit.Api.Models.Request.Organizations;
|
using Bit.Api.Models.Request.Organizations;
|
||||||
using Bit.Api.Models.Response;
|
using Bit.Api.Models.Response;
|
||||||
using Bit.Api.Vault.AuthorizationHandlers.Collections;
|
using Bit.Api.Vault.AuthorizationHandlers.Collections;
|
||||||
using Bit.Api.Vault.AuthorizationHandlers.OrganizationUsers;
|
|
||||||
using Bit.Core;
|
using Bit.Core;
|
||||||
using Bit.Core.AdminConsole.Enums;
|
using Bit.Core.AdminConsole.Enums;
|
||||||
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
|
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
using Bit.Core.Auth.Enums;
|
using Bit.Core.Auth.Enums;
|
||||||
@ -44,7 +45,6 @@ public class OrganizationUsersController : Controller
|
|||||||
private readonly ICountNewSmSeatsRequiredQuery _countNewSmSeatsRequiredQuery;
|
private readonly ICountNewSmSeatsRequiredQuery _countNewSmSeatsRequiredQuery;
|
||||||
private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand;
|
private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand;
|
||||||
private readonly IUpdateOrganizationUserCommand _updateOrganizationUserCommand;
|
private readonly IUpdateOrganizationUserCommand _updateOrganizationUserCommand;
|
||||||
private readonly IUpdateOrganizationUserGroupsCommand _updateOrganizationUserGroupsCommand;
|
|
||||||
private readonly IAcceptOrgUserCommand _acceptOrgUserCommand;
|
private readonly IAcceptOrgUserCommand _acceptOrgUserCommand;
|
||||||
private readonly IAuthorizationService _authorizationService;
|
private readonly IAuthorizationService _authorizationService;
|
||||||
private readonly IApplicationCacheService _applicationCacheService;
|
private readonly IApplicationCacheService _applicationCacheService;
|
||||||
@ -52,6 +52,7 @@ public class OrganizationUsersController : Controller
|
|||||||
private readonly ISsoConfigRepository _ssoConfigRepository;
|
private readonly ISsoConfigRepository _ssoConfigRepository;
|
||||||
private readonly IOrganizationUserUserDetailsQuery _organizationUserUserDetailsQuery;
|
private readonly IOrganizationUserUserDetailsQuery _organizationUserUserDetailsQuery;
|
||||||
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
|
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
|
||||||
|
private readonly IDeleteManagedOrganizationUserAccountCommand _deleteManagedOrganizationUserAccountCommand;
|
||||||
|
|
||||||
|
|
||||||
public OrganizationUsersController(
|
public OrganizationUsersController(
|
||||||
@ -66,14 +67,14 @@ public class OrganizationUsersController : Controller
|
|||||||
ICountNewSmSeatsRequiredQuery countNewSmSeatsRequiredQuery,
|
ICountNewSmSeatsRequiredQuery countNewSmSeatsRequiredQuery,
|
||||||
IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand,
|
IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand,
|
||||||
IUpdateOrganizationUserCommand updateOrganizationUserCommand,
|
IUpdateOrganizationUserCommand updateOrganizationUserCommand,
|
||||||
IUpdateOrganizationUserGroupsCommand updateOrganizationUserGroupsCommand,
|
|
||||||
IAcceptOrgUserCommand acceptOrgUserCommand,
|
IAcceptOrgUserCommand acceptOrgUserCommand,
|
||||||
IAuthorizationService authorizationService,
|
IAuthorizationService authorizationService,
|
||||||
IApplicationCacheService applicationCacheService,
|
IApplicationCacheService applicationCacheService,
|
||||||
IFeatureService featureService,
|
IFeatureService featureService,
|
||||||
ISsoConfigRepository ssoConfigRepository,
|
ISsoConfigRepository ssoConfigRepository,
|
||||||
IOrganizationUserUserDetailsQuery organizationUserUserDetailsQuery,
|
IOrganizationUserUserDetailsQuery organizationUserUserDetailsQuery,
|
||||||
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery)
|
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
|
||||||
|
IDeleteManagedOrganizationUserAccountCommand deleteManagedOrganizationUserAccountCommand)
|
||||||
{
|
{
|
||||||
_organizationRepository = organizationRepository;
|
_organizationRepository = organizationRepository;
|
||||||
_organizationUserRepository = organizationUserRepository;
|
_organizationUserRepository = organizationUserRepository;
|
||||||
@ -86,7 +87,6 @@ public class OrganizationUsersController : Controller
|
|||||||
_countNewSmSeatsRequiredQuery = countNewSmSeatsRequiredQuery;
|
_countNewSmSeatsRequiredQuery = countNewSmSeatsRequiredQuery;
|
||||||
_updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand;
|
_updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand;
|
||||||
_updateOrganizationUserCommand = updateOrganizationUserCommand;
|
_updateOrganizationUserCommand = updateOrganizationUserCommand;
|
||||||
_updateOrganizationUserGroupsCommand = updateOrganizationUserGroupsCommand;
|
|
||||||
_acceptOrgUserCommand = acceptOrgUserCommand;
|
_acceptOrgUserCommand = acceptOrgUserCommand;
|
||||||
_authorizationService = authorizationService;
|
_authorizationService = authorizationService;
|
||||||
_applicationCacheService = applicationCacheService;
|
_applicationCacheService = applicationCacheService;
|
||||||
@ -94,6 +94,7 @@ public class OrganizationUsersController : Controller
|
|||||||
_ssoConfigRepository = ssoConfigRepository;
|
_ssoConfigRepository = ssoConfigRepository;
|
||||||
_organizationUserUserDetailsQuery = organizationUserUserDetailsQuery;
|
_organizationUserUserDetailsQuery = organizationUserUserDetailsQuery;
|
||||||
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
|
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
|
||||||
|
_deleteManagedOrganizationUserAccountCommand = deleteManagedOrganizationUserAccountCommand;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id}")]
|
[HttpGet("{id}")]
|
||||||
@ -115,11 +116,27 @@ public class OrganizationUsersController : Controller
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("mini-details")]
|
||||||
|
[RequireFeature(FeatureFlagKeys.Pm3478RefactorOrganizationUserApi)]
|
||||||
|
public async Task<ListResponseModel<OrganizationUserUserMiniDetailsResponseModel>> GetMiniDetails(Guid orgId)
|
||||||
|
{
|
||||||
|
var authorizationResult = await _authorizationService.AuthorizeAsync(User, new OrganizationScope(orgId),
|
||||||
|
OrganizationUserUserMiniDetailsOperations.ReadAll);
|
||||||
|
if (!authorizationResult.Succeeded)
|
||||||
|
{
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
var organizationUserUserDetails = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(orgId);
|
||||||
|
return new ListResponseModel<OrganizationUserUserMiniDetailsResponseModel>(
|
||||||
|
organizationUserUserDetails.Select(ou => new OrganizationUserUserMiniDetailsResponseModel(ou)));
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("")]
|
[HttpGet("")]
|
||||||
public async Task<ListResponseModel<OrganizationUserUserDetailsResponseModel>> Get(Guid orgId, bool includeGroups = false, bool includeCollections = false)
|
public async Task<ListResponseModel<OrganizationUserUserDetailsResponseModel>> Get(Guid orgId, bool includeGroups = false, bool includeCollections = false)
|
||||||
{
|
{
|
||||||
var authorized = (await _authorizationService.AuthorizeAsync(
|
var authorized = (await _authorizationService.AuthorizeAsync(
|
||||||
User, OrganizationUserOperations.ReadAll(orgId))).Succeeded;
|
User, new OrganizationScope(orgId), OrganizationUserUserDetailsOperations.ReadAll)).Succeeded;
|
||||||
if (!authorized)
|
if (!authorized)
|
||||||
{
|
{
|
||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
@ -528,6 +545,59 @@ public class OrganizationUsersController : Controller
|
|||||||
new OrganizationUserBulkResponseModel(r.Item1.Id, r.Item2)));
|
new OrganizationUserBulkResponseModel(r.Item1.Id, r.Item2)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[RequireFeature(FeatureFlagKeys.AccountDeprovisioning)]
|
||||||
|
[HttpDelete("{id}/delete-account")]
|
||||||
|
[HttpPost("{id}/delete-account")]
|
||||||
|
public async Task DeleteAccount(Guid orgId, Guid id, [FromBody] SecretVerificationRequestModel model)
|
||||||
|
{
|
||||||
|
if (!await _currentContext.ManageUsers(orgId))
|
||||||
|
{
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentUser = await _userService.GetUserByPrincipalAsync(User);
|
||||||
|
if (currentUser == null)
|
||||||
|
{
|
||||||
|
throw new UnauthorizedAccessException();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await _userService.VerifySecretAsync(currentUser, model.Secret))
|
||||||
|
{
|
||||||
|
await Task.Delay(2000);
|
||||||
|
throw new BadRequestException(string.Empty, "User verification failed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await _deleteManagedOrganizationUserAccountCommand.DeleteUserAsync(orgId, id, currentUser.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RequireFeature(FeatureFlagKeys.AccountDeprovisioning)]
|
||||||
|
[HttpDelete("delete-account")]
|
||||||
|
[HttpPost("delete-account")]
|
||||||
|
public async Task<ListResponseModel<OrganizationUserBulkResponseModel>> BulkDeleteAccount(Guid orgId, [FromBody] SecureOrganizationUserBulkRequestModel model)
|
||||||
|
{
|
||||||
|
if (!await _currentContext.ManageUsers(orgId))
|
||||||
|
{
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentUser = await _userService.GetUserByPrincipalAsync(User);
|
||||||
|
if (currentUser == null)
|
||||||
|
{
|
||||||
|
throw new UnauthorizedAccessException();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await _userService.VerifySecretAsync(currentUser, model.Secret))
|
||||||
|
{
|
||||||
|
await Task.Delay(2000);
|
||||||
|
throw new BadRequestException(string.Empty, "User verification failed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var results = await _deleteManagedOrganizationUserAccountCommand.DeleteManyUsersAsync(orgId, model.Ids, currentUser.Id);
|
||||||
|
|
||||||
|
return new ListResponseModel<OrganizationUserBulkResponseModel>(results.Select(r =>
|
||||||
|
new OrganizationUserBulkResponseModel(r.OrganizationUserId, r.ErrorMessage)));
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPatch("{id}/revoke")]
|
[HttpPatch("{id}/revoke")]
|
||||||
[HttpPut("{id}/revoke")]
|
[HttpPut("{id}/revoke")]
|
||||||
public async Task RevokeAsync(Guid orgId, Guid id)
|
public async Task RevokeAsync(Guid orgId, Guid id)
|
||||||
|
@ -171,6 +171,21 @@ public class OrganizationsController : Controller
|
|||||||
return new OrganizationResponseModel(result.Item1);
|
return new OrganizationResponseModel(result.Item1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("create-without-payment")]
|
||||||
|
[SelfHosted(NotSelfHostedOnly = true)]
|
||||||
|
public async Task<OrganizationResponseModel> CreateWithoutPaymentAsync([FromBody] OrganizationNoPaymentCreateRequest model)
|
||||||
|
{
|
||||||
|
var user = await _userService.GetUserByPrincipalAsync(User);
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
throw new UnauthorizedAccessException();
|
||||||
|
}
|
||||||
|
|
||||||
|
var organizationSignup = model.ToOrganizationSignup(user);
|
||||||
|
var result = await _organizationService.SignUpAsync(organizationSignup);
|
||||||
|
return new OrganizationResponseModel(result.Item1);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPut("{id}")]
|
[HttpPut("{id}")]
|
||||||
[HttpPost("{id}")]
|
[HttpPost("{id}")]
|
||||||
public async Task<OrganizationResponseModel> Put(string id, [FromBody] OrganizationUpdateRequestModel model)
|
public async Task<OrganizationResponseModel> Put(string id, [FromBody] OrganizationUpdateRequestModel model)
|
||||||
|
@ -14,42 +14,63 @@ public class OrganizationCreateRequestModel : IValidatableObject
|
|||||||
[StringLength(50, ErrorMessage = "The field Name exceeds the maximum length.")]
|
[StringLength(50, ErrorMessage = "The field Name exceeds the maximum length.")]
|
||||||
[JsonConverter(typeof(HtmlEncodingStringConverter))]
|
[JsonConverter(typeof(HtmlEncodingStringConverter))]
|
||||||
public string Name { get; set; }
|
public string Name { get; set; }
|
||||||
|
|
||||||
[StringLength(50, ErrorMessage = "The field Business Name exceeds the maximum length.")]
|
[StringLength(50, ErrorMessage = "The field Business Name exceeds the maximum length.")]
|
||||||
[JsonConverter(typeof(HtmlEncodingStringConverter))]
|
[JsonConverter(typeof(HtmlEncodingStringConverter))]
|
||||||
public string BusinessName { get; set; }
|
public string BusinessName { get; set; }
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
[StringLength(256)]
|
[StringLength(256)]
|
||||||
[EmailAddress]
|
[EmailAddress]
|
||||||
public string BillingEmail { get; set; }
|
public string BillingEmail { get; set; }
|
||||||
|
|
||||||
public PlanType PlanType { get; set; }
|
public PlanType PlanType { get; set; }
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
public string Key { get; set; }
|
public string Key { get; set; }
|
||||||
|
|
||||||
public OrganizationKeysRequestModel Keys { get; set; }
|
public OrganizationKeysRequestModel Keys { get; set; }
|
||||||
public PaymentMethodType? PaymentMethodType { get; set; }
|
public PaymentMethodType? PaymentMethodType { get; set; }
|
||||||
public string PaymentToken { get; set; }
|
public string PaymentToken { get; set; }
|
||||||
|
|
||||||
[Range(0, int.MaxValue)]
|
[Range(0, int.MaxValue)]
|
||||||
public int AdditionalSeats { get; set; }
|
public int AdditionalSeats { get; set; }
|
||||||
|
|
||||||
[Range(0, 99)]
|
[Range(0, 99)]
|
||||||
public short? AdditionalStorageGb { get; set; }
|
public short? AdditionalStorageGb { get; set; }
|
||||||
|
|
||||||
public bool PremiumAccessAddon { get; set; }
|
public bool PremiumAccessAddon { get; set; }
|
||||||
|
|
||||||
[EncryptedString]
|
[EncryptedString]
|
||||||
[EncryptedStringLength(1000)]
|
[EncryptedStringLength(1000)]
|
||||||
public string CollectionName { get; set; }
|
public string CollectionName { get; set; }
|
||||||
|
|
||||||
public string TaxIdNumber { get; set; }
|
public string TaxIdNumber { get; set; }
|
||||||
|
|
||||||
public string BillingAddressLine1 { get; set; }
|
public string BillingAddressLine1 { get; set; }
|
||||||
|
|
||||||
public string BillingAddressLine2 { get; set; }
|
public string BillingAddressLine2 { get; set; }
|
||||||
|
|
||||||
public string BillingAddressCity { get; set; }
|
public string BillingAddressCity { get; set; }
|
||||||
|
|
||||||
public string BillingAddressState { get; set; }
|
public string BillingAddressState { get; set; }
|
||||||
|
|
||||||
public string BillingAddressPostalCode { get; set; }
|
public string BillingAddressPostalCode { get; set; }
|
||||||
|
|
||||||
[StringLength(2)]
|
[StringLength(2)]
|
||||||
public string BillingAddressCountry { get; set; }
|
public string BillingAddressCountry { get; set; }
|
||||||
|
|
||||||
public int? MaxAutoscaleSeats { get; set; }
|
public int? MaxAutoscaleSeats { get; set; }
|
||||||
|
|
||||||
[Range(0, int.MaxValue)]
|
[Range(0, int.MaxValue)]
|
||||||
public int? AdditionalSmSeats { get; set; }
|
public int? AdditionalSmSeats { get; set; }
|
||||||
|
|
||||||
[Range(0, int.MaxValue)]
|
[Range(0, int.MaxValue)]
|
||||||
public int? AdditionalServiceAccounts { get; set; }
|
public int? AdditionalServiceAccounts { get; set; }
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
public bool UseSecretsManager { get; set; }
|
public bool UseSecretsManager { get; set; }
|
||||||
|
|
||||||
public bool IsFromSecretsManagerTrial { get; set; }
|
public bool IsFromSecretsManagerTrial { get; set; }
|
||||||
|
|
||||||
public string InitiationPath { get; set; }
|
public string InitiationPath { get; set; }
|
||||||
@ -99,16 +120,19 @@ public class OrganizationCreateRequestModel : IValidatableObject
|
|||||||
{
|
{
|
||||||
yield return new ValidationResult("Payment required.", new string[] { nameof(PaymentToken) });
|
yield return new ValidationResult("Payment required.", new string[] { nameof(PaymentToken) });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (PlanType != PlanType.Free && !PaymentMethodType.HasValue)
|
if (PlanType != PlanType.Free && !PaymentMethodType.HasValue)
|
||||||
{
|
{
|
||||||
yield return new ValidationResult("Payment method type required.",
|
yield return new ValidationResult("Payment method type required.",
|
||||||
new string[] { nameof(PaymentMethodType) });
|
new string[] { nameof(PaymentMethodType) });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (PlanType != PlanType.Free && string.IsNullOrWhiteSpace(BillingAddressCountry))
|
if (PlanType != PlanType.Free && string.IsNullOrWhiteSpace(BillingAddressCountry))
|
||||||
{
|
{
|
||||||
yield return new ValidationResult("Country required.",
|
yield return new ValidationResult("Country required.",
|
||||||
new string[] { nameof(BillingAddressCountry) });
|
new string[] { nameof(BillingAddressCountry) });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (PlanType != PlanType.Free && BillingAddressCountry == "US" &&
|
if (PlanType != PlanType.Free && BillingAddressCountry == "US" &&
|
||||||
string.IsNullOrWhiteSpace(BillingAddressPostalCode))
|
string.IsNullOrWhiteSpace(BillingAddressPostalCode))
|
||||||
{
|
{
|
||||||
@ -117,3 +141,4 @@ public class OrganizationCreateRequestModel : IValidatableObject
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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; }
|
public IEnumerable<Guid> Groups { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
|
public class OrganizationUserUserMiniDetailsResponseModel : ResponseModel
|
||||||
|
{
|
||||||
|
public OrganizationUserUserMiniDetailsResponseModel(OrganizationUserUserDetails organizationUser)
|
||||||
|
: base("organizationUserUserMiniDetails")
|
||||||
|
{
|
||||||
|
Id = organizationUser.Id;
|
||||||
|
UserId = organizationUser.UserId;
|
||||||
|
Type = organizationUser.Type;
|
||||||
|
Status = organizationUser.Status;
|
||||||
|
Name = organizationUser.Name;
|
||||||
|
Email = organizationUser.Email;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Guid Id { get; }
|
||||||
|
public Guid? UserId { get; }
|
||||||
|
public OrganizationUserType Type { get; }
|
||||||
|
public OrganizationUserStatusType Status { get; }
|
||||||
|
public string? Name { get; }
|
||||||
|
public string Email { get; }
|
||||||
|
}
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
public class OrganizationUserUserDetailsResponseModel : OrganizationUserResponseModel
|
public class OrganizationUserUserDetailsResponseModel : OrganizationUserResponseModel
|
||||||
{
|
{
|
||||||
public OrganizationUserUserDetailsResponseModel(OrganizationUserUserDetails organizationUser,
|
public OrganizationUserUserDetailsResponseModel(OrganizationUserUserDetails organizationUser,
|
||||||
|
@ -35,7 +35,7 @@
|
|||||||
<PackageReference Include="AspNetCore.HealthChecks.SqlServer" Version="8.0.0" />
|
<PackageReference Include="AspNetCore.HealthChecks.SqlServer" Version="8.0.0" />
|
||||||
<PackageReference Include="AspNetCore.HealthChecks.Uris" Version="8.0.0" />
|
<PackageReference Include="AspNetCore.HealthChecks.Uris" Version="8.0.0" />
|
||||||
<PackageReference Include="Azure.Messaging.EventGrid" Version="4.25.0" />
|
<PackageReference Include="Azure.Messaging.EventGrid" Version="4.25.0" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.7.3" />
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.8.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
@ -443,10 +443,11 @@ public class AccountsController : Controller
|
|||||||
|
|
||||||
var twoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user);
|
var twoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user);
|
||||||
var hasPremiumFromOrg = await _userService.HasPremiumFromOrganization(user);
|
var hasPremiumFromOrg = await _userService.HasPremiumFromOrganization(user);
|
||||||
|
var managedByOrganizationId = await GetManagedByOrganizationIdAsync(user);
|
||||||
|
|
||||||
var response = new ProfileResponseModel(user, organizationUserDetails, providerUserDetails,
|
var response = new ProfileResponseModel(user, organizationUserDetails, providerUserDetails,
|
||||||
providerUserOrganizationDetails, twoFactorEnabled,
|
providerUserOrganizationDetails, twoFactorEnabled,
|
||||||
hasPremiumFromOrg);
|
hasPremiumFromOrg, managedByOrganizationId);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -471,7 +472,12 @@ public class AccountsController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
await _userService.SaveUserAsync(model.ToUser(user));
|
await _userService.SaveUserAsync(model.ToUser(user));
|
||||||
var response = new ProfileResponseModel(user, null, null, null, await _userService.TwoFactorIsEnabledAsync(user), await _userService.HasPremiumFromOrganization(user));
|
|
||||||
|
var twoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user);
|
||||||
|
var hasPremiumFromOrg = await _userService.HasPremiumFromOrganization(user);
|
||||||
|
var managedByOrganizationId = await GetManagedByOrganizationIdAsync(user);
|
||||||
|
|
||||||
|
var response = new ProfileResponseModel(user, null, null, null, twoFactorEnabled, hasPremiumFromOrg, managedByOrganizationId);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -485,7 +491,12 @@ public class AccountsController : Controller
|
|||||||
throw new UnauthorizedAccessException();
|
throw new UnauthorizedAccessException();
|
||||||
}
|
}
|
||||||
await _userService.SaveUserAsync(model.ToUser(user), true);
|
await _userService.SaveUserAsync(model.ToUser(user), true);
|
||||||
var response = new ProfileResponseModel(user, null, null, null, await _userService.TwoFactorIsEnabledAsync(user), await _userService.HasPremiumFromOrganization(user));
|
|
||||||
|
var userTwoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user);
|
||||||
|
var userHasPremiumFromOrganization = await _userService.HasPremiumFromOrganization(user);
|
||||||
|
var managedByOrganizationId = await GetManagedByOrganizationIdAsync(user);
|
||||||
|
|
||||||
|
var response = new ProfileResponseModel(user, null, null, null, userTwoFactorEnabled, userHasPremiumFromOrganization, managedByOrganizationId);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -633,7 +644,12 @@ public class AccountsController : Controller
|
|||||||
BillingAddressCountry = model.Country,
|
BillingAddressCountry = model.Country,
|
||||||
BillingAddressPostalCode = model.PostalCode,
|
BillingAddressPostalCode = model.PostalCode,
|
||||||
});
|
});
|
||||||
var profile = new ProfileResponseModel(user, null, null, null, await _userService.TwoFactorIsEnabledAsync(user), await _userService.HasPremiumFromOrganization(user));
|
|
||||||
|
var userTwoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user);
|
||||||
|
var userHasPremiumFromOrganization = await _userService.HasPremiumFromOrganization(user);
|
||||||
|
var managedByOrganizationId = await GetManagedByOrganizationIdAsync(user);
|
||||||
|
|
||||||
|
var profile = new ProfileResponseModel(user, null, null, null, userTwoFactorEnabled, userHasPremiumFromOrganization, managedByOrganizationId);
|
||||||
return new PaymentResponseModel
|
return new PaymentResponseModel
|
||||||
{
|
{
|
||||||
UserProfile = profile,
|
UserProfile = profile,
|
||||||
@ -920,4 +936,15 @@ public class AccountsController : Controller
|
|||||||
throw new BadRequestException("Token", "Invalid token");
|
throw new BadRequestException("Token", "Invalid token");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<Guid?> GetManagedByOrganizationIdAsync(User user)
|
||||||
|
{
|
||||||
|
if (!_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var organizationManagingUser = await _userService.GetOrganizationManagingUserAsync(user.Id);
|
||||||
|
return organizationManagingUser?.Id;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -14,7 +14,8 @@ public class ProfileResponseModel : ResponseModel
|
|||||||
IEnumerable<ProviderUserProviderDetails> providerUserDetails,
|
IEnumerable<ProviderUserProviderDetails> providerUserDetails,
|
||||||
IEnumerable<ProviderUserOrganizationDetails> providerUserOrganizationDetails,
|
IEnumerable<ProviderUserOrganizationDetails> providerUserOrganizationDetails,
|
||||||
bool twoFactorEnabled,
|
bool twoFactorEnabled,
|
||||||
bool premiumFromOrganization) : base("profile")
|
bool premiumFromOrganization,
|
||||||
|
Guid? managedByOrganizationId) : base("profile")
|
||||||
{
|
{
|
||||||
if (user == null)
|
if (user == null)
|
||||||
{
|
{
|
||||||
@ -40,6 +41,7 @@ public class ProfileResponseModel : ResponseModel
|
|||||||
Providers = providerUserDetails?.Select(p => new ProfileProviderResponseModel(p));
|
Providers = providerUserDetails?.Select(p => new ProfileProviderResponseModel(p));
|
||||||
ProviderOrganizations =
|
ProviderOrganizations =
|
||||||
providerUserOrganizationDetails?.Select(po => new ProfileProviderOrganizationResponseModel(po));
|
providerUserOrganizationDetails?.Select(po => new ProfileProviderOrganizationResponseModel(po));
|
||||||
|
ManagedByOrganizationId = managedByOrganizationId;
|
||||||
}
|
}
|
||||||
|
|
||||||
public ProfileResponseModel() : base("profile")
|
public ProfileResponseModel() : base("profile")
|
||||||
@ -61,6 +63,7 @@ public class ProfileResponseModel : ResponseModel
|
|||||||
public bool UsesKeyConnector { get; set; }
|
public bool UsesKeyConnector { get; set; }
|
||||||
public string AvatarColor { get; set; }
|
public string AvatarColor { get; set; }
|
||||||
public DateTime CreationDate { get; set; }
|
public DateTime CreationDate { get; set; }
|
||||||
|
public Guid? ManagedByOrganizationId { get; set; }
|
||||||
public IEnumerable<ProfileOrganizationResponseModel> Organizations { get; set; }
|
public IEnumerable<ProfileOrganizationResponseModel> Organizations { get; set; }
|
||||||
public IEnumerable<ProfileProviderResponseModel> Providers { get; set; }
|
public IEnumerable<ProfileProviderResponseModel> Providers { get; set; }
|
||||||
public IEnumerable<ProfileProviderOrganizationResponseModel> ProviderOrganizations { get; set; }
|
public IEnumerable<ProfileProviderOrganizationResponseModel> ProviderOrganizations { get; set; }
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
using Bit.Api.Vault.AuthorizationHandlers.Collections;
|
using Bit.Api.Vault.AuthorizationHandlers.Collections;
|
||||||
using Bit.Api.Vault.AuthorizationHandlers.Groups;
|
using Bit.Api.Vault.AuthorizationHandlers.Groups;
|
||||||
using Bit.Api.Vault.AuthorizationHandlers.OrganizationUsers;
|
|
||||||
using Bit.Core.IdentityServer;
|
using Bit.Core.IdentityServer;
|
||||||
using Bit.Core.Settings;
|
using Bit.Core.Settings;
|
||||||
using Bit.Core.Utilities;
|
using Bit.Core.Utilities;
|
||||||
@ -100,6 +99,5 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddScoped<IAuthorizationHandler, BulkCollectionAuthorizationHandler>();
|
services.AddScoped<IAuthorizationHandler, BulkCollectionAuthorizationHandler>();
|
||||||
services.AddScoped<IAuthorizationHandler, CollectionAuthorizationHandler>();
|
services.AddScoped<IAuthorizationHandler, CollectionAuthorizationHandler>();
|
||||||
services.AddScoped<IAuthorizationHandler, GroupAuthorizationHandler>();
|
services.AddScoped<IAuthorizationHandler, GroupAuthorizationHandler>();
|
||||||
services.AddScoped<IAuthorizationHandler, OrganizationUserAuthorizationHandler>();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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.Api.Vault.Models.Response;
|
||||||
|
using Bit.Core;
|
||||||
using Bit.Core.AdminConsole.Entities;
|
using Bit.Core.AdminConsole.Entities;
|
||||||
using Bit.Core.AdminConsole.Enums.Provider;
|
using Bit.Core.AdminConsole.Enums.Provider;
|
||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
@ -6,6 +7,7 @@ using Bit.Core.Entities;
|
|||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.Exceptions;
|
using Bit.Core.Exceptions;
|
||||||
using Bit.Core.Models.Data;
|
using Bit.Core.Models.Data;
|
||||||
|
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
using Bit.Core.Services;
|
using Bit.Core.Services;
|
||||||
using Bit.Core.Settings;
|
using Bit.Core.Settings;
|
||||||
@ -30,6 +32,7 @@ public class SyncController : Controller
|
|||||||
private readonly IPolicyRepository _policyRepository;
|
private readonly IPolicyRepository _policyRepository;
|
||||||
private readonly ISendRepository _sendRepository;
|
private readonly ISendRepository _sendRepository;
|
||||||
private readonly GlobalSettings _globalSettings;
|
private readonly GlobalSettings _globalSettings;
|
||||||
|
private readonly IFeatureService _featureService;
|
||||||
|
|
||||||
public SyncController(
|
public SyncController(
|
||||||
IUserService userService,
|
IUserService userService,
|
||||||
@ -41,7 +44,8 @@ public class SyncController : Controller
|
|||||||
IProviderUserRepository providerUserRepository,
|
IProviderUserRepository providerUserRepository,
|
||||||
IPolicyRepository policyRepository,
|
IPolicyRepository policyRepository,
|
||||||
ISendRepository sendRepository,
|
ISendRepository sendRepository,
|
||||||
GlobalSettings globalSettings)
|
GlobalSettings globalSettings,
|
||||||
|
IFeatureService featureService)
|
||||||
{
|
{
|
||||||
_userService = userService;
|
_userService = userService;
|
||||||
_folderRepository = folderRepository;
|
_folderRepository = folderRepository;
|
||||||
@ -53,6 +57,7 @@ public class SyncController : Controller
|
|||||||
_policyRepository = policyRepository;
|
_policyRepository = policyRepository;
|
||||||
_sendRepository = sendRepository;
|
_sendRepository = sendRepository;
|
||||||
_globalSettings = globalSettings;
|
_globalSettings = globalSettings;
|
||||||
|
_featureService = featureService;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("")]
|
[HttpGet("")]
|
||||||
@ -90,9 +95,23 @@ public class SyncController : Controller
|
|||||||
|
|
||||||
var userTwoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user);
|
var userTwoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user);
|
||||||
var userHasPremiumFromOrganization = await _userService.HasPremiumFromOrganization(user);
|
var userHasPremiumFromOrganization = await _userService.HasPremiumFromOrganization(user);
|
||||||
var response = new SyncResponseModel(_globalSettings, user, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationUserDetails,
|
var managedByOrganizationId = await GetManagedByOrganizationIdAsync(user, organizationUserDetails);
|
||||||
providerUserDetails, providerUserOrganizationDetails, folders, collections, ciphers,
|
|
||||||
collectionCiphersGroupDict, excludeDomains, policies, sends);
|
var response = new SyncResponseModel(_globalSettings, user, userTwoFactorEnabled, userHasPremiumFromOrganization,
|
||||||
|
managedByOrganizationId, organizationUserDetails, providerUserDetails, providerUserOrganizationDetails,
|
||||||
|
folders, collections, ciphers, collectionCiphersGroupDict, excludeDomains, policies, sends);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<Guid?> GetManagedByOrganizationIdAsync(User user, IEnumerable<OrganizationUserOrganizationDetails> organizationUserDetails)
|
||||||
|
{
|
||||||
|
if (!_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) ||
|
||||||
|
!organizationUserDetails.Any(o => o.Enabled && o.UseSso))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var organizationManagingUser = await _userService.GetOrganizationManagingUserAsync(user.Id);
|
||||||
|
return organizationManagingUser?.Id;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -21,6 +21,7 @@ public class SyncResponseModel : ResponseModel
|
|||||||
User user,
|
User user,
|
||||||
bool userTwoFactorEnabled,
|
bool userTwoFactorEnabled,
|
||||||
bool userHasPremiumFromOrganization,
|
bool userHasPremiumFromOrganization,
|
||||||
|
Guid? managedByOrganizationId,
|
||||||
IEnumerable<OrganizationUserOrganizationDetails> organizationUserDetails,
|
IEnumerable<OrganizationUserOrganizationDetails> organizationUserDetails,
|
||||||
IEnumerable<ProviderUserProviderDetails> providerUserDetails,
|
IEnumerable<ProviderUserProviderDetails> providerUserDetails,
|
||||||
IEnumerable<ProviderUserOrganizationDetails> providerUserOrganizationDetails,
|
IEnumerable<ProviderUserOrganizationDetails> providerUserOrganizationDetails,
|
||||||
@ -34,7 +35,7 @@ public class SyncResponseModel : ResponseModel
|
|||||||
: base("sync")
|
: base("sync")
|
||||||
{
|
{
|
||||||
Profile = new ProfileResponseModel(user, organizationUserDetails, providerUserDetails,
|
Profile = new ProfileResponseModel(user, organizationUserDetails, providerUserDetails,
|
||||||
providerUserOrganizationDetails, userTwoFactorEnabled, userHasPremiumFromOrganization);
|
providerUserOrganizationDetails, userTwoFactorEnabled, userHasPremiumFromOrganization, managedByOrganizationId);
|
||||||
Folders = folders.Select(f => new FolderResponseModel(f));
|
Folders = folders.Select(f => new FolderResponseModel(f));
|
||||||
Ciphers = ciphers.Select(c => new CipherDetailsResponseModel(c, globalSettings, collectionCiphersDict));
|
Ciphers = ciphers.Select(c => new CipherDetailsResponseModel(c, globalSettings, collectionCiphersDict));
|
||||||
Collections = collections?.Select(
|
Collections = collections?.Select(
|
||||||
|
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,
|
RequestOptions requestOptions = null,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<Event> GetEvent(
|
||||||
|
string eventId,
|
||||||
|
EventGetOptions eventGetOptions = null,
|
||||||
|
RequestOptions requestOptions = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task<Invoice> GetInvoice(
|
Task<Invoice> GetInvoice(
|
||||||
string invoiceId,
|
string invoiceId,
|
||||||
InvoiceGetOptions invoiceGetOptions = null,
|
InvoiceGetOptions invoiceGetOptions = null,
|
||||||
|
@ -2,34 +2,63 @@
|
|||||||
|
|
||||||
namespace Bit.Billing.Services.Implementations;
|
namespace Bit.Billing.Services.Implementations;
|
||||||
|
|
||||||
public class InvoiceCreatedHandler : IInvoiceCreatedHandler
|
public class InvoiceCreatedHandler(
|
||||||
{
|
ILogger<InvoiceCreatedHandler> logger,
|
||||||
private readonly IStripeEventService _stripeEventService;
|
|
||||||
private readonly IStripeEventUtilityService _stripeEventUtilityService;
|
|
||||||
private readonly IProviderEventService _providerEventService;
|
|
||||||
|
|
||||||
public InvoiceCreatedHandler(
|
|
||||||
IStripeEventService stripeEventService,
|
IStripeEventService stripeEventService,
|
||||||
IStripeEventUtilityService stripeEventUtilityService,
|
IStripeEventUtilityService stripeEventUtilityService,
|
||||||
IProviderEventService providerEventService)
|
IProviderEventService providerEventService)
|
||||||
|
: IInvoiceCreatedHandler
|
||||||
{
|
{
|
||||||
_stripeEventService = stripeEventService;
|
|
||||||
_stripeEventUtilityService = stripeEventUtilityService;
|
|
||||||
_providerEventService = providerEventService;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handles the <see cref="HandledStripeWebhook.InvoiceCreated"/> event type from Stripe.
|
/// <para>
|
||||||
|
/// This handler processes the `invoice.created` event in <see href="https://docs.stripe.com/api/events/types#event_types-invoice.created">Stripe</see>. It has
|
||||||
|
/// two primary responsibilities.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// 1. Checks to see if the newly created invoice belongs to a PayPal customer. If it does, and the invoice is ready to be paid, it will attempt to pay the invoice
|
||||||
|
/// with Braintree and then let Stripe know the invoice can be marked as paid.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// 2. If the invoice is for a provider, it records a point-in-time snapshot of the invoice broken down by the provider's client organizations. This is later used in
|
||||||
|
/// the provider invoice export.
|
||||||
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="parsedEvent"></param>
|
|
||||||
public async Task HandleAsync(Event parsedEvent)
|
public async Task HandleAsync(Event parsedEvent)
|
||||||
{
|
{
|
||||||
var invoice = await _stripeEventService.GetInvoice(parsedEvent, true);
|
try
|
||||||
if (_stripeEventUtilityService.ShouldAttemptToPayInvoice(invoice))
|
|
||||||
{
|
{
|
||||||
await _stripeEventUtilityService.AttemptToPayInvoiceAsync(invoice);
|
var invoice = await stripeEventService.GetInvoice(parsedEvent, true, ["customer"]);
|
||||||
|
|
||||||
|
var usingPayPal = invoice.Customer?.Metadata.ContainsKey("btCustomerId") ?? false;
|
||||||
|
|
||||||
|
if (usingPayPal && invoice is
|
||||||
|
{
|
||||||
|
AmountDue: > 0,
|
||||||
|
Paid: false,
|
||||||
|
CollectionMethod: "charge_automatically",
|
||||||
|
BillingReason:
|
||||||
|
"subscription_create" or
|
||||||
|
"subscription_cycle" or
|
||||||
|
"automatic_pending_invoice_item_invoice",
|
||||||
|
SubscriptionId: not null and not ""
|
||||||
|
})
|
||||||
|
{
|
||||||
|
await stripeEventUtilityService.AttemptToPayInvoiceAsync(invoice);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
logger.LogError(exception, "Failed to attempt paying for invoice while handling 'invoice.created' event ({EventID})", parsedEvent.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
await _providerEventService.TryRecordInvoiceLineItems(parsedEvent);
|
try
|
||||||
|
{
|
||||||
|
await providerEventService.TryRecordInvoiceLineItems(parsedEvent);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
logger.LogError(exception, "Failed to record provider invoice line items while handling 'invoice.created' event ({EventID})", parsedEvent.Id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -206,6 +206,12 @@ public class StripeEventUtilityService : IStripeEventUtilityService
|
|||||||
transaction.PaymentMethodType = PaymentMethodType.Card;
|
transaction.PaymentMethodType = PaymentMethodType.Card;
|
||||||
transaction.Details = $"{card.Brand?.ToUpperInvariant()}, *{card.Last4}";
|
transaction.Details = $"{card.Brand?.ToUpperInvariant()}, *{card.Last4}";
|
||||||
}
|
}
|
||||||
|
else if (charge.PaymentMethodDetails.UsBankAccount != null)
|
||||||
|
{
|
||||||
|
var usBankAccount = charge.PaymentMethodDetails.UsBankAccount;
|
||||||
|
transaction.PaymentMethodType = PaymentMethodType.BankAccount;
|
||||||
|
transaction.Details = $"{usBankAccount.BankName}, *{usBankAccount.Last4}";
|
||||||
|
}
|
||||||
else if (charge.PaymentMethodDetails.AchDebit != null)
|
else if (charge.PaymentMethodDetails.AchDebit != null)
|
||||||
{
|
{
|
||||||
var achDebit = charge.PaymentMethodDetails.AchDebit;
|
var achDebit = charge.PaymentMethodDetails.AchDebit;
|
||||||
|
@ -6,6 +6,7 @@ public class StripeFacade : IStripeFacade
|
|||||||
{
|
{
|
||||||
private readonly ChargeService _chargeService = new();
|
private readonly ChargeService _chargeService = new();
|
||||||
private readonly CustomerService _customerService = new();
|
private readonly CustomerService _customerService = new();
|
||||||
|
private readonly EventService _eventService = new();
|
||||||
private readonly InvoiceService _invoiceService = new();
|
private readonly InvoiceService _invoiceService = new();
|
||||||
private readonly PaymentMethodService _paymentMethodService = new();
|
private readonly PaymentMethodService _paymentMethodService = new();
|
||||||
private readonly SubscriptionService _subscriptionService = new();
|
private readonly SubscriptionService _subscriptionService = new();
|
||||||
@ -19,6 +20,13 @@ public class StripeFacade : IStripeFacade
|
|||||||
CancellationToken cancellationToken = default) =>
|
CancellationToken cancellationToken = default) =>
|
||||||
await _chargeService.GetAsync(chargeId, chargeGetOptions, requestOptions, cancellationToken);
|
await _chargeService.GetAsync(chargeId, chargeGetOptions, requestOptions, cancellationToken);
|
||||||
|
|
||||||
|
public async Task<Event> GetEvent(
|
||||||
|
string eventId,
|
||||||
|
EventGetOptions eventGetOptions = null,
|
||||||
|
RequestOptions requestOptions = null,
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
await _eventService.GetAsync(eventId, eventGetOptions, requestOptions, cancellationToken);
|
||||||
|
|
||||||
public async Task<Customer> GetCustomer(
|
public async Task<Customer> GetCustomer(
|
||||||
string customerId,
|
string customerId,
|
||||||
CustomerGetOptions customerGetOptions = null,
|
CustomerGetOptions customerGetOptions = null,
|
||||||
|
@ -32,12 +32,14 @@ public class SubscriptionDeletedHandler : ISubscriptionDeletedHandler
|
|||||||
var (organizationId, userId, providerId) = _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata);
|
var (organizationId, userId, providerId) = _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata);
|
||||||
var subCanceled = subscription.Status == StripeSubscriptionStatus.Canceled;
|
var subCanceled = subscription.Status == StripeSubscriptionStatus.Canceled;
|
||||||
|
|
||||||
|
const string providerMigrationCancellationComment = "Cancelled as part of provider migration to Consolidated Billing";
|
||||||
|
|
||||||
if (!subCanceled)
|
if (!subCanceled)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (organizationId.HasValue)
|
if (organizationId.HasValue && subscription is not { CancellationDetails.Comment: providerMigrationCancellationComment })
|
||||||
{
|
{
|
||||||
await _organizationService.DisableAsync(organizationId.Value, subscription.CurrentPeriodEnd);
|
await _organizationService.DisableAsync(organizationId.Value, subscription.CurrentPeriodEnd);
|
||||||
}
|
}
|
||||||
|
@ -94,6 +94,7 @@ public class Organization : ITableObject<Guid>, IStorableSubscriber, IRevisable,
|
|||||||
/// they have Can Manage permissions for.
|
/// they have Can Manage permissions for.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool LimitCollectionCreationDeletion { get; set; }
|
public bool LimitCollectionCreationDeletion { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// If set to true, admins, owners, and some custom users can read/write all collections and items in the Admin Console.
|
/// If set to true, admins, owners, and some custom users can read/write all collections and items in the Admin Console.
|
||||||
/// If set to false, users generally need collection-level permissions to read/write a collection or its items.
|
/// If set to false, users generally need collection-level permissions to read/write a collection or its items.
|
||||||
|
@ -9,4 +9,5 @@ public enum ScimProviderType : byte
|
|||||||
JumpCloud = 4,
|
JumpCloud = 4,
|
||||||
GoogleWorkspace = 5,
|
GoogleWorkspace = 5,
|
||||||
Rippling = 6,
|
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);
|
var paymentIntentClientSecret = await _paymentService.AdjustSeatsAsync(organization, plan, additionalSeats);
|
||||||
await _referenceEventService.RaiseEventAsync(
|
await _referenceEventService.RaiseEventAsync(
|
||||||
new ReferenceEvent(ReferenceEventType.AdjustSeats, organization, _currentContext)
|
new ReferenceEvent(ReferenceEventType.AdjustSeats, organization, _currentContext)
|
||||||
@ -589,12 +594,22 @@ public class OrganizationService : IOrganizationService
|
|||||||
await _organizationBillingService.Finalize(sale);
|
await _organizationBillingService.Finalize(sale);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
{
|
||||||
|
if (signup.PaymentMethodType != null)
|
||||||
{
|
{
|
||||||
await _paymentService.PurchaseOrganizationAsync(organization, signup.PaymentMethodType.Value,
|
await _paymentService.PurchaseOrganizationAsync(organization, signup.PaymentMethodType.Value,
|
||||||
signup.PaymentToken, plan, signup.AdditionalStorageGb, signup.AdditionalSeats,
|
signup.PaymentToken, plan, signup.AdditionalStorageGb, signup.AdditionalSeats,
|
||||||
signup.PremiumAccessAddon, signup.TaxInfo, signup.IsFromProvider, signup.AdditionalSmSeats.GetValueOrDefault(),
|
signup.PremiumAccessAddon, signup.TaxInfo, signup.IsFromProvider, signup.AdditionalSmSeats.GetValueOrDefault(),
|
||||||
signup.AdditionalServiceAccounts.GetValueOrDefault(), signup.IsFromSecretsManagerTrial);
|
signup.AdditionalServiceAccounts.GetValueOrDefault(), signup.IsFromSecretsManagerTrial);
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await _paymentService.PurchaseOrganizationNoPaymentMethod(organization, plan, signup.AdditionalSeats,
|
||||||
|
signup.PremiumAccessAddon, signup.AdditionalSmSeats.GetValueOrDefault(),
|
||||||
|
signup.AdditionalServiceAccounts.GetValueOrDefault(), signup.IsFromSecretsManagerTrial);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var ownerId = signup.IsFromProvider ? default : signup.Owner.Id;
|
var ownerId = signup.IsFromProvider ? default : signup.Owner.Id;
|
||||||
@ -1176,12 +1191,7 @@ public class OrganizationService : IOrganizationService
|
|||||||
var currentOrganization = await _organizationRepository.GetByIdAsync(organization.Id);
|
var currentOrganization = await _organizationRepository.GetByIdAsync(organization.Id);
|
||||||
|
|
||||||
// Revert autoscaling
|
// Revert autoscaling
|
||||||
if (initialSeatCount.HasValue && currentOrganization.Seats.HasValue && currentOrganization.Seats.Value != initialSeatCount.Value)
|
// Do this first so that SmSeats never exceed PM seats (due to current billing requirements)
|
||||||
{
|
|
||||||
await AdjustSeatsAsync(organization, initialSeatCount.Value - currentOrganization.Seats.Value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Revert SmSeat autoscaling
|
|
||||||
if (initialSmSeatCount.HasValue && currentOrganization.SmSeats.HasValue &&
|
if (initialSmSeatCount.HasValue && currentOrganization.SmSeats.HasValue &&
|
||||||
currentOrganization.SmSeats.Value != initialSmSeatCount.Value)
|
currentOrganization.SmSeats.Value != initialSmSeatCount.Value)
|
||||||
{
|
{
|
||||||
@ -1192,6 +1202,11 @@ public class OrganizationService : IOrganizationService
|
|||||||
await _updateSecretsManagerSubscriptionCommand.UpdateSubscriptionAsync(smSubscriptionUpdateRevert);
|
await _updateSecretsManagerSubscriptionCommand.UpdateSubscriptionAsync(smSubscriptionUpdateRevert);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (initialSeatCount.HasValue && currentOrganization.Seats.HasValue && currentOrganization.Seats.Value != initialSeatCount.Value)
|
||||||
|
{
|
||||||
|
await AdjustSeatsAsync(organization, initialSeatCount.Value - currentOrganization.Seats.Value);
|
||||||
|
}
|
||||||
|
|
||||||
exceptions.Add(e);
|
exceptions.Add(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -28,6 +28,11 @@ public static class StripeConstants
|
|||||||
public const string TaxIdInvalid = "tax_id_invalid";
|
public const string TaxIdInvalid = "tax_id_invalid";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static class PaymentBehavior
|
||||||
|
{
|
||||||
|
public const string DefaultIncomplete = "default_incomplete";
|
||||||
|
}
|
||||||
|
|
||||||
public static class PaymentMethodTypes
|
public static class PaymentMethodTypes
|
||||||
{
|
{
|
||||||
public const string Card = "card";
|
public const string Card = "card";
|
||||||
|
@ -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)
|
public static void AddBillingOperations(this IServiceCollection services)
|
||||||
{
|
{
|
||||||
services.AddTransient<IOrganizationBillingService, OrganizationBillingService>();
|
services.AddTransient<IOrganizationBillingService, OrganizationBillingService>();
|
||||||
|
services.AddTransient<IPremiumUserBillingService, PremiumUserBillingService>();
|
||||||
services.AddTransient<ISetupIntentCache, SetupIntentDistributedCache>();
|
services.AddTransient<ISetupIntentCache, SetupIntentDistributedCache>();
|
||||||
services.AddTransient<ISubscriberService, SubscriberService>();
|
services.AddTransient<ISubscriberService, SubscriberService>();
|
||||||
}
|
}
|
||||||
|
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;
|
namespace Bit.Core.Billing.Models;
|
||||||
|
|
||||||
@ -11,6 +12,15 @@ public record TaxInformation(
|
|||||||
string City,
|
string City,
|
||||||
string State)
|
string State)
|
||||||
{
|
{
|
||||||
|
public static TaxInformation From(TaxInfo taxInfo) => new(
|
||||||
|
taxInfo.BillingAddressCountry,
|
||||||
|
taxInfo.BillingAddressPostalCode,
|
||||||
|
taxInfo.TaxIdNumber,
|
||||||
|
taxInfo.BillingAddressLine1,
|
||||||
|
taxInfo.BillingAddressLine2,
|
||||||
|
taxInfo.BillingAddressCity,
|
||||||
|
taxInfo.BillingAddressState);
|
||||||
|
|
||||||
public (AddressOptions, List<CustomerTaxIdDataOptions>) GetStripeOptions()
|
public (AddressOptions, List<CustomerTaxIdDataOptions>) GetStripeOptions()
|
||||||
{
|
{
|
||||||
var address = new AddressOptions
|
var address = new AddressOptions
|
||||||
|
@ -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
|
public interface IOrganizationBillingService
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// <para>Establishes the billing configuration for a Bitwarden <see cref="Organization"/> using the provided <paramref name="sale"/>.</para>
|
/// <para>Establishes the Stripe entities necessary for a Bitwarden <see cref="Organization"/> using the provided <paramref name="sale"/>.</para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The method first checks to see if the
|
/// The method first checks to see if the
|
||||||
/// provided <see cref="OrganizationSale.Organization"/> already has a Stripe <see cref="Stripe.Customer"/> using the <see cref="Organization.GatewayCustomerId"/>.
|
/// provided <see cref="OrganizationSale.Organization"/> already has a Stripe <see cref="Stripe.Customer"/> using the <see cref="Organization.GatewayCustomerId"/>.
|
||||||
@ -17,7 +17,7 @@ public interface IOrganizationBillingService
|
|||||||
/// for the created or existing customer using the provided <see cref="OrganizationSale.SubscriptionSetup"/>.
|
/// for the created or existing customer using the provided <see cref="OrganizationSale.SubscriptionSetup"/>.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="sale">The purchase details necessary to establish the Stripe entities responsible for billing the organization.</param>
|
/// <param name="sale">The data required to establish the Stripe entities responsible for billing the organization.</param>
|
||||||
/// <example>
|
/// <example>
|
||||||
/// <code>
|
/// <code>
|
||||||
/// var sale = OrganizationSale.From(organization, organizationSignup);
|
/// var sale = OrganizationSale.From(organization, organizationSignup);
|
||||||
|
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;
|
var (organization, customerSetup, subscriptionSetup) = sale;
|
||||||
|
|
||||||
List<string> expand = ["tax"];
|
var customer = string.IsNullOrEmpty(organization.GatewayCustomerId) && customerSetup != null
|
||||||
|
? await CreateCustomerAsync(organization, customerSetup)
|
||||||
var customer = customerSetup != null
|
: await subscriberService.GetCustomerOrThrow(organization, new CustomerGetOptions { Expand = ["tax"] });
|
||||||
? await CreateCustomerAsync(organization, customerSetup, expand)
|
|
||||||
: await subscriberService.GetCustomerOrThrow(organization, new CustomerGetOptions { Expand = expand });
|
|
||||||
|
|
||||||
var subscription = await CreateSubscriptionAsync(organization.Id, customer, subscriptionSetup);
|
var subscription = await CreateSubscriptionAsync(organization.Id, customer, subscriptionSetup);
|
||||||
|
|
||||||
@ -111,31 +109,31 @@ public class OrganizationBillingService(
|
|||||||
|
|
||||||
private async Task<Customer> CreateCustomerAsync(
|
private async Task<Customer> CreateCustomerAsync(
|
||||||
Organization organization,
|
Organization organization,
|
||||||
CustomerSetup customerSetup,
|
CustomerSetup customerSetup)
|
||||||
List<string>? expand = null)
|
|
||||||
{
|
{
|
||||||
var organizationDisplayName = organization.DisplayName();
|
var displayName = organization.DisplayName();
|
||||||
|
|
||||||
var customerCreateOptions = new CustomerCreateOptions
|
var customerCreateOptions = new CustomerCreateOptions
|
||||||
{
|
{
|
||||||
Coupon = customerSetup.Coupon,
|
Coupon = customerSetup.Coupon,
|
||||||
Description = organization.DisplayBusinessName(),
|
Description = organization.DisplayBusinessName(),
|
||||||
Email = organization.BillingEmail,
|
Email = organization.BillingEmail,
|
||||||
Expand = expand,
|
Expand = ["tax"],
|
||||||
InvoiceSettings = new CustomerInvoiceSettingsOptions
|
InvoiceSettings = new CustomerInvoiceSettingsOptions
|
||||||
{
|
{
|
||||||
CustomFields = [
|
CustomFields = [
|
||||||
new CustomerInvoiceSettingsCustomFieldOptions
|
new CustomerInvoiceSettingsCustomFieldOptions
|
||||||
{
|
{
|
||||||
Name = organization.SubscriberType(),
|
Name = organization.SubscriberType(),
|
||||||
Value = organizationDisplayName.Length <= 30
|
Value = displayName.Length <= 30
|
||||||
? organizationDisplayName
|
? displayName
|
||||||
: organizationDisplayName[..30]
|
: displayName[..30]
|
||||||
}]
|
}]
|
||||||
},
|
},
|
||||||
Metadata = new Dictionary<string, string>
|
Metadata = new Dictionary<string, string>
|
||||||
{
|
{
|
||||||
{ "region", globalSettings.BaseServiceUri.CloudRegion }
|
["organizationId"] = organization.Id.ToString(),
|
||||||
|
["region"] = globalSettings.BaseServiceUri.CloudRegion
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -174,46 +172,41 @@ public class OrganizationBillingService(
|
|||||||
};
|
};
|
||||||
customerCreateOptions.TaxIdData = taxIdData;
|
customerCreateOptions.TaxIdData = taxIdData;
|
||||||
|
|
||||||
var (type, token) = customerSetup.TokenizedPaymentSource;
|
var (paymentMethodType, paymentMethodToken) = customerSetup.TokenizedPaymentSource;
|
||||||
|
|
||||||
// ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault
|
// ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault
|
||||||
switch (type)
|
switch (paymentMethodType)
|
||||||
{
|
{
|
||||||
case PaymentMethodType.BankAccount:
|
case PaymentMethodType.BankAccount:
|
||||||
{
|
{
|
||||||
var setupIntent =
|
var setupIntent =
|
||||||
(await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = token }))
|
(await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = paymentMethodToken }))
|
||||||
.FirstOrDefault();
|
.FirstOrDefault();
|
||||||
|
|
||||||
if (setupIntent == null)
|
if (setupIntent == null)
|
||||||
{
|
{
|
||||||
logger.LogError("Cannot create customer for organization ({OrganizationID}) without a setup intent for their bank account", organization.Id);
|
logger.LogError("Cannot create customer for organization ({OrganizationID}) without a setup intent for their bank account", organization.Id);
|
||||||
|
|
||||||
throw new BillingException();
|
throw new BillingException();
|
||||||
}
|
}
|
||||||
|
|
||||||
await setupIntentCache.Set(organization.Id, setupIntent.Id);
|
await setupIntentCache.Set(organization.Id, setupIntent.Id);
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case PaymentMethodType.Card:
|
case PaymentMethodType.Card:
|
||||||
{
|
{
|
||||||
customerCreateOptions.PaymentMethod = token;
|
customerCreateOptions.PaymentMethod = paymentMethodToken;
|
||||||
customerCreateOptions.InvoiceSettings.DefaultPaymentMethod = token;
|
customerCreateOptions.InvoiceSettings.DefaultPaymentMethod = paymentMethodToken;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case PaymentMethodType.PayPal:
|
case PaymentMethodType.PayPal:
|
||||||
{
|
{
|
||||||
braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(organization, token);
|
braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(organization, paymentMethodToken);
|
||||||
|
|
||||||
customerCreateOptions.Metadata[BraintreeCustomerIdKey] = braintreeCustomerId;
|
customerCreateOptions.Metadata[BraintreeCustomerIdKey] = braintreeCustomerId;
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
{
|
{
|
||||||
logger.LogError("Cannot create customer for organization ({OrganizationID}) using payment method type ({PaymentMethodType}) as it is not supported", organization.Id, type.ToString());
|
logger.LogError("Cannot create customer for organization ({OrganizationID}) using payment method type ({PaymentMethodType}) as it is not supported", organization.Id, paymentMethodType.ToString());
|
||||||
|
|
||||||
throw new BillingException();
|
throw new BillingException();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -227,7 +220,6 @@ public class OrganizationBillingService(
|
|||||||
StripeConstants.ErrorCodes.CustomerTaxLocationInvalid)
|
StripeConstants.ErrorCodes.CustomerTaxLocationInvalid)
|
||||||
{
|
{
|
||||||
await Revert();
|
await Revert();
|
||||||
|
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Your location wasn't recognized. Please ensure your country and postal code are valid.");
|
"Your location wasn't recognized. Please ensure your country and postal code are valid.");
|
||||||
}
|
}
|
||||||
@ -235,7 +227,6 @@ public class OrganizationBillingService(
|
|||||||
StripeConstants.ErrorCodes.TaxIdInvalid)
|
StripeConstants.ErrorCodes.TaxIdInvalid)
|
||||||
{
|
{
|
||||||
await Revert();
|
await Revert();
|
||||||
|
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Your tax ID wasn't recognized for your selected country. Please ensure your country and tax ID are valid.");
|
"Your tax ID wasn't recognized for your selected country. Please ensure your country and tax ID are valid.");
|
||||||
}
|
}
|
||||||
@ -257,7 +248,7 @@ public class OrganizationBillingService(
|
|||||||
await setupIntentCache.Remove(organization.Id);
|
await setupIntentCache.Remove(organization.Id);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case PaymentMethodType.PayPal:
|
case PaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId):
|
||||||
{
|
{
|
||||||
await braintreeGateway.Customer.DeleteAsync(braintreeCustomerId);
|
await braintreeGateway.Customer.DeleteAsync(braintreeCustomerId);
|
||||||
break;
|
break;
|
||||||
|
@ -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 AC2101UpdateTrialInitiationEmail = "AC-2101-update-trial-initiation-email";
|
||||||
public const string EnableConsolidatedBilling = "enable-consolidated-billing";
|
public const string EnableConsolidatedBilling = "enable-consolidated-billing";
|
||||||
public const string AC1795_UpdatedSubscriptionStatusSection = "AC-1795_updated-subscription-status-section";
|
public const string AC1795_UpdatedSubscriptionStatusSection = "AC-1795_updated-subscription-status-section";
|
||||||
public const string EnableDeleteProvider = "AC-1218-delete-provider";
|
|
||||||
public const string EmailVerification = "email-verification";
|
public const string EmailVerification = "email-verification";
|
||||||
public const string EmailVerificationDisableTimingDelays = "email-verification-disable-timing-delays";
|
public const string EmailVerificationDisableTimingDelays = "email-verification-disable-timing-delays";
|
||||||
public const string AnhFcmv1Migration = "anh-fcmv1-migration";
|
public const string AnhFcmv1Migration = "anh-fcmv1-migration";
|
||||||
@ -124,7 +123,6 @@ public static class FeatureFlagKeys
|
|||||||
public const string InlineMenuFieldQualification = "inline-menu-field-qualification";
|
public const string InlineMenuFieldQualification = "inline-menu-field-qualification";
|
||||||
public const string TwoFactorComponentRefactor = "two-factor-component-refactor";
|
public const string TwoFactorComponentRefactor = "two-factor-component-refactor";
|
||||||
public const string InlineMenuPositioningImprovements = "inline-menu-positioning-improvements";
|
public const string InlineMenuPositioningImprovements = "inline-menu-positioning-improvements";
|
||||||
public const string AC2828_ProviderPortalMembersPage = "AC-2828_provider-portal-members-page";
|
|
||||||
public const string ProviderClientVaultPrivacyBanner = "ac-2833-provider-client-vault-privacy-banner";
|
public const string ProviderClientVaultPrivacyBanner = "ac-2833-provider-client-vault-privacy-banner";
|
||||||
public const string DeviceTrustLogging = "pm-8285-device-trust-logging";
|
public const string DeviceTrustLogging = "pm-8285-device-trust-logging";
|
||||||
public const string AuthenticatorTwoFactorToken = "authenticator-2fa-token";
|
public const string AuthenticatorTwoFactorToken = "authenticator-2fa-token";
|
||||||
@ -143,6 +141,9 @@ public static class FeatureFlagKeys
|
|||||||
public const string CipherKeyEncryption = "cipher-key-encryption";
|
public const string CipherKeyEncryption = "cipher-key-encryption";
|
||||||
public const string EnableNewCardCombinedExpiryAutofill = "enable-new-card-combined-expiry-autofill";
|
public const string EnableNewCardCombinedExpiryAutofill = "enable-new-card-combined-expiry-autofill";
|
||||||
public const string StorageReseedRefactor = "storage-reseed-refactor";
|
public const string StorageReseedRefactor = "storage-reseed-refactor";
|
||||||
|
public const string TrialPayment = "PM-8163-trial-payment";
|
||||||
|
public const string Pm3478RefactorOrganizationUserApi = "pm-3478-refactor-organizationuser-api";
|
||||||
|
public const string RemoveServerVersionHeader = "remove-server-version-header";
|
||||||
|
|
||||||
public static List<string> GetAllKeys()
|
public static List<string> GetAllKeys()
|
||||||
{
|
{
|
||||||
|
@ -21,8 +21,8 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="AspNetCoreRateLimit.Redis" Version="2.0.0" />
|
<PackageReference Include="AspNetCoreRateLimit.Redis" Version="2.0.0" />
|
||||||
<PackageReference Include="AWSSDK.SimpleEmail" Version="3.7.401.11" />
|
<PackageReference Include="AWSSDK.SimpleEmail" Version="3.7.401.16" />
|
||||||
<PackageReference Include="AWSSDK.SQS" Version="3.7.400.21" />
|
<PackageReference Include="AWSSDK.SQS" Version="3.7.400.26" />
|
||||||
<PackageReference Include="Azure.Data.Tables" Version="12.9.0" />
|
<PackageReference Include="Azure.Data.Tables" Version="12.9.0" />
|
||||||
<PackageReference Include="Azure.Extensions.AspNetCore.DataProtection.Blobs" Version="1.3.4" />
|
<PackageReference Include="Azure.Extensions.AspNetCore.DataProtection.Blobs" Version="1.3.4" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="8.0.8" />
|
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="8.0.8" />
|
||||||
@ -34,9 +34,9 @@
|
|||||||
<PackageReference Include="DnsClient" Version="1.8.0" />
|
<PackageReference Include="DnsClient" Version="1.8.0" />
|
||||||
<PackageReference Include="Fido2.AspNet" Version="3.0.1" />
|
<PackageReference Include="Fido2.AspNet" Version="3.0.1" />
|
||||||
<PackageReference Include="Handlebars.Net" Version="2.1.6" />
|
<PackageReference Include="Handlebars.Net" Version="2.1.6" />
|
||||||
<PackageReference Include="MailKit" Version="4.7.1.1" />
|
<PackageReference Include="MailKit" Version="4.8.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.8" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.8" />
|
||||||
<PackageReference Include="Microsoft.Azure.Cosmos" Version="3.43.0" />
|
<PackageReference Include="Microsoft.Azure.Cosmos" Version="3.43.1" />
|
||||||
<PackageReference Include="Microsoft.Azure.NotificationHubs" Version="4.2.0" />
|
<PackageReference Include="Microsoft.Azure.NotificationHubs" Version="4.2.0" />
|
||||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="5.2.2" />
|
<PackageReference Include="Microsoft.Data.SqlClient" Version="5.2.2" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Caching.Cosmos" Version="1.6.1" />
|
<PackageReference Include="Microsoft.Extensions.Caching.Cosmos" Version="1.6.1" />
|
||||||
@ -54,8 +54,8 @@
|
|||||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||||
<PackageReference Include="Serilog.Sinks.SyslogMessages" Version="3.0.2" />
|
<PackageReference Include="Serilog.Sinks.SyslogMessages" Version="3.0.2" />
|
||||||
<PackageReference Include="AspNetCoreRateLimit" Version="5.0.0" />
|
<PackageReference Include="AspNetCoreRateLimit" Version="5.0.0" />
|
||||||
<PackageReference Include="Braintree" Version="5.26.0" />
|
<PackageReference Include="Braintree" Version="5.27.0" />
|
||||||
<PackageReference Include="Stripe.net" Version="45.13.0" />
|
<PackageReference Include="Stripe.net" Version="45.14.0" />
|
||||||
<PackageReference Include="Otp.NET" Version="1.4.0" />
|
<PackageReference Include="Otp.NET" Version="1.4.0" />
|
||||||
<PackageReference Include="YubicoDotNetClient" Version="1.2.0" />
|
<PackageReference Include="YubicoDotNetClient" Version="1.2.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="8.0.8" />
|
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="8.0.8" />
|
||||||
|
@ -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