mirror of
https://github.com/bitwarden/server.git
synced 2024-11-22 12:15:36 +01:00
Merge branch 'main' into km/pm-13706/private-key-regen-db
This commit is contained in:
commit
4640ad8eca
@ -3,7 +3,7 @@
|
|||||||
"isRoot": true,
|
"isRoot": true,
|
||||||
"tools": {
|
"tools": {
|
||||||
"swashbuckle.aspnetcore.cli": {
|
"swashbuckle.aspnetcore.cli": {
|
||||||
"version": "6.8.1",
|
"version": "6.9.0",
|
||||||
"commands": ["swagger"]
|
"commands": ["swagger"]
|
||||||
},
|
},
|
||||||
"dotnet-ef": {
|
"dotnet-ef": {
|
||||||
|
19
.github/renovate.json
vendored
19
.github/renovate.json
vendored
@ -29,7 +29,7 @@
|
|||||||
"commitMessagePrefix": "[deps] DevOps:"
|
"commitMessagePrefix": "[deps] DevOps:"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"matchPackageNames": ["DnsClient", "Quartz"],
|
"matchPackageNames": ["DnsClient"],
|
||||||
"description": "Admin Console owned dependencies",
|
"description": "Admin Console owned dependencies",
|
||||||
"commitMessagePrefix": "[deps] AC:",
|
"commitMessagePrefix": "[deps] AC:",
|
||||||
"reviewers": ["team:team-admin-console-dev"]
|
"reviewers": ["team:team-admin-console-dev"]
|
||||||
@ -42,14 +42,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"matchPackageNames": [
|
"matchPackageNames": [
|
||||||
"AspNetCoreRateLimit",
|
|
||||||
"AspNetCoreRateLimit.Redis",
|
|
||||||
"Azure.Data.Tables",
|
|
||||||
"Azure.Extensions.AspNetCore.DataProtection.Blobs",
|
"Azure.Extensions.AspNetCore.DataProtection.Blobs",
|
||||||
"Azure.Messaging.EventGrid",
|
|
||||||
"Azure.Messaging.ServiceBus",
|
|
||||||
"Azure.Storage.Blobs",
|
|
||||||
"Azure.Storage.Queues",
|
|
||||||
"DuoUniversal",
|
"DuoUniversal",
|
||||||
"Fido2.AspNet",
|
"Fido2.AspNet",
|
||||||
"Duende.IdentityServer",
|
"Duende.IdentityServer",
|
||||||
@ -128,8 +121,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"matchPackageNames": [
|
"matchPackageNames": [
|
||||||
|
"AspNetCoreRateLimit",
|
||||||
|
"AspNetCoreRateLimit.Redis",
|
||||||
|
"Azure.Data.Tables",
|
||||||
|
"Azure.Messaging.EventGrid",
|
||||||
|
"Azure.Messaging.ServiceBus",
|
||||||
|
"Azure.Storage.Blobs",
|
||||||
|
"Azure.Storage.Queues",
|
||||||
"Microsoft.AspNetCore.Authentication.JwtBearer",
|
"Microsoft.AspNetCore.Authentication.JwtBearer",
|
||||||
"Microsoft.AspNetCore.Http"
|
"Microsoft.AspNetCore.Http",
|
||||||
|
"Quartz"
|
||||||
],
|
],
|
||||||
"description": "Platform owned dependencies",
|
"description": "Platform owned dependencies",
|
||||||
"commitMessagePrefix": "[deps] Platform:",
|
"commitMessagePrefix": "[deps] Platform:",
|
||||||
|
@ -29,7 +29,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@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
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 }}
|
||||||
|
|
||||||
@ -53,7 +53,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@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
@ -107,7 +107,7 @@ jobs:
|
|||||||
devops-alerts-slack-webhook-url"
|
devops-alerts-slack-webhook-url"
|
||||||
|
|
||||||
- name: Import GPG keys
|
- name: Import GPG keys
|
||||||
uses: crazy-max/ghaction-import-gpg@01dd5d3ca463c7f10f7f4f7b4f177225ac661ee4 # v6.1.0
|
uses: crazy-max/ghaction-import-gpg@cb9bde2e2525e640591a934b1fd28eef1dcaf5e5 # v6.2.0
|
||||||
with:
|
with:
|
||||||
gpg_private_key: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key }}
|
gpg_private_key: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key }}
|
||||||
passphrase: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key-passphrase }}
|
passphrase: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key-passphrase }}
|
||||||
|
98
.github/workflows/build.yml
vendored
98
.github/workflows/build.yml
vendored
@ -7,21 +7,30 @@ on:
|
|||||||
- "main"
|
- "main"
|
||||||
- "rc"
|
- "rc"
|
||||||
- "hotfix-rc"
|
- "hotfix-rc"
|
||||||
pull_request:
|
pull_request_target:
|
||||||
|
types: [opened, synchronize]
|
||||||
|
|
||||||
env:
|
env:
|
||||||
_AZ_REGISTRY: "bitwardenprod.azurecr.io"
|
_AZ_REGISTRY: "bitwardenprod.azurecr.io"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
check-run:
|
||||||
|
name: Check PR run
|
||||||
|
uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
name: Lint
|
name: Lint
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
|
needs:
|
||||||
|
- check-run
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
|
||||||
- name: Set up .NET
|
- name: Set up .NET
|
||||||
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
|
uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
|
||||||
|
|
||||||
- name: Verify format
|
- name: Verify format
|
||||||
run: dotnet format --verify-no-changes
|
run: dotnet format --verify-no-changes
|
||||||
@ -67,13 +76,15 @@ jobs:
|
|||||||
node: true
|
node: true
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
|
||||||
- name: Set up .NET
|
- name: Set up .NET
|
||||||
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
|
uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
|
||||||
|
|
||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4.0.4
|
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
||||||
with:
|
with:
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
cache-dependency-path: "**/package-lock.json"
|
cache-dependency-path: "**/package-lock.json"
|
||||||
@ -120,7 +131,8 @@ jobs:
|
|||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
permissions:
|
permissions:
|
||||||
security-events: write
|
security-events: write
|
||||||
needs: build-artifacts
|
needs:
|
||||||
|
- build-artifacts
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@ -172,7 +184,9 @@ jobs:
|
|||||||
dotnet: true
|
dotnet: true
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
|
||||||
- name: Check branch to publish
|
- name: Check branch to publish
|
||||||
env:
|
env:
|
||||||
@ -212,7 +226,7 @@ jobs:
|
|||||||
- name: Generate Docker image tag
|
- name: Generate Docker image tag
|
||||||
id: tag
|
id: tag
|
||||||
run: |
|
run: |
|
||||||
if [[ $(grep "pull" <<< "${GITHUB_REF}") ]]; then
|
if [[ "${GITHUB_EVENT_NAME}" == "pull_request_target" ]]; then
|
||||||
IMAGE_TAG=$(echo "${GITHUB_HEAD_REF}" | sed "s#/#-#g")
|
IMAGE_TAG=$(echo "${GITHUB_HEAD_REF}" | sed "s#/#-#g")
|
||||||
else
|
else
|
||||||
IMAGE_TAG=$(echo "${GITHUB_REF:11}" | sed "s#/#-#g")
|
IMAGE_TAG=$(echo "${GITHUB_REF:11}" | sed "s#/#-#g")
|
||||||
@ -274,14 +288,14 @@ jobs:
|
|||||||
|
|
||||||
- name: Scan Docker image
|
- name: Scan Docker image
|
||||||
id: container-scan
|
id: container-scan
|
||||||
uses: anchore/scan-action@49e50b215b647c5ec97abb66f69af73c46a4ca08 # v5.0.1
|
uses: anchore/scan-action@5ed195cc06065322983cae4bb31e2a751feb86fd # v5.2.0
|
||||||
with:
|
with:
|
||||||
image: ${{ steps.image-tags.outputs.primary_tag }}
|
image: ${{ steps.image-tags.outputs.primary_tag }}
|
||||||
fail-build: false
|
fail-build: false
|
||||||
output-format: sarif
|
output-format: sarif
|
||||||
|
|
||||||
- name: Upload Grype results to GitHub
|
- name: Upload Grype results to GitHub
|
||||||
uses: github/codeql-action/upload-sarif@f779452ac5af1c261dce0346a8f964149f49322b # v3.26.13
|
uses: github/codeql-action/upload-sarif@662472033e021d55d94146f66f6058822b0b39fd # v3.27.0
|
||||||
with:
|
with:
|
||||||
sarif_file: ${{ steps.container-scan.outputs.sarif }}
|
sarif_file: ${{ steps.container-scan.outputs.sarif }}
|
||||||
|
|
||||||
@ -291,10 +305,12 @@ jobs:
|
|||||||
needs: build-docker
|
needs: build-docker
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
|
||||||
- name: Set up .NET
|
- name: Set up .NET
|
||||||
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
|
uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
|
||||||
|
|
||||||
- name: Log in to Azure - production subscription
|
- name: Log in to Azure - production subscription
|
||||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||||
@ -305,9 +321,9 @@ jobs:
|
|||||||
run: az acr login -n $_AZ_REGISTRY --only-show-errors
|
run: az acr login -n $_AZ_REGISTRY --only-show-errors
|
||||||
|
|
||||||
- name: Make Docker stubs
|
- name: Make Docker stubs
|
||||||
if: github.ref == 'refs/heads/main' ||
|
if: |
|
||||||
github.ref == 'refs/heads/rc' ||
|
github.event_name != 'pull_request_target'
|
||||||
github.ref == 'refs/heads/hotfix-rc'
|
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
|
||||||
run: |
|
run: |
|
||||||
# Set proper setup image based on branch
|
# Set proper setup image based on branch
|
||||||
case "$GITHUB_REF" in
|
case "$GITHUB_REF" in
|
||||||
@ -347,13 +363,17 @@ jobs:
|
|||||||
cd docker-stub/EU; zip -r ../../docker-stub-EU.zip *; cd ../..
|
cd docker-stub/EU; zip -r ../../docker-stub-EU.zip *; cd ../..
|
||||||
|
|
||||||
- name: Make Docker stub checksums
|
- name: Make Docker stub checksums
|
||||||
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc'
|
if: |
|
||||||
|
github.event_name != 'pull_request_target'
|
||||||
|
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
|
||||||
run: |
|
run: |
|
||||||
sha256sum docker-stub-US.zip > docker-stub-US-sha256.txt
|
sha256sum docker-stub-US.zip > docker-stub-US-sha256.txt
|
||||||
sha256sum docker-stub-EU.zip > docker-stub-EU-sha256.txt
|
sha256sum docker-stub-EU.zip > docker-stub-EU-sha256.txt
|
||||||
|
|
||||||
- name: Upload Docker stub US artifact
|
- name: Upload Docker stub US artifact
|
||||||
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc'
|
if: |
|
||||||
|
github.event_name != 'pull_request_target'
|
||||||
|
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
|
||||||
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
|
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
|
||||||
with:
|
with:
|
||||||
name: docker-stub-US.zip
|
name: docker-stub-US.zip
|
||||||
@ -361,7 +381,9 @@ jobs:
|
|||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
- name: Upload Docker stub EU artifact
|
- name: Upload Docker stub EU artifact
|
||||||
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc'
|
if: |
|
||||||
|
github.event_name != 'pull_request_target'
|
||||||
|
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
|
||||||
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
|
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
|
||||||
with:
|
with:
|
||||||
name: docker-stub-EU.zip
|
name: docker-stub-EU.zip
|
||||||
@ -369,7 +391,9 @@ jobs:
|
|||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
- name: Upload Docker stub US checksum artifact
|
- name: Upload Docker stub US checksum artifact
|
||||||
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc'
|
if: |
|
||||||
|
github.event_name != 'pull_request_target'
|
||||||
|
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
|
||||||
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
|
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
|
||||||
with:
|
with:
|
||||||
name: docker-stub-US-sha256.txt
|
name: docker-stub-US-sha256.txt
|
||||||
@ -377,7 +401,9 @@ jobs:
|
|||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
- name: Upload Docker stub EU checksum artifact
|
- name: Upload Docker stub EU checksum artifact
|
||||||
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc'
|
if: |
|
||||||
|
github.event_name != 'pull_request_target'
|
||||||
|
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
|
||||||
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
|
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
|
||||||
with:
|
with:
|
||||||
name: docker-stub-EU-sha256.txt
|
name: docker-stub-EU-sha256.txt
|
||||||
@ -452,7 +478,8 @@ jobs:
|
|||||||
build-mssqlmigratorutility:
|
build-mssqlmigratorutility:
|
||||||
name: Build MSSQL migrator utility
|
name: Build MSSQL migrator utility
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
needs: lint
|
needs:
|
||||||
|
- lint
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
shell: bash
|
shell: bash
|
||||||
@ -466,10 +493,12 @@ jobs:
|
|||||||
- win-x64
|
- win-x64
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
|
||||||
- name: Set up .NET
|
- name: Set up .NET
|
||||||
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
|
uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
|
||||||
|
|
||||||
- name: Print environment
|
- name: Print environment
|
||||||
run: |
|
run: |
|
||||||
@ -501,8 +530,10 @@ jobs:
|
|||||||
|
|
||||||
self-host-build:
|
self-host-build:
|
||||||
name: Trigger self-host build
|
name: Trigger self-host build
|
||||||
|
if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/main'
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
needs: build-docker
|
needs:
|
||||||
|
- build-docker
|
||||||
steps:
|
steps:
|
||||||
- name: Log in to Azure - CI subscription
|
- name: Log in to Azure - CI subscription
|
||||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||||
@ -533,9 +564,10 @@ jobs:
|
|||||||
|
|
||||||
trigger-k8s-deploy:
|
trigger-k8s-deploy:
|
||||||
name: Trigger k8s deploy
|
name: Trigger k8s deploy
|
||||||
if: github.ref == 'refs/heads/main'
|
if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/main'
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
needs: build-docker
|
needs:
|
||||||
|
- build-docker
|
||||||
steps:
|
steps:
|
||||||
- name: Log in to Azure - CI subscription
|
- name: Log in to Azure - CI subscription
|
||||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||||
@ -567,9 +599,12 @@ jobs:
|
|||||||
|
|
||||||
trigger-ee-updates:
|
trigger-ee-updates:
|
||||||
name: Trigger Ephemeral Environment updates
|
name: Trigger Ephemeral Environment updates
|
||||||
if: github.ref != 'refs/heads/main' && contains(github.event.pull_request.labels.*.name, 'ephemeral-environment')
|
if: |
|
||||||
|
github.event_name == 'pull_request_target'
|
||||||
|
&& contains(github.event.pull_request.labels.*.name, 'ephemeral-environment')
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
needs: build-docker
|
needs:
|
||||||
|
- build-docker
|
||||||
steps:
|
steps:
|
||||||
- name: Log in to Azure - CI subscription
|
- name: Log in to Azure - CI subscription
|
||||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||||
@ -613,9 +648,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Check if any job failed
|
- name: Check if any job failed
|
||||||
if: |
|
if: |
|
||||||
(github.ref == 'refs/heads/main'
|
github.event_name != 'pull_request_target'
|
||||||
|| github.ref == 'refs/heads/rc'
|
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
|
||||||
|| github.ref == 'refs/heads/hotfix-rc')
|
|
||||||
&& contains(needs.*.result, 'failure')
|
&& contains(needs.*.result, 'failure')
|
||||||
run: exit 1
|
run: exit 1
|
||||||
|
|
||||||
|
@ -12,7 +12,7 @@ jobs:
|
|||||||
config-exists: ${{ steps.validate-config.outputs.config-exists }}
|
config-exists: ${{ steps.validate-config.outputs.config-exists }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout PR
|
- name: Checkout PR
|
||||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
|
||||||
- name: Validate config exists in path
|
- name: Validate config exists in path
|
||||||
id: validate-config
|
id: validate-config
|
||||||
|
2
.github/workflows/cleanup-rc-branch.yml
vendored
2
.github/workflows/cleanup-rc-branch.yml
vendored
@ -23,7 +23,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@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
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@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
|
||||||
- name: Collect
|
- name: Collect
|
||||||
id: collect
|
id: collect
|
||||||
|
2
.github/workflows/protect-files.yml
vendored
2
.github/workflows/protect-files.yml
vendored
@ -28,7 +28,7 @@ jobs:
|
|||||||
label: "DB-migrations-changed"
|
label: "DB-migrations-changed"
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 2
|
fetch-depth: 2
|
||||||
|
|
||||||
|
2
.github/workflows/publish.yml
vendored
2
.github/workflows/publish.yml
vendored
@ -98,7 +98,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@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
|
||||||
- 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
@ -36,7 +36,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
|
||||||
- name: Check release version
|
- name: Check release version
|
||||||
id: version
|
id: version
|
||||||
|
176
.github/workflows/repository-management.yml
vendored
176
.github/workflows/repository-management.yml
vendored
@ -3,12 +3,13 @@ name: Repository management
|
|||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
branch_to_cut:
|
task:
|
||||||
default: "rc"
|
default: "Version Bump"
|
||||||
description: "Branch to cut"
|
description: "Task to execute"
|
||||||
options:
|
options:
|
||||||
- "rc"
|
- "Version Bump"
|
||||||
- "hotfix-rc"
|
- "Version Bump and Cut rc"
|
||||||
|
- "Version Bump and Cut hotfix-rc"
|
||||||
required: true
|
required: true
|
||||||
type: choice
|
type: choice
|
||||||
target_ref:
|
target_ref:
|
||||||
@ -22,18 +23,51 @@ on:
|
|||||||
type: string
|
type: string
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
setup:
|
||||||
|
name: Setup
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
outputs:
|
||||||
|
branch: ${{ steps.set-branch.outputs.branch }}
|
||||||
|
token: ${{ steps.app-token.outputs.token }}
|
||||||
|
steps:
|
||||||
|
- name: Set branch
|
||||||
|
id: set-branch
|
||||||
|
env:
|
||||||
|
TASK: ${{ inputs.task }}
|
||||||
|
run: |
|
||||||
|
if [[ "$TASK" == "Version Bump" ]]; then
|
||||||
|
BRANCH="none"
|
||||||
|
elif [[ "$TASK" == "Version Bump and Cut rc" ]]; then
|
||||||
|
BRANCH="rc"
|
||||||
|
elif [[ "$TASK" == "Version Bump and Cut hotfix-rc" ]]; then
|
||||||
|
BRANCH="hotfix-rc"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "branch=$BRANCH" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Generate GH App token
|
||||||
|
uses: actions/create-github-app-token@5d869da34e18e7287c1daad50e0b8ea0f506ce69 # v1.11.0
|
||||||
|
id: app-token
|
||||||
|
with:
|
||||||
|
app-id: ${{ secrets.BW_GHAPP_ID }}
|
||||||
|
private-key: ${{ secrets.BW_GHAPP_KEY }}
|
||||||
|
|
||||||
|
|
||||||
cut_branch:
|
cut_branch:
|
||||||
name: Cut branch
|
name: Cut branch
|
||||||
runs-on: ubuntu-22.04
|
if: ${{ needs.setup.outputs.branch != 'none' }}
|
||||||
|
needs: setup
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- name: Check out target ref
|
- name: Check out target ref
|
||||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
with:
|
with:
|
||||||
ref: ${{ inputs.target_ref }}
|
ref: ${{ inputs.target_ref }}
|
||||||
|
token: ${{ needs.setup.outputs.token }}
|
||||||
|
|
||||||
- name: Check if ${{ inputs.branch_to_cut }} branch exists
|
- name: Check if ${{ needs.setup.outputs.branch }} branch exists
|
||||||
env:
|
env:
|
||||||
BRANCH_NAME: ${{ inputs.branch_to_cut }}
|
BRANCH_NAME: ${{ needs.setup.outputs.branch }}
|
||||||
run: |
|
run: |
|
||||||
if [[ $(git ls-remote --heads origin $BRANCH_NAME) ]]; then
|
if [[ $(git ls-remote --heads origin $BRANCH_NAME) ]]; then
|
||||||
echo "$BRANCH_NAME already exists! Please delete $BRANCH_NAME before running again." >> $GITHUB_STEP_SUMMARY
|
echo "$BRANCH_NAME already exists! Please delete $BRANCH_NAME before running again." >> $GITHUB_STEP_SUMMARY
|
||||||
@ -42,7 +76,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Cut branch
|
- name: Cut branch
|
||||||
env:
|
env:
|
||||||
BRANCH_NAME: ${{ inputs.branch_to_cut }}
|
BRANCH_NAME: ${{ needs.setup.outputs.branch }}
|
||||||
run: |
|
run: |
|
||||||
git switch --quiet --create $BRANCH_NAME
|
git switch --quiet --create $BRANCH_NAME
|
||||||
git push --quiet --set-upstream origin $BRANCH_NAME
|
git push --quiet --set-upstream origin $BRANCH_NAME
|
||||||
@ -50,8 +84,11 @@ jobs:
|
|||||||
|
|
||||||
bump_version:
|
bump_version:
|
||||||
name: Bump Version
|
name: Bump Version
|
||||||
runs-on: ubuntu-22.04
|
if: ${{ always() }}
|
||||||
needs: cut_branch
|
runs-on: ubuntu-24.04
|
||||||
|
needs:
|
||||||
|
- cut_branch
|
||||||
|
- setup
|
||||||
outputs:
|
outputs:
|
||||||
version: ${{ steps.set-final-version-output.outputs.version }}
|
version: ${{ steps.set-final-version-output.outputs.version }}
|
||||||
steps:
|
steps:
|
||||||
@ -62,9 +99,15 @@ jobs:
|
|||||||
version: ${{ inputs.version_number_override }}
|
version: ${{ inputs.version_number_override }}
|
||||||
|
|
||||||
- name: Check out branch
|
- name: Check out branch
|
||||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
with:
|
with:
|
||||||
ref: main
|
ref: main
|
||||||
|
token: ${{ needs.setup.outputs.token }}
|
||||||
|
|
||||||
|
- name: Configure Git
|
||||||
|
run: |
|
||||||
|
git config --local user.email "actions@github.com"
|
||||||
|
git config --local user.name "Github Actions"
|
||||||
|
|
||||||
- name: Install xmllint
|
- name: Install xmllint
|
||||||
run: |
|
run: |
|
||||||
@ -123,85 +166,69 @@ jobs:
|
|||||||
|
|
||||||
- name: Set final version output
|
- name: Set final version output
|
||||||
id: set-final-version-output
|
id: set-final-version-output
|
||||||
|
env:
|
||||||
|
VERSION: ${{ inputs.version_number_override }}
|
||||||
run: |
|
run: |
|
||||||
if [[ "${{ steps.bump-version-override.outcome }}" = "success" ]]; then
|
if [[ "${{ steps.bump-version-override.outcome }}" = "success" ]]; then
|
||||||
echo "version=${{ inputs.version_number_override }}" >> $GITHUB_OUTPUT
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
elif [[ "${{ steps.bump-version-automatic.outcome }}" = "success" ]]; then
|
elif [[ "${{ steps.bump-version-automatic.outcome }}" = "success" ]]; then
|
||||||
echo "version=${{ steps.calculate-next-version.outputs.version }}" >> $GITHUB_OUTPUT
|
echo "version=${{ steps.calculate-next-version.outputs.version }}" >> $GITHUB_OUTPUT
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Configure Git
|
|
||||||
run: |
|
|
||||||
git config --local user.email "actions@github.com"
|
|
||||||
git config --local user.name "Github Actions"
|
|
||||||
|
|
||||||
- name: Commit files
|
- name: Commit files
|
||||||
run: git commit -m "Bumped version to ${{ steps.set-final-version-output.outputs.version }}" -a
|
run: git commit -m "Bumped version to ${{ steps.set-final-version-output.outputs.version }}" -a
|
||||||
|
|
||||||
- name: Push changes
|
- name: Push changes
|
||||||
run: |
|
run: git push
|
||||||
git pull -pt
|
|
||||||
git push
|
|
||||||
|
|
||||||
|
|
||||||
cherry_pick:
|
cherry_pick:
|
||||||
name: Cherry-Pick Commit(s)
|
name: Cherry-Pick Commit(s)
|
||||||
runs-on: ubuntu-22.04
|
if: ${{ needs.setup.outputs.branch != 'none' }}
|
||||||
needs: bump_version
|
runs-on: ubuntu-24.04
|
||||||
|
needs:
|
||||||
|
- bump_version
|
||||||
|
- setup
|
||||||
steps:
|
steps:
|
||||||
- name: Check out main branch
|
- name: Check out main branch
|
||||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
with:
|
with:
|
||||||
ref: main
|
ref: main
|
||||||
|
token: ${{ needs.setup.outputs.token }}
|
||||||
|
|
||||||
|
- name: Configure Git
|
||||||
|
run: |
|
||||||
|
git config --local user.email "actions@github.com"
|
||||||
|
git config --local user.name "Github Actions"
|
||||||
|
|
||||||
- name: Install xmllint
|
- name: Install xmllint
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y libxml2-utils
|
sudo apt-get install -y libxml2-utils
|
||||||
|
|
||||||
- name: Verify version has been updated
|
|
||||||
env:
|
|
||||||
NEW_VERSION: ${{ needs.bump_version.outputs.version }}
|
|
||||||
run: |
|
|
||||||
# Wait for version to change.
|
|
||||||
while : ; do
|
|
||||||
echo "Waiting for version to be updated..."
|
|
||||||
git pull --force
|
|
||||||
CURRENT_VERSION=$(xmllint -xpath "/Project/PropertyGroup/Version/text()" Directory.Build.props)
|
|
||||||
|
|
||||||
# If the versions don't match we continue the loop, otherwise we break out of the loop.
|
|
||||||
[[ "$NEW_VERSION" != "$CURRENT_VERSION" ]] || break
|
|
||||||
sleep 10
|
|
||||||
done
|
|
||||||
|
|
||||||
- name: Get last version commit(s)
|
|
||||||
id: get-commits
|
|
||||||
run: |
|
|
||||||
git switch main
|
|
||||||
MAIN_COMMIT=$(git log --reverse --pretty=format:"%H" --max-count=1 Directory.Build.props)
|
|
||||||
echo "main_commit=$MAIN_COMMIT" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
if [[ $(git ls-remote --heads origin rc) ]]; then
|
|
||||||
git switch rc
|
|
||||||
RC_COMMIT=$(git log --reverse --pretty=format:"%H" --max-count=1 Directory.Build.props)
|
|
||||||
echo "rc_commit=$RC_COMMIT" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
RC_VERSION=$(xmllint -xpath "/Project/PropertyGroup/Version/text()" Directory.Build.props)
|
|
||||||
echo "rc_version=$RC_VERSION" >> $GITHUB_OUTPUT
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Configure Git
|
|
||||||
run: |
|
|
||||||
git config --local user.email "actions@github.com"
|
|
||||||
git config --local user.name "Github Actions"
|
|
||||||
|
|
||||||
- name: Perform cherry-pick(s)
|
- name: Perform cherry-pick(s)
|
||||||
env:
|
env:
|
||||||
CUT_BRANCH: ${{ inputs.branch_to_cut }}
|
CUT_BRANCH: ${{ needs.setup.outputs.branch }}
|
||||||
MAIN_COMMIT: ${{ steps.get-commits.outputs.main_commit }}
|
|
||||||
RC_COMMIT: ${{ steps.get-commits.outputs.rc_commit }}
|
|
||||||
RC_VERSION: ${{ steps.get-commits.outputs.rc_version }}
|
|
||||||
run: |
|
run: |
|
||||||
|
# Function for cherry-picking
|
||||||
|
cherry_pick () {
|
||||||
|
local source_branch=$1
|
||||||
|
local destination_branch=$2
|
||||||
|
|
||||||
|
# Get project commit/version from source branch
|
||||||
|
git switch $source_branch
|
||||||
|
SOURCE_COMMIT=$(git log --reverse --pretty=format:"%H" --max-count=1 Directory.Build.props)
|
||||||
|
SOURCE_VERSION=$(xmllint -xpath "/Project/PropertyGroup/Version/text()" Directory.Build.props)
|
||||||
|
|
||||||
|
# Get project commit/version from destination branch
|
||||||
|
git switch $destination_branch
|
||||||
|
DESTINATION_VERSION=$(xmllint -xpath "/Project/PropertyGroup/Version/text()" Directory.Build.props)
|
||||||
|
|
||||||
|
if [[ "$DESTINATION_VERSION" != "$SOURCE_VERSION" ]]; then
|
||||||
|
git cherry-pick --strategy-option=theirs -x $SOURCE_COMMIT
|
||||||
|
git push -u origin $destination_branch
|
||||||
|
fi
|
||||||
|
|
||||||
# If we are cutting 'hotfix-rc':
|
# If we are cutting 'hotfix-rc':
|
||||||
if [[ "$CUT_BRANCH" == "hotfix-rc" ]]; then
|
if [[ "$CUT_BRANCH" == "hotfix-rc" ]]; then
|
||||||
|
|
||||||
@ -209,25 +236,16 @@ jobs:
|
|||||||
if [[ $(git ls-remote --heads origin rc) ]]; then
|
if [[ $(git ls-remote --heads origin rc) ]]; then
|
||||||
|
|
||||||
# Chery-pick from 'rc' into 'hotfix-rc'
|
# Chery-pick from 'rc' into 'hotfix-rc'
|
||||||
git switch hotfix-rc
|
cherry_pick rc hotfix-rc
|
||||||
HOTFIX_RC_VERSION=$(xmllint -xpath "/Project/PropertyGroup/Version/text()" Directory.Build.props)
|
|
||||||
if [[ "$HOTFIX_RC_VERSION" != "$RC_VERSION" ]]; then
|
|
||||||
git cherry-pick --strategy-option=theirs -x $RC_COMMIT
|
|
||||||
git push -u origin hotfix-rc
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Cherry-pick from 'main' into 'rc'
|
# Cherry-pick from 'main' into 'rc'
|
||||||
git switch rc
|
cherry_pick main rc
|
||||||
git cherry-pick --strategy-option=theirs -x $MAIN_COMMIT
|
|
||||||
git push -u origin rc
|
|
||||||
|
|
||||||
# If the 'rc' branch does not exist:
|
# If the 'rc' branch does not exist:
|
||||||
else
|
else
|
||||||
|
|
||||||
# Cherry-pick from 'main' into 'hotfix-rc'
|
# Cherry-pick from 'main' into 'hotfix-rc'
|
||||||
git switch hotfix-rc
|
cherry_pick main hotfix-rc
|
||||||
git cherry-pick --strategy-option=theirs -x $MAIN_COMMIT
|
|
||||||
git push -u origin hotfix-rc
|
|
||||||
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@ -235,9 +253,7 @@ jobs:
|
|||||||
elif [[ "$CUT_BRANCH" == "rc" ]]; then
|
elif [[ "$CUT_BRANCH" == "rc" ]]; then
|
||||||
|
|
||||||
# Cherry-pick from 'main' into 'rc'
|
# Cherry-pick from 'main' into 'rc'
|
||||||
git switch rc
|
cherry_pick main rc
|
||||||
git cherry-pick --strategy-option=theirs -x $MAIN_COMMIT
|
|
||||||
git push -u origin rc
|
|
||||||
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
10
.github/workflows/scan.yml
vendored
10
.github/workflows/scan.yml
vendored
@ -26,7 +26,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
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@f779452ac5af1c261dce0346a8f964149f49322b # v3.26.13
|
uses: github/codeql-action/upload-sarif@662472033e021d55d94146f66f6058822b0b39fd # v3.27.0
|
||||||
with:
|
with:
|
||||||
sarif_file: cx_result.sarif
|
sarif_file: cx_result.sarif
|
||||||
|
|
||||||
@ -60,19 +60,19 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Set up JDK 17
|
- name: Set up JDK 17
|
||||||
uses: actions/setup-java@b36c23c0d998641eff861008f374ee103c25ac73 # v4.4.0
|
uses: actions/setup-java@8df1039502a15bceb9433410b1a100fbe190c53b # v4.5.0
|
||||||
with:
|
with:
|
||||||
java-version: 17
|
java-version: 17
|
||||||
distribution: "zulu"
|
distribution: "zulu"
|
||||||
|
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
ref: ${{ github.event.pull_request.head.sha }}
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
|
||||||
- name: Set up .NET
|
- name: Set up .NET
|
||||||
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
|
uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
|
||||||
|
|
||||||
- name: Install SonarCloud scanner
|
- name: Install SonarCloud scanner
|
||||||
run: dotnet tool install dotnet-sonarscanner -g
|
run: dotnet tool install dotnet-sonarscanner -g
|
||||||
|
47
.github/workflows/test-database.yml
vendored
47
.github/workflows/test-database.yml
vendored
@ -30,15 +30,34 @@ on:
|
|||||||
- "test/Infrastructure.IntegrationTest/**" # Any changes to the tests
|
- "test/Infrastructure.IntegrationTest/**" # Any changes to the tests
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
check-test-secrets:
|
||||||
|
name: Check for test secrets
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
outputs:
|
||||||
|
available: ${{ steps.check-test-secrets.outputs.available }}
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check
|
||||||
|
id: check-test-secrets
|
||||||
|
run: |
|
||||||
|
if [ "${{ secrets.CODECOV_TOKEN }}" != '' ]; then
|
||||||
|
echo "available=true" >> $GITHUB_OUTPUT;
|
||||||
|
else
|
||||||
|
echo "available=false" >> $GITHUB_OUTPUT;
|
||||||
|
fi
|
||||||
|
|
||||||
test:
|
test:
|
||||||
name: Run tests
|
name: Run tests
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
|
needs: check-test-secrets
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
|
||||||
- name: Set up .NET
|
- name: Set up .NET
|
||||||
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
|
uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
|
||||||
|
|
||||||
- name: Restore tools
|
- name: Restore tools
|
||||||
run: dotnet tool restore
|
run: dotnet tool restore
|
||||||
@ -51,6 +70,11 @@ jobs:
|
|||||||
docker compose --profile mssql --profile postgres --profile mysql up -d
|
docker compose --profile mssql --profile postgres --profile mysql up -d
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
|
|
||||||
|
- name: Add MariaDB for unified
|
||||||
|
# Use a different port than MySQL
|
||||||
|
run: |
|
||||||
|
docker run --detach --name mariadb --env MARIADB_ROOT_PASSWORD=mariadb-password -p 4306:3306 mariadb:10
|
||||||
|
|
||||||
# I've seen the SQL Server container not be ready for commands right after starting up and just needing a bit longer to be ready
|
# I've seen the SQL Server container not be ready for commands right after starting up and just needing a bit longer to be ready
|
||||||
- name: Sleep
|
- name: Sleep
|
||||||
run: sleep 15s
|
run: sleep 15s
|
||||||
@ -84,6 +108,12 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
CONN_STR: "server=localhost;uid=root;pwd=SET_A_PASSWORD_HERE_123;database=vault_dev;Allow User Variables=true"
|
CONN_STR: "server=localhost;uid=root;pwd=SET_A_PASSWORD_HERE_123;database=vault_dev;Allow User Variables=true"
|
||||||
|
|
||||||
|
- name: Migrate MariaDB
|
||||||
|
working-directory: "util/MySqlMigrations"
|
||||||
|
run: 'dotnet ef database update --connection "$CONN_STR" -- --GlobalSettings:MySql:ConnectionString="$CONN_STR"'
|
||||||
|
env:
|
||||||
|
CONN_STR: "server=localhost;port=4306;uid=root;pwd=mariadb-password;database=vault_dev;Allow User Variables=true"
|
||||||
|
|
||||||
- name: Migrate Postgres
|
- name: Migrate Postgres
|
||||||
working-directory: "util/PostgresMigrations"
|
working-directory: "util/PostgresMigrations"
|
||||||
run: 'dotnet ef database update --connection "$CONN_STR" -- --GlobalSettings:PostgreSql:ConnectionString="$CONN_STR"'
|
run: 'dotnet ef database update --connection "$CONN_STR" -- --GlobalSettings:PostgreSql:ConnectionString="$CONN_STR"'
|
||||||
@ -111,6 +141,9 @@ jobs:
|
|||||||
# Default Sqlite
|
# Default Sqlite
|
||||||
BW_TEST_DATABASES__3__TYPE: "Sqlite"
|
BW_TEST_DATABASES__3__TYPE: "Sqlite"
|
||||||
BW_TEST_DATABASES__3__CONNECTIONSTRING: "Data Source=${{ runner.temp }}/test.db"
|
BW_TEST_DATABASES__3__CONNECTIONSTRING: "Data Source=${{ runner.temp }}/test.db"
|
||||||
|
# Unified MariaDB
|
||||||
|
BW_TEST_DATABASES__4__TYPE: "MySql"
|
||||||
|
BW_TEST_DATABASES__4__CONNECTIONSTRING: "server=localhost;port=4306;uid=root;pwd=mariadb-password;database=vault_dev;Allow User Variables=true"
|
||||||
run: dotnet test --logger "trx;LogFileName=infrastructure-test-results.trx"
|
run: dotnet test --logger "trx;LogFileName=infrastructure-test-results.trx"
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
|
|
||||||
@ -118,6 +151,10 @@ jobs:
|
|||||||
if: failure()
|
if: failure()
|
||||||
run: 'docker logs $(docker ps --quiet --filter "name=mysql")'
|
run: 'docker logs $(docker ps --quiet --filter "name=mysql")'
|
||||||
|
|
||||||
|
- name: Print MariaDB Logs
|
||||||
|
if: failure()
|
||||||
|
run: 'docker logs $(docker ps --quiet --filter "name=mariadb")'
|
||||||
|
|
||||||
- name: Print Postgres Logs
|
- name: Print Postgres Logs
|
||||||
if: failure()
|
if: failure()
|
||||||
run: 'docker logs $(docker ps --quiet --filter "name=postgres")'
|
run: 'docker logs $(docker ps --quiet --filter "name=postgres")'
|
||||||
@ -128,7 +165,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Report test results
|
- name: Report test results
|
||||||
uses: dorny/test-reporter@31a54ee7ebcacc03a09ea97a7e5465a47b84aea5 # v1.9.1
|
uses: dorny/test-reporter@31a54ee7ebcacc03a09ea97a7e5465a47b84aea5 # v1.9.1
|
||||||
if: always()
|
if: ${{ needs.check-test-secrets.outputs.available == 'true' && !cancelled() }}
|
||||||
with:
|
with:
|
||||||
name: Test Results
|
name: Test Results
|
||||||
path: "**/*-test-results.trx"
|
path: "**/*-test-results.trx"
|
||||||
@ -146,10 +183,10 @@ jobs:
|
|||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
|
||||||
- name: Set up .NET
|
- name: Set up .NET
|
||||||
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
|
uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
|
||||||
|
|
||||||
- name: Print environment
|
- name: Print environment
|
||||||
run: |
|
run: |
|
||||||
|
4
.github/workflows/test.yml
vendored
4
.github/workflows/test.yml
vendored
@ -46,10 +46,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
|
||||||
- name: Set up .NET
|
- name: Set up .NET
|
||||||
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
|
uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
|
||||||
|
|
||||||
- name: Print environment
|
- name: Print environment
|
||||||
run: |
|
run: |
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
|
||||||
<Version>2024.10.1</Version>
|
<Version>2024.11.0</Version>
|
||||||
|
|
||||||
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
|
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
@ -40,6 +40,36 @@ public class CreateProviderCommand : ICreateProviderCommand
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async Task CreateMspAsync(Provider provider, string ownerEmail, int teamsMinimumSeats, int enterpriseMinimumSeats)
|
public async Task CreateMspAsync(Provider provider, string ownerEmail, int teamsMinimumSeats, int enterpriseMinimumSeats)
|
||||||
|
{
|
||||||
|
var providerId = await CreateProviderAsync(provider, ownerEmail);
|
||||||
|
|
||||||
|
var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
|
||||||
|
|
||||||
|
if (isConsolidatedBillingEnabled)
|
||||||
|
{
|
||||||
|
await CreateProviderPlanAsync(providerId, PlanType.TeamsMonthly, teamsMinimumSeats);
|
||||||
|
await CreateProviderPlanAsync(providerId, PlanType.EnterpriseMonthly, enterpriseMinimumSeats);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task CreateResellerAsync(Provider provider)
|
||||||
|
{
|
||||||
|
await ProviderRepositoryCreateAsync(provider, ProviderStatusType.Created);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task CreateMultiOrganizationEnterpriseAsync(Provider provider, string ownerEmail, PlanType plan, int minimumSeats)
|
||||||
|
{
|
||||||
|
var providerId = await CreateProviderAsync(provider, ownerEmail);
|
||||||
|
|
||||||
|
var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
|
||||||
|
|
||||||
|
if (isConsolidatedBillingEnabled)
|
||||||
|
{
|
||||||
|
await CreateProviderPlanAsync(providerId, plan, minimumSeats);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Guid> CreateProviderAsync(Provider provider, string ownerEmail)
|
||||||
{
|
{
|
||||||
var owner = await _userRepository.GetByEmailAsync(ownerEmail);
|
var owner = await _userRepository.GetByEmailAsync(ownerEmail);
|
||||||
if (owner == null)
|
if (owner == null)
|
||||||
@ -64,27 +94,10 @@ public class CreateProviderCommand : ICreateProviderCommand
|
|||||||
Status = ProviderUserStatusType.Confirmed,
|
Status = ProviderUserStatusType.Confirmed,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isConsolidatedBillingEnabled)
|
|
||||||
{
|
|
||||||
var providerPlans = new List<ProviderPlan>
|
|
||||||
{
|
|
||||||
CreateProviderPlan(provider.Id, PlanType.TeamsMonthly, teamsMinimumSeats),
|
|
||||||
CreateProviderPlan(provider.Id, PlanType.EnterpriseMonthly, enterpriseMinimumSeats)
|
|
||||||
};
|
|
||||||
|
|
||||||
foreach (var providerPlan in providerPlans)
|
|
||||||
{
|
|
||||||
await _providerPlanRepository.CreateAsync(providerPlan);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await _providerUserRepository.CreateAsync(providerUser);
|
await _providerUserRepository.CreateAsync(providerUser);
|
||||||
await _providerService.SendProviderSetupInviteEmailAsync(provider, owner.Email);
|
await _providerService.SendProviderSetupInviteEmailAsync(provider, owner.Email);
|
||||||
}
|
|
||||||
|
|
||||||
public async Task CreateResellerAsync(Provider provider)
|
return provider.Id;
|
||||||
{
|
|
||||||
await ProviderRepositoryCreateAsync(provider, ProviderStatusType.Created);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ProviderRepositoryCreateAsync(Provider provider, ProviderStatusType status)
|
private async Task ProviderRepositoryCreateAsync(Provider provider, ProviderStatusType status)
|
||||||
@ -95,9 +108,9 @@ public class CreateProviderCommand : ICreateProviderCommand
|
|||||||
await _providerRepository.CreateAsync(provider);
|
await _providerRepository.CreateAsync(provider);
|
||||||
}
|
}
|
||||||
|
|
||||||
private ProviderPlan CreateProviderPlan(Guid providerId, PlanType planType, int seatMinimum)
|
private async Task CreateProviderPlanAsync(Guid providerId, PlanType planType, int seatMinimum)
|
||||||
{
|
{
|
||||||
return new ProviderPlan
|
var plan = new ProviderPlan
|
||||||
{
|
{
|
||||||
ProviderId = providerId,
|
ProviderId = providerId,
|
||||||
PlanType = planType,
|
PlanType = planType,
|
||||||
@ -105,5 +118,6 @@ public class CreateProviderCommand : ICreateProviderCommand
|
|||||||
PurchasedSeats = 0,
|
PurchasedSeats = 0,
|
||||||
AllocatedSeats = 0
|
AllocatedSeats = 0
|
||||||
};
|
};
|
||||||
|
await _providerPlanRepository.CreateAsync(plan);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -392,7 +392,9 @@ public class ProviderService : IProviderService
|
|||||||
|
|
||||||
var organization = await _organizationRepository.GetByIdAsync(organizationId);
|
var organization = await _organizationRepository.GetByIdAsync(organizationId);
|
||||||
|
|
||||||
ThrowOnInvalidPlanType(organization.PlanType);
|
var provider = await _providerRepository.GetByIdAsync(providerId);
|
||||||
|
|
||||||
|
ThrowOnInvalidPlanType(provider.Type, organization.PlanType);
|
||||||
|
|
||||||
if (organization.UseSecretsManager)
|
if (organization.UseSecretsManager)
|
||||||
{
|
{
|
||||||
@ -407,8 +409,6 @@ public class ProviderService : IProviderService
|
|||||||
Key = key,
|
Key = key,
|
||||||
};
|
};
|
||||||
|
|
||||||
var provider = await _providerRepository.GetByIdAsync(providerId);
|
|
||||||
|
|
||||||
await ApplyProviderPriceRateAsync(organization, provider);
|
await ApplyProviderPriceRateAsync(organization, provider);
|
||||||
await _providerOrganizationRepository.CreateAsync(providerOrganization);
|
await _providerOrganizationRepository.CreateAsync(providerOrganization);
|
||||||
|
|
||||||
@ -547,7 +547,7 @@ public class ProviderService : IProviderService
|
|||||||
|
|
||||||
var consolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling) && provider.IsBillable();
|
var consolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling) && provider.IsBillable();
|
||||||
|
|
||||||
ThrowOnInvalidPlanType(organizationSignup.Plan, consolidatedBillingEnabled);
|
ThrowOnInvalidPlanType(provider.Type, organizationSignup.Plan, consolidatedBillingEnabled);
|
||||||
|
|
||||||
var (organization, _, defaultCollection) = consolidatedBillingEnabled
|
var (organization, _, defaultCollection) = consolidatedBillingEnabled
|
||||||
? await _organizationService.SignupClientAsync(organizationSignup)
|
? await _organizationService.SignupClientAsync(organizationSignup)
|
||||||
@ -687,11 +687,27 @@ public class ProviderService : IProviderService
|
|||||||
return confirmedOwnersIds.Except(providerUserIds).Any();
|
return confirmedOwnersIds.Except(providerUserIds).Any();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ThrowOnInvalidPlanType(PlanType requestedType, bool consolidatedBillingEnabled = false)
|
private void ThrowOnInvalidPlanType(ProviderType providerType, PlanType requestedType, bool consolidatedBillingEnabled = false)
|
||||||
{
|
{
|
||||||
if (consolidatedBillingEnabled && requestedType is not (PlanType.TeamsMonthly or PlanType.EnterpriseMonthly))
|
if (consolidatedBillingEnabled)
|
||||||
{
|
{
|
||||||
throw new BadRequestException($"Providers cannot manage organizations with the plan type {requestedType}. Only Teams (Monthly) and Enterprise (Monthly) are allowed.");
|
switch (providerType)
|
||||||
|
{
|
||||||
|
case ProviderType.Msp:
|
||||||
|
if (requestedType is not (PlanType.TeamsMonthly or PlanType.EnterpriseMonthly))
|
||||||
|
{
|
||||||
|
throw new BadRequestException($"Managed Service Providers cannot manage organizations with the plan type {requestedType}. Only Teams (Monthly) and Enterprise (Monthly) are allowed.");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case ProviderType.MultiOrganizationEnterprise:
|
||||||
|
if (requestedType is not (PlanType.EnterpriseMonthly or PlanType.EnterpriseAnnually))
|
||||||
|
{
|
||||||
|
throw new BadRequestException($"Multi-organization Enterprise Providers cannot manage organizations with the plan type {requestedType}. Only Enterprise (Monthly) and Enterprise (Annually) are allowed.");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new BadRequestException($"Unsupported provider type {providerType}.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ProviderDisallowedOrganizationTypes.Contains(requestedType))
|
if (ProviderDisallowedOrganizationTypes.Contains(requestedType))
|
||||||
|
@ -2,16 +2,14 @@
|
|||||||
using Bit.Commercial.Core.Billing.Models;
|
using Bit.Commercial.Core.Billing.Models;
|
||||||
using Bit.Core.AdminConsole.Entities;
|
using Bit.Core.AdminConsole.Entities;
|
||||||
using Bit.Core.AdminConsole.Entities.Provider;
|
using Bit.Core.AdminConsole.Entities.Provider;
|
||||||
using Bit.Core.AdminConsole.Enums.Provider;
|
|
||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
using Bit.Core.Billing;
|
using Bit.Core.Billing;
|
||||||
using Bit.Core.Billing.Constants;
|
using Bit.Core.Billing.Constants;
|
||||||
using Bit.Core.Billing.Entities;
|
using Bit.Core.Billing.Entities;
|
||||||
using Bit.Core.Billing.Enums;
|
using Bit.Core.Billing.Enums;
|
||||||
using Bit.Core.Billing.Extensions;
|
|
||||||
using Bit.Core.Billing.Repositories;
|
using Bit.Core.Billing.Repositories;
|
||||||
using Bit.Core.Billing.Services;
|
using Bit.Core.Billing.Services;
|
||||||
using Bit.Core.Context;
|
using Bit.Core.Billing.Services.Contracts;
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.Exceptions;
|
using Bit.Core.Exceptions;
|
||||||
using Bit.Core.Models.Business;
|
using Bit.Core.Models.Business;
|
||||||
@ -26,7 +24,6 @@ using Stripe;
|
|||||||
namespace Bit.Commercial.Core.Billing;
|
namespace Bit.Commercial.Core.Billing;
|
||||||
|
|
||||||
public class ProviderBillingService(
|
public class ProviderBillingService(
|
||||||
ICurrentContext currentContext,
|
|
||||||
IGlobalSettings globalSettings,
|
IGlobalSettings globalSettings,
|
||||||
ILogger<ProviderBillingService> logger,
|
ILogger<ProviderBillingService> logger,
|
||||||
IOrganizationRepository organizationRepository,
|
IOrganizationRepository organizationRepository,
|
||||||
@ -34,38 +31,76 @@ public class ProviderBillingService(
|
|||||||
IProviderInvoiceItemRepository providerInvoiceItemRepository,
|
IProviderInvoiceItemRepository providerInvoiceItemRepository,
|
||||||
IProviderOrganizationRepository providerOrganizationRepository,
|
IProviderOrganizationRepository providerOrganizationRepository,
|
||||||
IProviderPlanRepository providerPlanRepository,
|
IProviderPlanRepository providerPlanRepository,
|
||||||
IProviderRepository providerRepository,
|
|
||||||
IStripeAdapter stripeAdapter,
|
IStripeAdapter stripeAdapter,
|
||||||
ISubscriberService subscriberService) : IProviderBillingService
|
ISubscriberService subscriberService) : IProviderBillingService
|
||||||
{
|
{
|
||||||
public async Task AssignSeatsToClientOrganization(
|
public async Task ChangePlan(ChangeProviderPlanCommand command)
|
||||||
Provider provider,
|
|
||||||
Organization organization,
|
|
||||||
int seats)
|
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(organization);
|
var plan = await providerPlanRepository.GetByIdAsync(command.ProviderPlanId);
|
||||||
|
|
||||||
if (seats < 0)
|
if (plan == null)
|
||||||
{
|
{
|
||||||
throw new BillingException(
|
throw new BadRequestException("Provider plan not found.");
|
||||||
"You cannot assign negative seats to a client.",
|
|
||||||
"MSP cannot assign negative seats to a client organization");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (seats == organization.Seats)
|
if (plan.PlanType == command.NewPlan)
|
||||||
{
|
{
|
||||||
logger.LogWarning("Client organization ({ID}) already has {Seats} seats assigned to it", organization.Id, organization.Seats);
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var seatAdjustment = seats - (organization.Seats ?? 0);
|
var oldPlanConfiguration = StaticStore.GetPlan(plan.PlanType);
|
||||||
|
|
||||||
await ScaleSeats(provider, organization.PlanType, seatAdjustment);
|
plan.PlanType = command.NewPlan;
|
||||||
|
await providerPlanRepository.ReplaceAsync(plan);
|
||||||
|
|
||||||
organization.Seats = seats;
|
Subscription subscription;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
subscription = await stripeAdapter.ProviderSubscriptionGetAsync(command.GatewaySubscriptionId, plan.ProviderId);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException)
|
||||||
|
{
|
||||||
|
throw new ConflictException("Subscription not found.");
|
||||||
|
}
|
||||||
|
|
||||||
await organizationRepository.ReplaceAsync(organization);
|
var oldSubscriptionItem = subscription.Items.SingleOrDefault(x =>
|
||||||
|
x.Price.Id == oldPlanConfiguration.PasswordManager.StripeProviderPortalSeatPlanId);
|
||||||
|
|
||||||
|
var updateOptions = new SubscriptionUpdateOptions
|
||||||
|
{
|
||||||
|
Items =
|
||||||
|
[
|
||||||
|
new SubscriptionItemOptions
|
||||||
|
{
|
||||||
|
Price = StaticStore.GetPlan(command.NewPlan).PasswordManager.StripeProviderPortalSeatPlanId,
|
||||||
|
Quantity = oldSubscriptionItem!.Quantity
|
||||||
|
},
|
||||||
|
new SubscriptionItemOptions
|
||||||
|
{
|
||||||
|
Id = oldSubscriptionItem.Id,
|
||||||
|
Deleted = true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
await stripeAdapter.SubscriptionUpdateAsync(command.GatewaySubscriptionId, updateOptions);
|
||||||
|
|
||||||
|
// Refactor later to ?ChangeClientPlanCommand? (ProviderPlanId, ProviderId, OrganizationId)
|
||||||
|
// 1. Retrieve PlanType and PlanName for ProviderPlan
|
||||||
|
// 2. Assign PlanType & PlanName to Organization
|
||||||
|
var providerOrganizations = await providerOrganizationRepository.GetManyDetailsByProviderAsync(plan.ProviderId);
|
||||||
|
|
||||||
|
foreach (var providerOrganization in providerOrganizations)
|
||||||
|
{
|
||||||
|
var organization = await organizationRepository.GetByIdAsync(providerOrganization.OrganizationId);
|
||||||
|
if (organization == null)
|
||||||
|
{
|
||||||
|
throw new ConflictException($"Organization '{providerOrganization.Id}' not found.");
|
||||||
|
}
|
||||||
|
organization.PlanType = command.NewPlan;
|
||||||
|
organization.Plan = StaticStore.GetPlan(command.NewPlan).Name;
|
||||||
|
await organizationRepository.ReplaceAsync(organization);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task CreateCustomerForClientOrganization(
|
public async Task CreateCustomerForClientOrganization(
|
||||||
@ -170,72 +205,16 @@ public class ProviderBillingService(
|
|||||||
return memoryStream.ToArray();
|
return memoryStream.ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<int> GetAssignedSeatTotalForPlanOrThrow(
|
|
||||||
Guid providerId,
|
|
||||||
PlanType planType)
|
|
||||||
{
|
|
||||||
var provider = await providerRepository.GetByIdAsync(providerId);
|
|
||||||
|
|
||||||
if (provider == null)
|
|
||||||
{
|
|
||||||
logger.LogError(
|
|
||||||
"Could not find provider ({ID}) when retrieving assigned seat total",
|
|
||||||
providerId);
|
|
||||||
|
|
||||||
throw new BillingException();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (provider.Type == ProviderType.Reseller)
|
|
||||||
{
|
|
||||||
logger.LogError("Assigned seats cannot be retrieved for reseller-type provider ({ID})", providerId);
|
|
||||||
|
|
||||||
throw new BillingException();
|
|
||||||
}
|
|
||||||
|
|
||||||
var providerOrganizations = await providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId);
|
|
||||||
|
|
||||||
var plan = StaticStore.GetPlan(planType);
|
|
||||||
|
|
||||||
return providerOrganizations
|
|
||||||
.Where(providerOrganization => providerOrganization.Plan == plan.Name && providerOrganization.Status == OrganizationStatusType.Managed)
|
|
||||||
.Sum(providerOrganization => providerOrganization.Seats ?? 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task ScaleSeats(
|
public async Task ScaleSeats(
|
||||||
Provider provider,
|
Provider provider,
|
||||||
PlanType planType,
|
PlanType planType,
|
||||||
int seatAdjustment)
|
int seatAdjustment)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(provider);
|
var providerPlan = await GetProviderPlanAsync(provider, planType);
|
||||||
|
|
||||||
if (provider.Type != ProviderType.Msp)
|
var seatMinimum = providerPlan.SeatMinimum ?? 0;
|
||||||
{
|
|
||||||
logger.LogError("Non-MSP provider ({ProviderID}) cannot scale their seats", provider.Id);
|
|
||||||
|
|
||||||
throw new BillingException();
|
var currentlyAssignedSeatTotal = await GetAssignedSeatTotalAsync(provider, planType);
|
||||||
}
|
|
||||||
|
|
||||||
if (!planType.SupportsConsolidatedBilling())
|
|
||||||
{
|
|
||||||
logger.LogError("Cannot scale provider ({ProviderID}) seats for plan type {PlanType} as it does not support consolidated billing", provider.Id, planType.ToString());
|
|
||||||
|
|
||||||
throw new BillingException();
|
|
||||||
}
|
|
||||||
|
|
||||||
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
|
|
||||||
|
|
||||||
var providerPlan = providerPlans.FirstOrDefault(providerPlan => providerPlan.PlanType == planType);
|
|
||||||
|
|
||||||
if (providerPlan == null || !providerPlan.IsConfigured())
|
|
||||||
{
|
|
||||||
logger.LogError("Cannot scale provider ({ProviderID}) seats for plan type {PlanType} when their matching provider plan is not configured", provider.Id, planType);
|
|
||||||
|
|
||||||
throw new BillingException();
|
|
||||||
}
|
|
||||||
|
|
||||||
var seatMinimum = providerPlan.SeatMinimum.GetValueOrDefault(0);
|
|
||||||
|
|
||||||
var currentlyAssignedSeatTotal = await GetAssignedSeatTotalForPlanOrThrow(provider.Id, planType);
|
|
||||||
|
|
||||||
var newlyAssignedSeatTotal = currentlyAssignedSeatTotal + seatAdjustment;
|
var newlyAssignedSeatTotal = currentlyAssignedSeatTotal + seatAdjustment;
|
||||||
|
|
||||||
@ -262,13 +241,6 @@ public class ProviderBillingService(
|
|||||||
else if (currentlyAssignedSeatTotal <= seatMinimum &&
|
else if (currentlyAssignedSeatTotal <= seatMinimum &&
|
||||||
newlyAssignedSeatTotal > seatMinimum)
|
newlyAssignedSeatTotal > seatMinimum)
|
||||||
{
|
{
|
||||||
if (!currentContext.ProviderProviderAdmin(provider.Id))
|
|
||||||
{
|
|
||||||
logger.LogError("Service user for provider ({ProviderID}) cannot scale a provider's seat count over the seat minimum", provider.Id);
|
|
||||||
|
|
||||||
throw new BillingException();
|
|
||||||
}
|
|
||||||
|
|
||||||
await update(
|
await update(
|
||||||
seatMinimum,
|
seatMinimum,
|
||||||
newlyAssignedSeatTotal);
|
newlyAssignedSeatTotal);
|
||||||
@ -297,6 +269,26 @@ public class ProviderBillingService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<bool> SeatAdjustmentResultsInPurchase(
|
||||||
|
Provider provider,
|
||||||
|
PlanType planType,
|
||||||
|
int seatAdjustment)
|
||||||
|
{
|
||||||
|
var providerPlan = await GetProviderPlanAsync(provider, planType);
|
||||||
|
|
||||||
|
var seatMinimum = providerPlan.SeatMinimum;
|
||||||
|
|
||||||
|
var currentlyAssignedSeatTotal = await GetAssignedSeatTotalAsync(provider, planType);
|
||||||
|
|
||||||
|
var newlyAssignedSeatTotal = currentlyAssignedSeatTotal + seatAdjustment;
|
||||||
|
|
||||||
|
return
|
||||||
|
// Below the limit to above the limit
|
||||||
|
(currentlyAssignedSeatTotal <= seatMinimum && newlyAssignedSeatTotal > seatMinimum) ||
|
||||||
|
// Above the limit to further above the limit
|
||||||
|
(currentlyAssignedSeatTotal > seatMinimum && newlyAssignedSeatTotal > seatMinimum && newlyAssignedSeatTotal > currentlyAssignedSeatTotal);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<Customer> SetupCustomer(
|
public async Task<Customer> SetupCustomer(
|
||||||
Provider provider,
|
Provider provider,
|
||||||
TaxInfo taxInfo)
|
TaxInfo taxInfo)
|
||||||
@ -379,42 +371,23 @@ public class ProviderBillingService(
|
|||||||
|
|
||||||
var subscriptionItemOptionsList = new List<SubscriptionItemOptions>();
|
var subscriptionItemOptionsList = new List<SubscriptionItemOptions>();
|
||||||
|
|
||||||
var teamsProviderPlan =
|
foreach (var providerPlan in providerPlans)
|
||||||
providerPlans.SingleOrDefault(providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly);
|
|
||||||
|
|
||||||
if (teamsProviderPlan == null || !teamsProviderPlan.IsConfigured())
|
|
||||||
{
|
{
|
||||||
logger.LogError("Cannot start subscription for provider ({ProviderID}) that has no configured Teams plan", provider.Id);
|
var plan = StaticStore.GetPlan(providerPlan.PlanType);
|
||||||
|
|
||||||
throw new BillingException();
|
if (!providerPlan.IsConfigured())
|
||||||
|
{
|
||||||
|
logger.LogError("Cannot start subscription for provider ({ProviderID}) that has no configured {ProviderName} plan", provider.Id, plan.Name);
|
||||||
|
throw new BillingException();
|
||||||
|
}
|
||||||
|
|
||||||
|
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
|
||||||
|
{
|
||||||
|
Price = plan.PasswordManager.StripeProviderPortalSeatPlanId,
|
||||||
|
Quantity = providerPlan.SeatMinimum
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
var teamsPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
|
|
||||||
|
|
||||||
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
|
|
||||||
{
|
|
||||||
Price = teamsPlan.PasswordManager.StripeProviderPortalSeatPlanId,
|
|
||||||
Quantity = teamsProviderPlan.SeatMinimum
|
|
||||||
});
|
|
||||||
|
|
||||||
var enterpriseProviderPlan =
|
|
||||||
providerPlans.SingleOrDefault(providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly);
|
|
||||||
|
|
||||||
if (enterpriseProviderPlan == null || !enterpriseProviderPlan.IsConfigured())
|
|
||||||
{
|
|
||||||
logger.LogError("Cannot start subscription for provider ({ProviderID}) that has no configured Enterprise plan", provider.Id);
|
|
||||||
|
|
||||||
throw new BillingException();
|
|
||||||
}
|
|
||||||
|
|
||||||
var enterprisePlan = StaticStore.GetPlan(PlanType.EnterpriseMonthly);
|
|
||||||
|
|
||||||
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
|
|
||||||
{
|
|
||||||
Price = enterprisePlan.PasswordManager.StripeProviderPortalSeatPlanId,
|
|
||||||
Quantity = enterpriseProviderPlan.SeatMinimum
|
|
||||||
});
|
|
||||||
|
|
||||||
var subscriptionCreateOptions = new SubscriptionCreateOptions
|
var subscriptionCreateOptions = new SubscriptionCreateOptions
|
||||||
{
|
{
|
||||||
AutomaticTax = new SubscriptionAutomaticTaxOptions
|
AutomaticTax = new SubscriptionAutomaticTaxOptions
|
||||||
@ -456,144 +429,90 @@ public class ProviderBillingService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task UpdateSeatMinimums(
|
public async Task UpdateSeatMinimums(UpdateProviderSeatMinimumsCommand command)
|
||||||
Provider provider,
|
|
||||||
int enterpriseSeatMinimum,
|
|
||||||
int teamsSeatMinimum)
|
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(provider);
|
if (command.Configuration.Any(x => x.SeatsMinimum < 0))
|
||||||
|
|
||||||
if (enterpriseSeatMinimum < 0 || teamsSeatMinimum < 0)
|
|
||||||
{
|
{
|
||||||
throw new BadRequestException("Provider seat minimums must be at least 0.");
|
throw new BadRequestException("Provider seat minimums must be at least 0.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var subscription = await stripeAdapter.SubscriptionGetAsync(provider.GatewaySubscriptionId);
|
Subscription subscription;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
subscription = await stripeAdapter.ProviderSubscriptionGetAsync(command.GatewaySubscriptionId, command.Id);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException)
|
||||||
|
{
|
||||||
|
throw new ConflictException("Subscription not found.");
|
||||||
|
}
|
||||||
|
|
||||||
var subscriptionItemOptionsList = new List<SubscriptionItemOptions>();
|
var subscriptionItemOptionsList = new List<SubscriptionItemOptions>();
|
||||||
|
|
||||||
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
|
var providerPlans = await providerPlanRepository.GetByProviderId(command.Id);
|
||||||
|
|
||||||
var enterpriseProviderPlan =
|
foreach (var newPlanConfiguration in command.Configuration)
|
||||||
providerPlans.Single(providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly);
|
|
||||||
|
|
||||||
if (enterpriseProviderPlan.SeatMinimum != enterpriseSeatMinimum)
|
|
||||||
{
|
{
|
||||||
var enterprisePriceId = StaticStore.GetPlan(PlanType.EnterpriseMonthly).PasswordManager
|
var providerPlan =
|
||||||
.StripeProviderPortalSeatPlanId;
|
providerPlans.Single(providerPlan => providerPlan.PlanType == newPlanConfiguration.Plan);
|
||||||
|
|
||||||
var enterpriseSubscriptionItem = subscription.Items.First(item => item.Price.Id == enterprisePriceId);
|
if (providerPlan.SeatMinimum != newPlanConfiguration.SeatsMinimum)
|
||||||
|
|
||||||
if (enterpriseProviderPlan.PurchasedSeats == 0)
|
|
||||||
{
|
{
|
||||||
if (enterpriseProviderPlan.AllocatedSeats > enterpriseSeatMinimum)
|
var priceId = StaticStore.GetPlan(newPlanConfiguration.Plan).PasswordManager
|
||||||
{
|
.StripeProviderPortalSeatPlanId;
|
||||||
enterpriseProviderPlan.PurchasedSeats =
|
var subscriptionItem = subscription.Items.First(item => item.Price.Id == priceId);
|
||||||
enterpriseProviderPlan.AllocatedSeats - enterpriseSeatMinimum;
|
|
||||||
|
|
||||||
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
|
if (providerPlan.PurchasedSeats == 0)
|
||||||
|
{
|
||||||
|
if (providerPlan.AllocatedSeats > newPlanConfiguration.SeatsMinimum)
|
||||||
{
|
{
|
||||||
Id = enterpriseSubscriptionItem.Id,
|
providerPlan.PurchasedSeats = providerPlan.AllocatedSeats - newPlanConfiguration.SeatsMinimum;
|
||||||
Price = enterprisePriceId,
|
|
||||||
Quantity = enterpriseProviderPlan.AllocatedSeats
|
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
|
||||||
});
|
{
|
||||||
|
Id = subscriptionItem.Id,
|
||||||
|
Price = priceId,
|
||||||
|
Quantity = providerPlan.AllocatedSeats
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
|
||||||
|
{
|
||||||
|
Id = subscriptionItem.Id,
|
||||||
|
Price = priceId,
|
||||||
|
Quantity = newPlanConfiguration.SeatsMinimum
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
|
var totalSeats = providerPlan.SeatMinimum + providerPlan.PurchasedSeats;
|
||||||
|
|
||||||
|
if (newPlanConfiguration.SeatsMinimum <= totalSeats)
|
||||||
{
|
{
|
||||||
Id = enterpriseSubscriptionItem.Id,
|
providerPlan.PurchasedSeats = totalSeats - newPlanConfiguration.SeatsMinimum;
|
||||||
Price = enterprisePriceId,
|
}
|
||||||
Quantity = enterpriseSeatMinimum
|
else
|
||||||
});
|
{
|
||||||
|
providerPlan.PurchasedSeats = 0;
|
||||||
|
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
|
||||||
|
{
|
||||||
|
Id = subscriptionItem.Id,
|
||||||
|
Price = priceId,
|
||||||
|
Quantity = newPlanConfiguration.SeatsMinimum
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
providerPlan.SeatMinimum = newPlanConfiguration.SeatsMinimum;
|
||||||
|
|
||||||
|
await providerPlanRepository.ReplaceAsync(providerPlan);
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
|
||||||
var totalEnterpriseSeats = enterpriseProviderPlan.SeatMinimum + enterpriseProviderPlan.PurchasedSeats;
|
|
||||||
|
|
||||||
if (enterpriseSeatMinimum <= totalEnterpriseSeats)
|
|
||||||
{
|
|
||||||
enterpriseProviderPlan.PurchasedSeats = totalEnterpriseSeats - enterpriseSeatMinimum;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
enterpriseProviderPlan.PurchasedSeats = 0;
|
|
||||||
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
|
|
||||||
{
|
|
||||||
Id = enterpriseSubscriptionItem.Id,
|
|
||||||
Price = enterprisePriceId,
|
|
||||||
Quantity = enterpriseSeatMinimum
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enterpriseProviderPlan.SeatMinimum = enterpriseSeatMinimum;
|
|
||||||
|
|
||||||
await providerPlanRepository.ReplaceAsync(enterpriseProviderPlan);
|
|
||||||
}
|
|
||||||
|
|
||||||
var teamsProviderPlan =
|
|
||||||
providerPlans.Single(providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly);
|
|
||||||
|
|
||||||
if (teamsProviderPlan.SeatMinimum != teamsSeatMinimum)
|
|
||||||
{
|
|
||||||
var teamsPriceId = StaticStore.GetPlan(PlanType.TeamsMonthly).PasswordManager
|
|
||||||
.StripeProviderPortalSeatPlanId;
|
|
||||||
|
|
||||||
var teamsSubscriptionItem = subscription.Items.First(item => item.Price.Id == teamsPriceId);
|
|
||||||
|
|
||||||
if (teamsProviderPlan.PurchasedSeats == 0)
|
|
||||||
{
|
|
||||||
if (teamsProviderPlan.AllocatedSeats > teamsSeatMinimum)
|
|
||||||
{
|
|
||||||
teamsProviderPlan.PurchasedSeats = teamsProviderPlan.AllocatedSeats - teamsSeatMinimum;
|
|
||||||
|
|
||||||
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
|
|
||||||
{
|
|
||||||
Id = teamsSubscriptionItem.Id,
|
|
||||||
Price = teamsPriceId,
|
|
||||||
Quantity = teamsProviderPlan.AllocatedSeats
|
|
||||||
});
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
|
|
||||||
{
|
|
||||||
Id = teamsSubscriptionItem.Id,
|
|
||||||
Price = teamsPriceId,
|
|
||||||
Quantity = teamsSeatMinimum
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var totalTeamsSeats = teamsProviderPlan.SeatMinimum + teamsProviderPlan.PurchasedSeats;
|
|
||||||
|
|
||||||
if (teamsSeatMinimum <= totalTeamsSeats)
|
|
||||||
{
|
|
||||||
teamsProviderPlan.PurchasedSeats = totalTeamsSeats - teamsSeatMinimum;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
teamsProviderPlan.PurchasedSeats = 0;
|
|
||||||
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
|
|
||||||
{
|
|
||||||
Id = teamsSubscriptionItem.Id,
|
|
||||||
Price = teamsPriceId,
|
|
||||||
Quantity = teamsSeatMinimum
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
teamsProviderPlan.SeatMinimum = teamsSeatMinimum;
|
|
||||||
|
|
||||||
await providerPlanRepository.ReplaceAsync(teamsProviderPlan);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (subscriptionItemOptionsList.Count > 0)
|
if (subscriptionItemOptionsList.Count > 0)
|
||||||
{
|
{
|
||||||
await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId,
|
await stripeAdapter.SubscriptionUpdateAsync(command.GatewaySubscriptionId,
|
||||||
new SubscriptionUpdateOptions { Items = subscriptionItemOptionsList });
|
new SubscriptionUpdateOptions { Items = subscriptionItemOptionsList });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -620,4 +539,32 @@ public class ProviderBillingService(
|
|||||||
|
|
||||||
await providerPlanRepository.ReplaceAsync(providerPlan);
|
await providerPlanRepository.ReplaceAsync(providerPlan);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// TODO: Replace with SPROC
|
||||||
|
private async Task<int> GetAssignedSeatTotalAsync(Provider provider, PlanType planType)
|
||||||
|
{
|
||||||
|
var providerOrganizations =
|
||||||
|
await providerOrganizationRepository.GetManyDetailsByProviderAsync(provider.Id);
|
||||||
|
|
||||||
|
var plan = StaticStore.GetPlan(planType);
|
||||||
|
|
||||||
|
return providerOrganizations
|
||||||
|
.Where(providerOrganization => providerOrganization.Plan == plan.Name && providerOrganization.Status == OrganizationStatusType.Managed)
|
||||||
|
.Sum(providerOrganization => providerOrganization.Seats ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Replace with SPROC
|
||||||
|
private async Task<ProviderPlan> GetProviderPlanAsync(Provider provider, PlanType planType)
|
||||||
|
{
|
||||||
|
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
|
||||||
|
|
||||||
|
var providerPlan = providerPlans.FirstOrDefault(x => x.PlanType == planType);
|
||||||
|
|
||||||
|
if (providerPlan == null || !providerPlan.IsConfigured())
|
||||||
|
{
|
||||||
|
throw new BillingException(message: "Provider plan is missing or misconfigured");
|
||||||
|
}
|
||||||
|
|
||||||
|
return providerPlan;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,6 +3,7 @@ using Bit.Core.AdminConsole.Entities.Provider;
|
|||||||
using Bit.Core.AdminConsole.Enums.Provider;
|
using Bit.Core.AdminConsole.Enums.Provider;
|
||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
using Bit.Core.AdminConsole.Services;
|
using Bit.Core.AdminConsole.Services;
|
||||||
|
using Bit.Core.Billing.Enums;
|
||||||
using Bit.Core.Entities;
|
using Bit.Core.Entities;
|
||||||
using Bit.Core.Exceptions;
|
using Bit.Core.Exceptions;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
@ -19,23 +20,30 @@ public class CreateProviderCommandTests
|
|||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
public async Task CreateMspAsync_UserIdIsInvalid_Throws(Provider provider, SutProvider<CreateProviderCommand> sutProvider)
|
public async Task CreateMspAsync_UserIdIsInvalid_Throws(Provider provider, SutProvider<CreateProviderCommand> sutProvider)
|
||||||
{
|
{
|
||||||
|
// Arrange
|
||||||
provider.Type = ProviderType.Msp;
|
provider.Type = ProviderType.Msp;
|
||||||
|
|
||||||
|
// Act
|
||||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||||
() => sutProvider.Sut.CreateMspAsync(provider, default, default, default));
|
() => sutProvider.Sut.CreateMspAsync(provider, default, default, default));
|
||||||
|
|
||||||
|
// Assert
|
||||||
Assert.Contains("Invalid owner.", exception.Message);
|
Assert.Contains("Invalid owner.", exception.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
public async Task CreateMspAsync_Success(Provider provider, User user, SutProvider<CreateProviderCommand> sutProvider)
|
public async Task CreateMspAsync_Success(Provider provider, User user, SutProvider<CreateProviderCommand> sutProvider)
|
||||||
{
|
{
|
||||||
|
// Arrange
|
||||||
provider.Type = ProviderType.Msp;
|
provider.Type = ProviderType.Msp;
|
||||||
|
|
||||||
var userRepository = sutProvider.GetDependency<IUserRepository>();
|
var userRepository = sutProvider.GetDependency<IUserRepository>();
|
||||||
userRepository.GetByEmailAsync(user.Email).Returns(user);
|
userRepository.GetByEmailAsync(user.Email).Returns(user);
|
||||||
|
|
||||||
|
// Act
|
||||||
await sutProvider.Sut.CreateMspAsync(provider, user.Email, default, default);
|
await sutProvider.Sut.CreateMspAsync(provider, user.Email, default, default);
|
||||||
|
|
||||||
|
// Assert
|
||||||
await sutProvider.GetDependency<IProviderRepository>().ReceivedWithAnyArgs().CreateAsync(default);
|
await sutProvider.GetDependency<IProviderRepository>().ReceivedWithAnyArgs().CreateAsync(default);
|
||||||
await sutProvider.GetDependency<IProviderService>().Received(1).SendProviderSetupInviteEmailAsync(provider, user.Email);
|
await sutProvider.GetDependency<IProviderService>().Received(1).SendProviderSetupInviteEmailAsync(provider, user.Email);
|
||||||
}
|
}
|
||||||
@ -43,11 +51,52 @@ public class CreateProviderCommandTests
|
|||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
public async Task CreateResellerAsync_Success(Provider provider, SutProvider<CreateProviderCommand> sutProvider)
|
public async Task CreateResellerAsync_Success(Provider provider, SutProvider<CreateProviderCommand> sutProvider)
|
||||||
{
|
{
|
||||||
|
// Arrange
|
||||||
provider.Type = ProviderType.Reseller;
|
provider.Type = ProviderType.Reseller;
|
||||||
|
|
||||||
|
// Act
|
||||||
await sutProvider.Sut.CreateResellerAsync(provider);
|
await sutProvider.Sut.CreateResellerAsync(provider);
|
||||||
|
|
||||||
|
// Assert
|
||||||
await sutProvider.GetDependency<IProviderRepository>().ReceivedWithAnyArgs().CreateAsync(default);
|
await sutProvider.GetDependency<IProviderRepository>().ReceivedWithAnyArgs().CreateAsync(default);
|
||||||
await sutProvider.GetDependency<IProviderService>().DidNotReceiveWithAnyArgs().SendProviderSetupInviteEmailAsync(default, default);
|
await sutProvider.GetDependency<IProviderService>().DidNotReceiveWithAnyArgs().SendProviderSetupInviteEmailAsync(default, default);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task CreateMultiOrganizationEnterpriseAsync_Success(
|
||||||
|
Provider provider,
|
||||||
|
User user,
|
||||||
|
PlanType plan,
|
||||||
|
int minimumSeats,
|
||||||
|
SutProvider<CreateProviderCommand> sutProvider)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
provider.Type = ProviderType.MultiOrganizationEnterprise;
|
||||||
|
|
||||||
|
var userRepository = sutProvider.GetDependency<IUserRepository>();
|
||||||
|
userRepository.GetByEmailAsync(user.Email).Returns(user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await sutProvider.Sut.CreateMultiOrganizationEnterpriseAsync(provider, user.Email, plan, minimumSeats);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await sutProvider.GetDependency<IProviderRepository>().ReceivedWithAnyArgs().CreateAsync(provider);
|
||||||
|
await sutProvider.GetDependency<IProviderService>().Received(1).SendProviderSetupInviteEmailAsync(provider, user.Email);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task CreateMultiOrganizationEnterpriseAsync_UserIdIsInvalid_Throws(
|
||||||
|
Provider provider,
|
||||||
|
SutProvider<CreateProviderCommand> sutProvider)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
provider.Type = ProviderType.Msp;
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||||
|
() => sutProvider.Sut.CreateMultiOrganizationEnterpriseAsync(provider, default, default, default));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Contains("Invalid owner.", exception.Message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -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.8" />
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.10" />
|
||||||
<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)" />
|
||||||
|
@ -5,8 +5,10 @@ using Bit.Admin.Services;
|
|||||||
using Bit.Admin.Utilities;
|
using Bit.Admin.Utilities;
|
||||||
using Bit.Core;
|
using Bit.Core;
|
||||||
using Bit.Core.AdminConsole.Entities;
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Bit.Core.AdminConsole.Enums.Provider;
|
||||||
using Bit.Core.AdminConsole.Providers.Interfaces;
|
using Bit.Core.AdminConsole.Providers.Interfaces;
|
||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
|
using Bit.Core.Billing.Enums;
|
||||||
using Bit.Core.Billing.Extensions;
|
using Bit.Core.Billing.Extensions;
|
||||||
using Bit.Core.Billing.Services;
|
using Bit.Core.Billing.Services;
|
||||||
using Bit.Core.Context;
|
using Bit.Core.Context;
|
||||||
@ -230,7 +232,23 @@ public class OrganizationsController : Controller
|
|||||||
[SelfHosted(NotSelfHostedOnly = true)]
|
[SelfHosted(NotSelfHostedOnly = true)]
|
||||||
public async Task<IActionResult> Edit(Guid id, OrganizationEditModel model)
|
public async Task<IActionResult> Edit(Guid id, OrganizationEditModel model)
|
||||||
{
|
{
|
||||||
var organization = await GetOrganization(id, model);
|
var organization = await _organizationRepository.GetByIdAsync(id);
|
||||||
|
|
||||||
|
if (organization == null)
|
||||||
|
{
|
||||||
|
TempData["Error"] = "Could not find organization to update.";
|
||||||
|
return RedirectToAction("Index");
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingOrganizationData = new Organization
|
||||||
|
{
|
||||||
|
Id = organization.Id,
|
||||||
|
Status = organization.Status,
|
||||||
|
PlanType = organization.PlanType,
|
||||||
|
Seats = organization.Seats
|
||||||
|
};
|
||||||
|
|
||||||
|
UpdateOrganization(organization, model);
|
||||||
|
|
||||||
if (organization.UseSecretsManager &&
|
if (organization.UseSecretsManager &&
|
||||||
!StaticStore.GetPlan(organization.PlanType).SupportsSecretsManager)
|
!StaticStore.GetPlan(organization.PlanType).SupportsSecretsManager)
|
||||||
@ -239,7 +257,12 @@ public class OrganizationsController : Controller
|
|||||||
return RedirectToAction("Edit", new { id });
|
return RedirectToAction("Edit", new { id });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await HandlePotentialProviderSeatScalingAsync(
|
||||||
|
existingOrganizationData,
|
||||||
|
model);
|
||||||
|
|
||||||
await _organizationRepository.ReplaceAsync(organization);
|
await _organizationRepository.ReplaceAsync(organization);
|
||||||
|
|
||||||
await _applicationCacheService.UpsertOrganizationAbilityAsync(organization);
|
await _applicationCacheService.UpsertOrganizationAbilityAsync(organization);
|
||||||
await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.OrganizationEditedByAdmin, organization, _currentContext)
|
await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.OrganizationEditedByAdmin, organization, _currentContext)
|
||||||
{
|
{
|
||||||
@ -394,10 +417,9 @@ public class OrganizationsController : Controller
|
|||||||
|
|
||||||
return Json(null);
|
return Json(null);
|
||||||
}
|
}
|
||||||
private async Task<Organization> GetOrganization(Guid id, OrganizationEditModel model)
|
|
||||||
{
|
|
||||||
var organization = await _organizationRepository.GetByIdAsync(id);
|
|
||||||
|
|
||||||
|
private void UpdateOrganization(Organization organization, OrganizationEditModel model)
|
||||||
|
{
|
||||||
if (_accessControlService.UserHasPermission(Permission.Org_CheckEnabledBox))
|
if (_accessControlService.UserHasPermission(Permission.Org_CheckEnabledBox))
|
||||||
{
|
{
|
||||||
organization.Enabled = model.Enabled;
|
organization.Enabled = model.Enabled;
|
||||||
@ -449,7 +471,64 @@ public class OrganizationsController : Controller
|
|||||||
organization.GatewayCustomerId = model.GatewayCustomerId;
|
organization.GatewayCustomerId = model.GatewayCustomerId;
|
||||||
organization.GatewaySubscriptionId = model.GatewaySubscriptionId;
|
organization.GatewaySubscriptionId = model.GatewaySubscriptionId;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return organization;
|
private async Task HandlePotentialProviderSeatScalingAsync(
|
||||||
|
Organization organization,
|
||||||
|
OrganizationEditModel update)
|
||||||
|
{
|
||||||
|
var consolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
|
||||||
|
|
||||||
|
var scaleMSPOnClientOrganizationUpdate =
|
||||||
|
_featureService.IsEnabled(FeatureFlagKeys.PM14401_ScaleMSPOnClientOrganizationUpdate);
|
||||||
|
|
||||||
|
if (!consolidatedBillingEnabled || !scaleMSPOnClientOrganizationUpdate)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var provider = await _providerRepository.GetByOrganizationIdAsync(organization.Id);
|
||||||
|
|
||||||
|
// No scaling required
|
||||||
|
if (provider is not { Type: ProviderType.Msp, Status: ProviderStatusType.Billable } ||
|
||||||
|
organization is not { Status: OrganizationStatusType.Managed } ||
|
||||||
|
!organization.Seats.HasValue ||
|
||||||
|
update is { Seats: null, PlanType: null } ||
|
||||||
|
update is { PlanType: not PlanType.TeamsMonthly and not PlanType.EnterpriseMonthly } ||
|
||||||
|
(PlanTypesMatch() && SeatsMatch()))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only scale the plan
|
||||||
|
if (!PlanTypesMatch() && SeatsMatch())
|
||||||
|
{
|
||||||
|
await _providerBillingService.ScaleSeats(provider, organization.PlanType, -organization.Seats.Value);
|
||||||
|
await _providerBillingService.ScaleSeats(provider, update.PlanType!.Value, organization.Seats.Value);
|
||||||
|
}
|
||||||
|
// Only scale the seats
|
||||||
|
else if (PlanTypesMatch() && !SeatsMatch())
|
||||||
|
{
|
||||||
|
var seatAdjustment = update.Seats!.Value - organization.Seats.Value;
|
||||||
|
await _providerBillingService.ScaleSeats(provider, organization.PlanType, seatAdjustment);
|
||||||
|
}
|
||||||
|
// Scale both
|
||||||
|
else if (!PlanTypesMatch() && !SeatsMatch())
|
||||||
|
{
|
||||||
|
var seatAdjustment = update.Seats!.Value - organization.Seats.Value;
|
||||||
|
var planTypeAdjustment = organization.Seats.Value;
|
||||||
|
var totalAdjustment = seatAdjustment + planTypeAdjustment;
|
||||||
|
|
||||||
|
await _providerBillingService.ScaleSeats(provider, organization.PlanType, -organization.Seats.Value);
|
||||||
|
await _providerBillingService.ScaleSeats(provider, update.PlanType!.Value, totalAdjustment);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
|
||||||
|
bool PlanTypesMatch()
|
||||||
|
=> update.PlanType.HasValue && update.PlanType.Value == organization.PlanType;
|
||||||
|
|
||||||
|
bool SeatsMatch()
|
||||||
|
=> update.Seats.HasValue && update.Seats.Value == organization.Seats;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -14,6 +14,7 @@ using Bit.Core.Billing.Enums;
|
|||||||
using Bit.Core.Billing.Extensions;
|
using Bit.Core.Billing.Extensions;
|
||||||
using Bit.Core.Billing.Repositories;
|
using Bit.Core.Billing.Repositories;
|
||||||
using Bit.Core.Billing.Services;
|
using Bit.Core.Billing.Services;
|
||||||
|
using Bit.Core.Billing.Services.Contracts;
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.Exceptions;
|
using Bit.Core.Exceptions;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
@ -107,9 +108,15 @@ public class ProvidersController : Controller
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public IActionResult Create(int teamsMinimumSeats, int enterpriseMinimumSeats, string ownerEmail = null)
|
public IActionResult Create()
|
||||||
{
|
{
|
||||||
return View(new CreateProviderModel
|
return View(new CreateProviderModel());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("providers/create/msp")]
|
||||||
|
public IActionResult CreateMsp(int teamsMinimumSeats, int enterpriseMinimumSeats, string ownerEmail = null)
|
||||||
|
{
|
||||||
|
return View(new CreateMspProviderModel
|
||||||
{
|
{
|
||||||
OwnerEmail = ownerEmail,
|
OwnerEmail = ownerEmail,
|
||||||
TeamsMonthlySeatMinimum = teamsMinimumSeats,
|
TeamsMonthlySeatMinimum = teamsMinimumSeats,
|
||||||
@ -117,10 +124,50 @@ public class ProvidersController : Controller
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("providers/create/reseller")]
|
||||||
|
public IActionResult CreateReseller()
|
||||||
|
{
|
||||||
|
return View(new CreateResellerProviderModel());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("providers/create/multi-organization-enterprise")]
|
||||||
|
public IActionResult CreateMultiOrganizationEnterprise(int enterpriseMinimumSeats, string ownerEmail = null)
|
||||||
|
{
|
||||||
|
if (!_featureService.IsEnabled(FeatureFlagKeys.PM12275_MultiOrganizationEnterprises))
|
||||||
|
{
|
||||||
|
return RedirectToAction("Create");
|
||||||
|
}
|
||||||
|
|
||||||
|
return View(new CreateMultiOrganizationEnterpriseProviderModel
|
||||||
|
{
|
||||||
|
OwnerEmail = ownerEmail,
|
||||||
|
EnterpriseSeatMinimum = enterpriseMinimumSeats
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[ValidateAntiForgeryToken]
|
[ValidateAntiForgeryToken]
|
||||||
[RequirePermission(Permission.Provider_Create)]
|
[RequirePermission(Permission.Provider_Create)]
|
||||||
public async Task<IActionResult> Create(CreateProviderModel model)
|
public IActionResult Create(CreateProviderModel model)
|
||||||
|
{
|
||||||
|
if (!ModelState.IsValid)
|
||||||
|
{
|
||||||
|
return View(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
return model.Type switch
|
||||||
|
{
|
||||||
|
ProviderType.Msp => RedirectToAction("CreateMsp"),
|
||||||
|
ProviderType.Reseller => RedirectToAction("CreateReseller"),
|
||||||
|
ProviderType.MultiOrganizationEnterprise => RedirectToAction("CreateMultiOrganizationEnterprise"),
|
||||||
|
_ => View(model)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("providers/create/msp")]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
[RequirePermission(Permission.Provider_Create)]
|
||||||
|
public async Task<IActionResult> CreateMsp(CreateMspProviderModel model)
|
||||||
{
|
{
|
||||||
if (!ModelState.IsValid)
|
if (!ModelState.IsValid)
|
||||||
{
|
{
|
||||||
@ -128,19 +175,51 @@ public class ProvidersController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
var provider = model.ToProvider();
|
var provider = model.ToProvider();
|
||||||
switch (provider.Type)
|
|
||||||
|
await _createProviderCommand.CreateMspAsync(
|
||||||
|
provider,
|
||||||
|
model.OwnerEmail,
|
||||||
|
model.TeamsMonthlySeatMinimum,
|
||||||
|
model.EnterpriseMonthlySeatMinimum);
|
||||||
|
|
||||||
|
return RedirectToAction("Edit", new { id = provider.Id });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("providers/create/reseller")]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
[RequirePermission(Permission.Provider_Create)]
|
||||||
|
public async Task<IActionResult> CreateReseller(CreateResellerProviderModel model)
|
||||||
|
{
|
||||||
|
if (!ModelState.IsValid)
|
||||||
{
|
{
|
||||||
case ProviderType.Msp:
|
return View(model);
|
||||||
await _createProviderCommand.CreateMspAsync(
|
|
||||||
provider,
|
|
||||||
model.OwnerEmail,
|
|
||||||
model.TeamsMonthlySeatMinimum,
|
|
||||||
model.EnterpriseMonthlySeatMinimum);
|
|
||||||
break;
|
|
||||||
case ProviderType.Reseller:
|
|
||||||
await _createProviderCommand.CreateResellerAsync(provider);
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
var provider = model.ToProvider();
|
||||||
|
await _createProviderCommand.CreateResellerAsync(provider);
|
||||||
|
|
||||||
|
return RedirectToAction("Edit", new { id = provider.Id });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("providers/create/multi-organization-enterprise")]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
[RequirePermission(Permission.Provider_Create)]
|
||||||
|
public async Task<IActionResult> CreateMultiOrganizationEnterprise(CreateMultiOrganizationEnterpriseProviderModel model)
|
||||||
|
{
|
||||||
|
if (!ModelState.IsValid)
|
||||||
|
{
|
||||||
|
return View(model);
|
||||||
|
}
|
||||||
|
var provider = model.ToProvider();
|
||||||
|
|
||||||
|
if (!_featureService.IsEnabled(FeatureFlagKeys.PM12275_MultiOrganizationEnterprises))
|
||||||
|
{
|
||||||
|
return RedirectToAction("Create");
|
||||||
|
}
|
||||||
|
await _createProviderCommand.CreateMultiOrganizationEnterpriseAsync(
|
||||||
|
provider,
|
||||||
|
model.OwnerEmail,
|
||||||
|
model.Plan.Value,
|
||||||
|
model.EnterpriseSeatMinimum);
|
||||||
|
|
||||||
return RedirectToAction("Edit", new { id = provider.Id });
|
return RedirectToAction("Edit", new { id = provider.Id });
|
||||||
}
|
}
|
||||||
@ -212,25 +291,39 @@ public class ProvidersController : Controller
|
|||||||
|
|
||||||
var providerPlans = await _providerPlanRepository.GetByProviderId(id);
|
var providerPlans = await _providerPlanRepository.GetByProviderId(id);
|
||||||
|
|
||||||
if (providerPlans.Count == 0)
|
switch (provider.Type)
|
||||||
{
|
{
|
||||||
var newProviderPlans = new List<ProviderPlan>
|
case ProviderType.Msp:
|
||||||
{
|
var updateMspSeatMinimumsCommand = new UpdateProviderSeatMinimumsCommand(
|
||||||
new () { ProviderId = provider.Id, PlanType = PlanType.TeamsMonthly, SeatMinimum = model.TeamsMonthlySeatMinimum, PurchasedSeats = 0, AllocatedSeats = 0 },
|
provider.Id,
|
||||||
new () { ProviderId = provider.Id, PlanType = PlanType.EnterpriseMonthly, SeatMinimum = model.EnterpriseMonthlySeatMinimum, PurchasedSeats = 0, AllocatedSeats = 0 }
|
provider.GatewaySubscriptionId,
|
||||||
};
|
[
|
||||||
|
(Plan: PlanType.TeamsMonthly, SeatsMinimum: model.TeamsMonthlySeatMinimum),
|
||||||
|
(Plan: PlanType.EnterpriseMonthly, SeatsMinimum: model.EnterpriseMonthlySeatMinimum)
|
||||||
|
]);
|
||||||
|
await _providerBillingService.UpdateSeatMinimums(updateMspSeatMinimumsCommand);
|
||||||
|
break;
|
||||||
|
case ProviderType.MultiOrganizationEnterprise:
|
||||||
|
{
|
||||||
|
var existingMoePlan = providerPlans.Single();
|
||||||
|
|
||||||
foreach (var newProviderPlan in newProviderPlans)
|
// 1. Change the plan and take over any old values.
|
||||||
{
|
var changeMoePlanCommand = new ChangeProviderPlanCommand(
|
||||||
await _providerPlanRepository.CreateAsync(newProviderPlan);
|
existingMoePlan.Id,
|
||||||
}
|
model.Plan!.Value,
|
||||||
}
|
provider.GatewaySubscriptionId);
|
||||||
else
|
await _providerBillingService.ChangePlan(changeMoePlanCommand);
|
||||||
{
|
|
||||||
await _providerBillingService.UpdateSeatMinimums(
|
// 2. Update the seat minimums.
|
||||||
provider,
|
var updateMoeSeatMinimumsCommand = new UpdateProviderSeatMinimumsCommand(
|
||||||
model.EnterpriseMonthlySeatMinimum,
|
provider.Id,
|
||||||
model.TeamsMonthlySeatMinimum);
|
provider.GatewaySubscriptionId,
|
||||||
|
[
|
||||||
|
(Plan: model.Plan!.Value, SeatsMinimum: model.EnterpriseMinimumSeats!.Value)
|
||||||
|
]);
|
||||||
|
await _providerBillingService.UpdateSeatMinimums(updateMoeSeatMinimumsCommand);
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return RedirectToAction("Edit", new { id });
|
return RedirectToAction("Edit", new { id });
|
||||||
|
45
src/Admin/AdminConsole/Models/CreateMspProviderModel.cs
Normal file
45
src/Admin/AdminConsole/Models/CreateMspProviderModel.cs
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using Bit.Core.AdminConsole.Entities.Provider;
|
||||||
|
using Bit.Core.AdminConsole.Enums.Provider;
|
||||||
|
using Bit.SharedWeb.Utilities;
|
||||||
|
|
||||||
|
namespace Bit.Admin.AdminConsole.Models;
|
||||||
|
|
||||||
|
public class CreateMspProviderModel : IValidatableObject
|
||||||
|
{
|
||||||
|
[Display(Name = "Owner Email")]
|
||||||
|
public string OwnerEmail { get; set; }
|
||||||
|
|
||||||
|
[Display(Name = "Teams (Monthly) Seat Minimum")]
|
||||||
|
public int TeamsMonthlySeatMinimum { get; set; }
|
||||||
|
|
||||||
|
[Display(Name = "Enterprise (Monthly) Seat Minimum")]
|
||||||
|
public int EnterpriseMonthlySeatMinimum { get; set; }
|
||||||
|
|
||||||
|
public virtual Provider ToProvider()
|
||||||
|
{
|
||||||
|
return new Provider
|
||||||
|
{
|
||||||
|
Type = ProviderType.Msp
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(OwnerEmail))
|
||||||
|
{
|
||||||
|
var ownerEmailDisplayName = nameof(OwnerEmail).GetDisplayAttribute<CreateMspProviderModel>()?.GetName() ?? nameof(OwnerEmail);
|
||||||
|
yield return new ValidationResult($"The {ownerEmailDisplayName} field is required.");
|
||||||
|
}
|
||||||
|
if (TeamsMonthlySeatMinimum < 0)
|
||||||
|
{
|
||||||
|
var teamsMinimumSeatsDisplayName = nameof(TeamsMonthlySeatMinimum).GetDisplayAttribute<CreateMspProviderModel>()?.GetName() ?? nameof(TeamsMonthlySeatMinimum);
|
||||||
|
yield return new ValidationResult($"The {teamsMinimumSeatsDisplayName} field can not be negative.");
|
||||||
|
}
|
||||||
|
if (EnterpriseMonthlySeatMinimum < 0)
|
||||||
|
{
|
||||||
|
var enterpriseMinimumSeatsDisplayName = nameof(EnterpriseMonthlySeatMinimum).GetDisplayAttribute<CreateMspProviderModel>()?.GetName() ?? nameof(EnterpriseMonthlySeatMinimum);
|
||||||
|
yield return new ValidationResult($"The {enterpriseMinimumSeatsDisplayName} field can not be negative.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,47 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using Bit.Core.AdminConsole.Entities.Provider;
|
||||||
|
using Bit.Core.AdminConsole.Enums.Provider;
|
||||||
|
using Bit.Core.Billing.Enums;
|
||||||
|
using Bit.SharedWeb.Utilities;
|
||||||
|
|
||||||
|
namespace Bit.Admin.AdminConsole.Models;
|
||||||
|
|
||||||
|
public class CreateMultiOrganizationEnterpriseProviderModel : IValidatableObject
|
||||||
|
{
|
||||||
|
[Display(Name = "Owner Email")]
|
||||||
|
public string OwnerEmail { get; set; }
|
||||||
|
|
||||||
|
[Display(Name = "Enterprise Seat Minimum")]
|
||||||
|
public int EnterpriseSeatMinimum { get; set; }
|
||||||
|
|
||||||
|
[Display(Name = "Plan")]
|
||||||
|
[Required]
|
||||||
|
public PlanType? Plan { get; set; }
|
||||||
|
|
||||||
|
public virtual Provider ToProvider()
|
||||||
|
{
|
||||||
|
return new Provider
|
||||||
|
{
|
||||||
|
Type = ProviderType.MultiOrganizationEnterprise
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(OwnerEmail))
|
||||||
|
{
|
||||||
|
var ownerEmailDisplayName = nameof(OwnerEmail).GetDisplayAttribute<CreateMultiOrganizationEnterpriseProviderModel>()?.GetName() ?? nameof(OwnerEmail);
|
||||||
|
yield return new ValidationResult($"The {ownerEmailDisplayName} field is required.");
|
||||||
|
}
|
||||||
|
if (EnterpriseSeatMinimum < 0)
|
||||||
|
{
|
||||||
|
var enterpriseSeatMinimumDisplayName = nameof(EnterpriseSeatMinimum).GetDisplayAttribute<CreateMultiOrganizationEnterpriseProviderModel>()?.GetName() ?? nameof(EnterpriseSeatMinimum);
|
||||||
|
yield return new ValidationResult($"The {enterpriseSeatMinimumDisplayName} field can not be negative.");
|
||||||
|
}
|
||||||
|
if (Plan != PlanType.EnterpriseAnnually && Plan != PlanType.EnterpriseMonthly)
|
||||||
|
{
|
||||||
|
var planDisplayName = nameof(Plan).GetDisplayAttribute<CreateMultiOrganizationEnterpriseProviderModel>()?.GetName() ?? nameof(Plan);
|
||||||
|
yield return new ValidationResult($"The {planDisplayName} field must be set to Enterprise Annually or Enterprise Monthly.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,84 +1,8 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using Bit.Core.AdminConsole.Enums.Provider;
|
||||||
using Bit.Core.AdminConsole.Entities.Provider;
|
|
||||||
using Bit.Core.AdminConsole.Enums.Provider;
|
|
||||||
using Bit.SharedWeb.Utilities;
|
|
||||||
|
|
||||||
namespace Bit.Admin.AdminConsole.Models;
|
namespace Bit.Admin.AdminConsole.Models;
|
||||||
|
|
||||||
public class CreateProviderModel : IValidatableObject
|
public class CreateProviderModel
|
||||||
{
|
{
|
||||||
public CreateProviderModel() { }
|
|
||||||
|
|
||||||
[Display(Name = "Provider Type")]
|
|
||||||
public ProviderType Type { get; set; }
|
public ProviderType Type { get; set; }
|
||||||
|
|
||||||
[Display(Name = "Owner Email")]
|
|
||||||
public string OwnerEmail { get; set; }
|
|
||||||
|
|
||||||
[Display(Name = "Name")]
|
|
||||||
public string Name { get; set; }
|
|
||||||
|
|
||||||
[Display(Name = "Business Name")]
|
|
||||||
public string BusinessName { get; set; }
|
|
||||||
|
|
||||||
[Display(Name = "Primary Billing Email")]
|
|
||||||
public string BillingEmail { get; set; }
|
|
||||||
|
|
||||||
[Display(Name = "Teams (Monthly) Seat Minimum")]
|
|
||||||
public int TeamsMonthlySeatMinimum { get; set; }
|
|
||||||
|
|
||||||
[Display(Name = "Enterprise (Monthly) Seat Minimum")]
|
|
||||||
public int EnterpriseMonthlySeatMinimum { get; set; }
|
|
||||||
|
|
||||||
public virtual Provider ToProvider()
|
|
||||||
{
|
|
||||||
return new Provider()
|
|
||||||
{
|
|
||||||
Type = Type,
|
|
||||||
Name = Name,
|
|
||||||
BusinessName = BusinessName,
|
|
||||||
BillingEmail = BillingEmail?.ToLowerInvariant().Trim()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
|
||||||
{
|
|
||||||
switch (Type)
|
|
||||||
{
|
|
||||||
case ProviderType.Msp:
|
|
||||||
if (string.IsNullOrWhiteSpace(OwnerEmail))
|
|
||||||
{
|
|
||||||
var ownerEmailDisplayName = nameof(OwnerEmail).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(OwnerEmail);
|
|
||||||
yield return new ValidationResult($"The {ownerEmailDisplayName} field is required.");
|
|
||||||
}
|
|
||||||
if (TeamsMonthlySeatMinimum < 0)
|
|
||||||
{
|
|
||||||
var teamsMinimumSeatsDisplayName = nameof(TeamsMonthlySeatMinimum).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(TeamsMonthlySeatMinimum);
|
|
||||||
yield return new ValidationResult($"The {teamsMinimumSeatsDisplayName} field can not be negative.");
|
|
||||||
}
|
|
||||||
if (EnterpriseMonthlySeatMinimum < 0)
|
|
||||||
{
|
|
||||||
var enterpriseMinimumSeatsDisplayName = nameof(EnterpriseMonthlySeatMinimum).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(EnterpriseMonthlySeatMinimum);
|
|
||||||
yield return new ValidationResult($"The {enterpriseMinimumSeatsDisplayName} field can not be negative.");
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case ProviderType.Reseller:
|
|
||||||
if (string.IsNullOrWhiteSpace(Name))
|
|
||||||
{
|
|
||||||
var nameDisplayName = nameof(Name).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(Name);
|
|
||||||
yield return new ValidationResult($"The {nameDisplayName} field is required.");
|
|
||||||
}
|
|
||||||
if (string.IsNullOrWhiteSpace(BusinessName))
|
|
||||||
{
|
|
||||||
var businessNameDisplayName = nameof(BusinessName).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(BusinessName);
|
|
||||||
yield return new ValidationResult($"The {businessNameDisplayName} field is required.");
|
|
||||||
}
|
|
||||||
if (string.IsNullOrWhiteSpace(BillingEmail))
|
|
||||||
{
|
|
||||||
var billingEmailDisplayName = nameof(BillingEmail).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(BillingEmail);
|
|
||||||
yield return new ValidationResult($"The {billingEmailDisplayName} field is required.");
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
48
src/Admin/AdminConsole/Models/CreateResellerProviderModel.cs
Normal file
48
src/Admin/AdminConsole/Models/CreateResellerProviderModel.cs
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using Bit.Core.AdminConsole.Entities.Provider;
|
||||||
|
using Bit.Core.AdminConsole.Enums.Provider;
|
||||||
|
using Bit.SharedWeb.Utilities;
|
||||||
|
|
||||||
|
namespace Bit.Admin.AdminConsole.Models;
|
||||||
|
|
||||||
|
public class CreateResellerProviderModel : IValidatableObject
|
||||||
|
{
|
||||||
|
[Display(Name = "Name")]
|
||||||
|
public string Name { get; set; }
|
||||||
|
|
||||||
|
[Display(Name = "Business Name")]
|
||||||
|
public string BusinessName { get; set; }
|
||||||
|
|
||||||
|
[Display(Name = "Primary Billing Email")]
|
||||||
|
public string BillingEmail { get; set; }
|
||||||
|
|
||||||
|
public virtual Provider ToProvider()
|
||||||
|
{
|
||||||
|
return new Provider
|
||||||
|
{
|
||||||
|
Name = Name,
|
||||||
|
BusinessName = BusinessName,
|
||||||
|
BillingEmail = BillingEmail?.ToLowerInvariant().Trim(),
|
||||||
|
Type = ProviderType.Reseller
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(Name))
|
||||||
|
{
|
||||||
|
var nameDisplayName = nameof(Name).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(Name);
|
||||||
|
yield return new ValidationResult($"The {nameDisplayName} field is required.");
|
||||||
|
}
|
||||||
|
if (string.IsNullOrWhiteSpace(BusinessName))
|
||||||
|
{
|
||||||
|
var businessNameDisplayName = nameof(BusinessName).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(BusinessName);
|
||||||
|
yield return new ValidationResult($"The {businessNameDisplayName} field is required.");
|
||||||
|
}
|
||||||
|
if (string.IsNullOrWhiteSpace(BillingEmail))
|
||||||
|
{
|
||||||
|
var billingEmailDisplayName = nameof(BillingEmail).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(BillingEmail);
|
||||||
|
yield return new ValidationResult($"The {billingEmailDisplayName} field is required.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -33,6 +33,13 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject
|
|||||||
GatewayCustomerUrl = gatewayCustomerUrl;
|
GatewayCustomerUrl = gatewayCustomerUrl;
|
||||||
GatewaySubscriptionUrl = gatewaySubscriptionUrl;
|
GatewaySubscriptionUrl = gatewaySubscriptionUrl;
|
||||||
Type = provider.Type;
|
Type = provider.Type;
|
||||||
|
|
||||||
|
if (Type == ProviderType.MultiOrganizationEnterprise)
|
||||||
|
{
|
||||||
|
var plan = providerPlans.SingleOrDefault();
|
||||||
|
EnterpriseMinimumSeats = plan?.SeatMinimum ?? 0;
|
||||||
|
Plan = plan?.PlanType;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[Display(Name = "Billing Email")]
|
[Display(Name = "Billing Email")]
|
||||||
@ -58,13 +65,24 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject
|
|||||||
[Display(Name = "Provider Type")]
|
[Display(Name = "Provider Type")]
|
||||||
public ProviderType Type { get; set; }
|
public ProviderType Type { get; set; }
|
||||||
|
|
||||||
|
[Display(Name = "Plan")]
|
||||||
|
public PlanType? Plan { get; set; }
|
||||||
|
|
||||||
|
[Display(Name = "Enterprise Seats Minimum")]
|
||||||
|
public int? EnterpriseMinimumSeats { get; set; }
|
||||||
|
|
||||||
public virtual Provider ToProvider(Provider existingProvider)
|
public virtual Provider ToProvider(Provider existingProvider)
|
||||||
{
|
{
|
||||||
existingProvider.BillingEmail = BillingEmail?.ToLowerInvariant().Trim();
|
existingProvider.BillingEmail = BillingEmail?.ToLowerInvariant().Trim();
|
||||||
existingProvider.BillingPhone = BillingPhone?.ToLowerInvariant().Trim();
|
existingProvider.BillingPhone = BillingPhone?.ToLowerInvariant().Trim();
|
||||||
existingProvider.Gateway = Gateway;
|
switch (Type)
|
||||||
existingProvider.GatewayCustomerId = GatewayCustomerId;
|
{
|
||||||
existingProvider.GatewaySubscriptionId = GatewaySubscriptionId;
|
case ProviderType.Msp:
|
||||||
|
existingProvider.Gateway = Gateway;
|
||||||
|
existingProvider.GatewayCustomerId = GatewayCustomerId;
|
||||||
|
existingProvider.GatewaySubscriptionId = GatewaySubscriptionId;
|
||||||
|
break;
|
||||||
|
}
|
||||||
return existingProvider;
|
return existingProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -82,6 +100,23 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject
|
|||||||
yield return new ValidationResult($"The {billingEmailDisplayName} field is required.");
|
yield return new ValidationResult($"The {billingEmailDisplayName} field is required.");
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case ProviderType.MultiOrganizationEnterprise:
|
||||||
|
if (Plan == null)
|
||||||
|
{
|
||||||
|
var displayName = nameof(Plan).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(Plan);
|
||||||
|
yield return new ValidationResult($"The {displayName} field is required.");
|
||||||
|
}
|
||||||
|
if (EnterpriseMinimumSeats == null)
|
||||||
|
{
|
||||||
|
var displayName = nameof(EnterpriseMinimumSeats).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(EnterpriseMinimumSeats);
|
||||||
|
yield return new ValidationResult($"The {displayName} field is required.");
|
||||||
|
}
|
||||||
|
if (EnterpriseMinimumSeats < 0)
|
||||||
|
{
|
||||||
|
var displayName = nameof(EnterpriseMinimumSeats).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(EnterpriseMinimumSeats);
|
||||||
|
yield return new ValidationResult($"The {displayName} field cannot be less than 0.");
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,80 +1,48 @@
|
|||||||
@using Bit.SharedWeb.Utilities
|
@using Bit.SharedWeb.Utilities
|
||||||
@using Bit.Core.AdminConsole.Enums.Provider
|
@using Bit.Core.AdminConsole.Enums.Provider
|
||||||
@using Bit.Core
|
@using Bit.Core
|
||||||
|
|
||||||
@model CreateProviderModel
|
@model CreateProviderModel
|
||||||
|
|
||||||
@inject Bit.Core.Services.IFeatureService FeatureService
|
@inject Bit.Core.Services.IFeatureService FeatureService
|
||||||
|
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = "Create Provider";
|
ViewData["Title"] = "Create Provider";
|
||||||
}
|
|
||||||
|
|
||||||
@section Scripts {
|
var providerTypes = Enum.GetValues<ProviderType>()
|
||||||
<script>
|
.OrderBy(x => x.GetDisplayAttribute().Order)
|
||||||
function toggleProviderTypeInfo(value) {
|
.ToList();
|
||||||
document.querySelectorAll('[id^="info-"]').forEach(el => { el.classList.add('d-none'); });
|
|
||||||
document.getElementById('info-' + value).classList.remove('d-none');
|
if (!FeatureService.IsEnabled(FeatureFlagKeys.PM12275_MultiOrganizationEnterprises))
|
||||||
}
|
{
|
||||||
</script>
|
providerTypes.Remove(ProviderType.MultiOrganizationEnterprise);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
<h1>Create Provider</h1>
|
<h1>Create Provider</h1>
|
||||||
|
<form method="post" asp-action="Create">
|
||||||
<form method="post">
|
|
||||||
<div asp-validation-summary="All" class="alert alert-danger"></div>
|
<div asp-validation-summary="All" class="alert alert-danger"></div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label asp-for="Type" class="h2"></label>
|
<label asp-for="Type" class="h2"></label>
|
||||||
@foreach(ProviderType providerType in Enum.GetValues(typeof(ProviderType)))
|
@foreach (var providerType in providerTypes)
|
||||||
{
|
{
|
||||||
var providerTypeValue = (int)providerType;
|
var providerTypeValue = (int)providerType;
|
||||||
<div class="form-check">
|
<div class="form-group">
|
||||||
@Html.RadioButtonFor(m => m.Type, providerType, new { id = $"providerType-{providerTypeValue}", @class = "form-check-input", onclick=$"toggleProviderTypeInfo({providerTypeValue})" })
|
<div class="row">
|
||||||
@Html.LabelFor(m => m.Type, providerType.GetDisplayAttribute()?.GetName(), new { @class = "form-check-label align-middle", @for = $"providerType-{providerTypeValue}" })
|
<div class="col">
|
||||||
<br/>
|
<div class="form-check">
|
||||||
@Html.LabelFor(m => m.Type, providerType.GetDisplayAttribute()?.GetDescription(), new { @class = "form-check-label small text-muted ml-3 align-top", @for = $"providerType-{providerTypeValue}" })
|
@Html.RadioButtonFor(m => m.Type, providerType, new { id = $"providerType-{providerTypeValue}", @class = "form-check-input" })
|
||||||
</div>
|
@Html.LabelFor(m => m.Type, providerType.GetDisplayAttribute()?.GetName(), new { @class = "form-check-label align-middle", @for = $"providerType-{providerTypeValue}" })
|
||||||
}
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="@($"info-{(int)ProviderType.Msp}")" class="form-group @(Model.Type != ProviderType.Msp ? "d-none" : string.Empty)">
|
|
||||||
<h2>MSP Info</h2>
|
|
||||||
<div class="form-group">
|
|
||||||
<label asp-for="OwnerEmail"></label>
|
|
||||||
<input type="text" class="form-control" asp-for="OwnerEmail">
|
|
||||||
</div>
|
|
||||||
@if (FeatureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling))
|
|
||||||
{
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-sm">
|
|
||||||
<div class="form-group">
|
|
||||||
<label asp-for="TeamsMonthlySeatMinimum"></label>
|
|
||||||
<input type="number" class="form-control" asp-for="TeamsMonthlySeatMinimum">
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-sm">
|
<div class="row">
|
||||||
<div class="form-group">
|
<div class="col">
|
||||||
<label asp-for="EnterpriseMonthlySeatMinimum"></label>
|
@Html.LabelFor(m => m.Type, providerType.GetDisplayAttribute()?.GetDescription(), new { @class = "form-check-label small text-muted align-top", @for = $"providerType-{providerTypeValue}" })
|
||||||
<input type="number" class="form-control" asp-for="EnterpriseMonthlySeatMinimum">
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary mb-2">Next</button>
|
||||||
<div id="@($"info-{(int)ProviderType.Reseller}")" class="form-group @(Model.Type != ProviderType.Reseller ? "d-none" : string.Empty)">
|
|
||||||
<h2>Reseller Info</h2>
|
|
||||||
<div class="form-group">
|
|
||||||
<label asp-for="Name"></label>
|
|
||||||
<input type="text" class="form-control" asp-for="Name">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label asp-for="BusinessName"></label>
|
|
||||||
<input type="text" class="form-control" asp-for="BusinessName">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label asp-for="BillingEmail"></label>
|
|
||||||
<input type="text" class="form-control" asp-for="BillingEmail">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary mb-2">Create Provider</button>
|
|
||||||
</form>
|
</form>
|
||||||
|
39
src/Admin/AdminConsole/Views/Providers/CreateMsp.cshtml
Normal file
39
src/Admin/AdminConsole/Views/Providers/CreateMsp.cshtml
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
@using Bit.Core.AdminConsole.Enums.Provider
|
||||||
|
@using Bit.Core
|
||||||
|
|
||||||
|
@model CreateMspProviderModel
|
||||||
|
|
||||||
|
@inject Bit.Core.Services.IFeatureService FeatureService
|
||||||
|
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Create Managed Service Provider";
|
||||||
|
}
|
||||||
|
|
||||||
|
<h1>Create Managed Service Provider</h1>
|
||||||
|
<div>
|
||||||
|
<form class="form-group" method="post" asp-action="CreateMsp">
|
||||||
|
<div asp-validation-summary="All" class="alert alert-danger"></div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label asp-for="OwnerEmail"></label>
|
||||||
|
<input type="text" class="form-control" asp-for="OwnerEmail">
|
||||||
|
</div>
|
||||||
|
@if (FeatureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling))
|
||||||
|
{
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-sm">
|
||||||
|
<div class="form-group">
|
||||||
|
<label asp-for="TeamsMonthlySeatMinimum"></label>
|
||||||
|
<input type="number" class="form-control" asp-for="TeamsMonthlySeatMinimum">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm">
|
||||||
|
<div class="form-group">
|
||||||
|
<label asp-for="EnterpriseMonthlySeatMinimum"></label>
|
||||||
|
<input type="number" class="form-control" asp-for="EnterpriseMonthlySeatMinimum">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<button type="submit" class="btn btn-primary mb-2">Create Provider</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
@ -0,0 +1,43 @@
|
|||||||
|
@using Bit.Core.Billing.Enums
|
||||||
|
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
||||||
|
|
||||||
|
@model CreateMultiOrganizationEnterpriseProviderModel
|
||||||
|
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Create Multi-organization Enterprise Provider";
|
||||||
|
}
|
||||||
|
|
||||||
|
<h1>Create Multi-organization Enterprise Provider</h1>
|
||||||
|
<div>
|
||||||
|
<form class="form-group" method="post" asp-action="CreateMultiOrganizationEnterprise">
|
||||||
|
<div asp-validation-summary="All" class="alert alert-danger"></div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label asp-for="OwnerEmail"></label>
|
||||||
|
<input type="text" class="form-control" asp-for="OwnerEmail">
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-sm">
|
||||||
|
<div class="form-group">
|
||||||
|
@{
|
||||||
|
var multiOrgPlans = new List<PlanType>
|
||||||
|
{
|
||||||
|
PlanType.EnterpriseAnnually,
|
||||||
|
PlanType.EnterpriseMonthly
|
||||||
|
};
|
||||||
|
}
|
||||||
|
<label asp-for="Plan"></label>
|
||||||
|
<select class="form-control" asp-for="Plan" asp-items="Html.GetEnumSelectList(multiOrgPlans)">
|
||||||
|
<option value="">--</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm">
|
||||||
|
<div class="form-group">
|
||||||
|
<label asp-for="EnterpriseSeatMinimum"></label>
|
||||||
|
<input type="number" class="form-control" asp-for="EnterpriseSeatMinimum">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary mb-2">Create Provider</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
25
src/Admin/AdminConsole/Views/Providers/CreateReseller.cshtml
Normal file
25
src/Admin/AdminConsole/Views/Providers/CreateReseller.cshtml
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
@model CreateResellerProviderModel
|
||||||
|
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Create Reseller Provider";
|
||||||
|
}
|
||||||
|
|
||||||
|
<h1>Create Reseller Provider</h1>
|
||||||
|
<div>
|
||||||
|
<form class="form-group" method="post" asp-action="CreateReseller">
|
||||||
|
<div asp-validation-summary="All" class="alert alert-danger"></div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label asp-for="Name"></label>
|
||||||
|
<input type="text" class="form-control" asp-for="Name">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label asp-for="BusinessName"></label>
|
||||||
|
<input type="text" class="form-control" asp-for="BusinessName">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label asp-for="BillingEmail"></label>
|
||||||
|
<input type="text" class="form-control" asp-for="BillingEmail">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary mb-2">Create Provider</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
@ -1,6 +1,9 @@
|
|||||||
@using Bit.Admin.Enums;
|
@using Bit.Admin.Enums;
|
||||||
@using Bit.Core
|
@using Bit.Core
|
||||||
|
@using Bit.Core.AdminConsole.Enums.Provider
|
||||||
|
@using Bit.Core.Billing.Enums
|
||||||
@using Bit.Core.Billing.Extensions
|
@using Bit.Core.Billing.Extensions
|
||||||
|
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
||||||
@inject Bit.Admin.Services.IAccessControlService AccessControlService
|
@inject Bit.Admin.Services.IAccessControlService AccessControlService
|
||||||
@inject Bit.Core.Services.IFeatureService FeatureService
|
@inject Bit.Core.Services.IFeatureService FeatureService
|
||||||
|
|
||||||
@ -47,60 +50,97 @@
|
|||||||
</div>
|
</div>
|
||||||
@if (FeatureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling) && Model.Provider.IsBillable())
|
@if (FeatureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling) && Model.Provider.IsBillable())
|
||||||
{
|
{
|
||||||
<div class="row">
|
switch (Model.Provider.Type)
|
||||||
<div class="col-sm">
|
{
|
||||||
<div class="form-group">
|
case ProviderType.Msp:
|
||||||
<label asp-for="TeamsMonthlySeatMinimum"></label>
|
{
|
||||||
<input type="number" class="form-control" asp-for="TeamsMonthlySeatMinimum">
|
<div class="row">
|
||||||
</div>
|
<div class="col-sm">
|
||||||
</div>
|
<div class="form-group">
|
||||||
<div class="col-sm">
|
<label asp-for="TeamsMonthlySeatMinimum"></label>
|
||||||
<div class="form-group">
|
<input type="number" class="form-control" asp-for="TeamsMonthlySeatMinimum">
|
||||||
<label asp-for="EnterpriseMonthlySeatMinimum"></label>
|
</div>
|
||||||
<input type="number" class="form-control" asp-for="EnterpriseMonthlySeatMinimum">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-sm">
|
|
||||||
<div class="form-group">
|
|
||||||
<div class="form-group">
|
|
||||||
<label asp-for="Gateway"></label>
|
|
||||||
<select class="form-control" asp-for="Gateway" asp-items="Html.GetEnumSelectList<Bit.Core.Enums.GatewayType>()">
|
|
||||||
<option value="">--</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="col-sm">
|
||||||
</div>
|
<div class="form-group">
|
||||||
</div>
|
<label asp-for="EnterpriseMonthlySeatMinimum"></label>
|
||||||
<div class="row">
|
<input type="number" class="form-control" asp-for="EnterpriseMonthlySeatMinimum">
|
||||||
<div class="col-sm">
|
|
||||||
<div class="form-group">
|
|
||||||
<label asp-for="GatewayCustomerId"></label>
|
|
||||||
<div class="input-group">
|
|
||||||
<input type="text" class="form-control" asp-for="GatewayCustomerId">
|
|
||||||
<div class="input-group-append">
|
|
||||||
<a href="@Model.GatewayCustomerUrl" class="btn btn-secondary" target="_blank">
|
|
||||||
<i class="fa fa-external-link"></i>
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="row">
|
||||||
<div class="col-sm">
|
<div class="col-sm">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label asp-for="GatewaySubscriptionId"></label>
|
<div class="form-group">
|
||||||
<div class="input-group">
|
<label asp-for="Gateway"></label>
|
||||||
<input type="text" class="form-control" asp-for="GatewaySubscriptionId">
|
<select class="form-control" asp-for="Gateway" asp-items="Html.GetEnumSelectList<Bit.Core.Enums.GatewayType>()">
|
||||||
<div class="input-group-append">
|
<option value="">--</option>
|
||||||
<a href="@Model.GatewaySubscriptionUrl" class="btn btn-secondary" target="_blank">
|
</select>
|
||||||
<i class="fa fa-external-link"></i>
|
</div>
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="row">
|
||||||
</div>
|
<div class="col-sm">
|
||||||
|
<div class="form-group">
|
||||||
|
<label asp-for="GatewayCustomerId"></label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" class="form-control" asp-for="GatewayCustomerId">
|
||||||
|
<div class="input-group-append">
|
||||||
|
<a href="@Model.GatewayCustomerUrl" class="btn btn-secondary" target="_blank">
|
||||||
|
<i class="fa fa-external-link"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm">
|
||||||
|
<div class="form-group">
|
||||||
|
<label asp-for="GatewaySubscriptionId"></label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" class="form-control" asp-for="GatewaySubscriptionId">
|
||||||
|
<div class="input-group-append">
|
||||||
|
<a href="@Model.GatewaySubscriptionUrl" class="btn btn-secondary" target="_blank">
|
||||||
|
<i class="fa fa-external-link"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case ProviderType.MultiOrganizationEnterprise:
|
||||||
|
{
|
||||||
|
@if (FeatureService.IsEnabled(FeatureFlagKeys.PM12275_MultiOrganizationEnterprises) && Model.Provider.Type == ProviderType.MultiOrganizationEnterprise)
|
||||||
|
{
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-sm">
|
||||||
|
<div class="form-group">
|
||||||
|
@{
|
||||||
|
var multiOrgPlans = new List<PlanType>
|
||||||
|
{
|
||||||
|
PlanType.EnterpriseAnnually,
|
||||||
|
PlanType.EnterpriseMonthly
|
||||||
|
};
|
||||||
|
}
|
||||||
|
<label asp-for="Plan"></label>
|
||||||
|
<select class="form-control" asp-for="Plan" asp-items="Html.GetEnumSelectList(multiOrgPlans)">
|
||||||
|
<option value="">--</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm">
|
||||||
|
<div class="form-group">
|
||||||
|
<label asp-for="EnterpriseMinimumSeats"></label>
|
||||||
|
<input type="number" class="form-control" asp-for="EnterpriseMinimumSeats">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</form>
|
</form>
|
||||||
@await Html.PartialAsync("Organizations", Model)
|
@await Html.PartialAsync("Organizations", Model)
|
||||||
|
@ -4,6 +4,7 @@ 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.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
using Bit.Core.Services;
|
using Bit.Core.Services;
|
||||||
@ -24,6 +25,8 @@ public class UsersController : Controller
|
|||||||
private readonly GlobalSettings _globalSettings;
|
private readonly GlobalSettings _globalSettings;
|
||||||
private readonly IAccessControlService _accessControlService;
|
private readonly IAccessControlService _accessControlService;
|
||||||
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
|
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
|
||||||
|
private readonly IUserService _userService;
|
||||||
|
private readonly IFeatureService _featureService;
|
||||||
|
|
||||||
public UsersController(
|
public UsersController(
|
||||||
IUserRepository userRepository,
|
IUserRepository userRepository,
|
||||||
@ -31,7 +34,9 @@ public class UsersController : Controller
|
|||||||
IPaymentService paymentService,
|
IPaymentService paymentService,
|
||||||
GlobalSettings globalSettings,
|
GlobalSettings globalSettings,
|
||||||
IAccessControlService accessControlService,
|
IAccessControlService accessControlService,
|
||||||
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery)
|
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
|
||||||
|
IUserService userService,
|
||||||
|
IFeatureService featureService)
|
||||||
{
|
{
|
||||||
_userRepository = userRepository;
|
_userRepository = userRepository;
|
||||||
_cipherRepository = cipherRepository;
|
_cipherRepository = cipherRepository;
|
||||||
@ -39,6 +44,8 @@ public class UsersController : Controller
|
|||||||
_globalSettings = globalSettings;
|
_globalSettings = globalSettings;
|
||||||
_accessControlService = accessControlService;
|
_accessControlService = accessControlService;
|
||||||
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
|
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
|
||||||
|
_userService = userService;
|
||||||
|
_featureService = featureService;
|
||||||
}
|
}
|
||||||
|
|
||||||
[RequirePermission(Permission.User_List_View)]
|
[RequirePermission(Permission.User_List_View)]
|
||||||
@ -82,8 +89,8 @@ public class UsersController : Controller
|
|||||||
var ciphers = await _cipherRepository.GetManyByUserIdAsync(id);
|
var ciphers = await _cipherRepository.GetManyByUserIdAsync(id);
|
||||||
|
|
||||||
var isTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user);
|
var isTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user);
|
||||||
|
var verifiedDomain = await AccountDeprovisioningEnabled(user.Id);
|
||||||
return View(UserViewModel.MapViewModel(user, isTwoFactorEnabled, ciphers));
|
return View(UserViewModel.MapViewModel(user, isTwoFactorEnabled, ciphers, verifiedDomain));
|
||||||
}
|
}
|
||||||
|
|
||||||
[SelfHosted(NotSelfHostedOnly = true)]
|
[SelfHosted(NotSelfHostedOnly = true)]
|
||||||
@ -99,7 +106,8 @@ public class UsersController : Controller
|
|||||||
var billingInfo = await _paymentService.GetBillingAsync(user);
|
var billingInfo = await _paymentService.GetBillingAsync(user);
|
||||||
var billingHistoryInfo = await _paymentService.GetBillingHistoryAsync(user);
|
var billingHistoryInfo = await _paymentService.GetBillingHistoryAsync(user);
|
||||||
var isTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user);
|
var isTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user);
|
||||||
return View(new UserEditModel(user, isTwoFactorEnabled, ciphers, billingInfo, billingHistoryInfo, _globalSettings));
|
var verifiedDomain = await AccountDeprovisioningEnabled(user.Id);
|
||||||
|
return View(new UserEditModel(user, isTwoFactorEnabled, ciphers, billingInfo, billingHistoryInfo, _globalSettings, verifiedDomain));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
@ -153,4 +161,12 @@ public class UsersController : Controller
|
|||||||
|
|
||||||
return RedirectToAction("Index");
|
return RedirectToAction("Index");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Feature flag to be removed in PM-14207
|
||||||
|
private async Task<bool?> AccountDeprovisioningEnabled(Guid userId)
|
||||||
|
{
|
||||||
|
return _featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
|
||||||
|
? await _userService.IsManagedByAnyOrganizationAsync(userId)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
19
src/Admin/Enums/HtmlHelperExtensions.cs
Normal file
19
src/Admin/Enums/HtmlHelperExtensions.cs
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
|
||||||
|
using Bit.SharedWeb.Utilities;
|
||||||
|
|
||||||
|
// ReSharper disable once CheckNamespace
|
||||||
|
namespace Microsoft.AspNetCore.Mvc.Rendering;
|
||||||
|
|
||||||
|
public static class HtmlHelper
|
||||||
|
{
|
||||||
|
public static IEnumerable<SelectListItem> GetEnumSelectList<T>(this IHtmlHelper htmlHelper, IEnumerable<T> values)
|
||||||
|
where T : Enum
|
||||||
|
{
|
||||||
|
return values.Select(v => new SelectListItem
|
||||||
|
{
|
||||||
|
Text = v.GetDisplayAttribute().Name,
|
||||||
|
Value = v.ToString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -20,9 +20,11 @@ public class UserEditModel
|
|||||||
IEnumerable<Cipher> ciphers,
|
IEnumerable<Cipher> ciphers,
|
||||||
BillingInfo billingInfo,
|
BillingInfo billingInfo,
|
||||||
BillingHistoryInfo billingHistoryInfo,
|
BillingHistoryInfo billingHistoryInfo,
|
||||||
GlobalSettings globalSettings)
|
GlobalSettings globalSettings,
|
||||||
|
bool? domainVerified
|
||||||
|
)
|
||||||
{
|
{
|
||||||
User = UserViewModel.MapViewModel(user, isTwoFactorEnabled, ciphers);
|
User = UserViewModel.MapViewModel(user, isTwoFactorEnabled, ciphers, domainVerified);
|
||||||
|
|
||||||
BillingInfo = billingInfo;
|
BillingInfo = billingInfo;
|
||||||
BillingHistoryInfo = billingHistoryInfo;
|
BillingHistoryInfo = billingHistoryInfo;
|
||||||
|
@ -14,6 +14,7 @@ public class UserViewModel
|
|||||||
public bool Premium { get; }
|
public bool Premium { get; }
|
||||||
public short? MaxStorageGb { get; }
|
public short? MaxStorageGb { get; }
|
||||||
public bool EmailVerified { get; }
|
public bool EmailVerified { get; }
|
||||||
|
public bool? DomainVerified { get; }
|
||||||
public bool TwoFactorEnabled { get; }
|
public bool TwoFactorEnabled { get; }
|
||||||
public DateTime AccountRevisionDate { get; }
|
public DateTime AccountRevisionDate { get; }
|
||||||
public DateTime RevisionDate { get; }
|
public DateTime RevisionDate { get; }
|
||||||
@ -35,6 +36,7 @@ public class UserViewModel
|
|||||||
bool premium,
|
bool premium,
|
||||||
short? maxStorageGb,
|
short? maxStorageGb,
|
||||||
bool emailVerified,
|
bool emailVerified,
|
||||||
|
bool? domainVerified,
|
||||||
bool twoFactorEnabled,
|
bool twoFactorEnabled,
|
||||||
DateTime accountRevisionDate,
|
DateTime accountRevisionDate,
|
||||||
DateTime revisionDate,
|
DateTime revisionDate,
|
||||||
@ -56,6 +58,7 @@ public class UserViewModel
|
|||||||
Premium = premium;
|
Premium = premium;
|
||||||
MaxStorageGb = maxStorageGb;
|
MaxStorageGb = maxStorageGb;
|
||||||
EmailVerified = emailVerified;
|
EmailVerified = emailVerified;
|
||||||
|
DomainVerified = domainVerified;
|
||||||
TwoFactorEnabled = twoFactorEnabled;
|
TwoFactorEnabled = twoFactorEnabled;
|
||||||
AccountRevisionDate = accountRevisionDate;
|
AccountRevisionDate = accountRevisionDate;
|
||||||
RevisionDate = revisionDate;
|
RevisionDate = revisionDate;
|
||||||
@ -73,10 +76,10 @@ public class UserViewModel
|
|||||||
public static IEnumerable<UserViewModel> MapViewModels(
|
public static IEnumerable<UserViewModel> MapViewModels(
|
||||||
IEnumerable<User> users,
|
IEnumerable<User> users,
|
||||||
IEnumerable<(Guid userId, bool twoFactorIsEnabled)> lookup) =>
|
IEnumerable<(Guid userId, bool twoFactorIsEnabled)> lookup) =>
|
||||||
users.Select(user => MapViewModel(user, lookup));
|
users.Select(user => MapViewModel(user, lookup, false));
|
||||||
|
|
||||||
public static UserViewModel MapViewModel(User user,
|
public static UserViewModel MapViewModel(User user,
|
||||||
IEnumerable<(Guid userId, bool twoFactorIsEnabled)> lookup) =>
|
IEnumerable<(Guid userId, bool twoFactorIsEnabled)> lookup, bool? domainVerified) =>
|
||||||
new(
|
new(
|
||||||
user.Id,
|
user.Id,
|
||||||
user.Name,
|
user.Name,
|
||||||
@ -86,6 +89,7 @@ public class UserViewModel
|
|||||||
user.Premium,
|
user.Premium,
|
||||||
user.MaxStorageGb,
|
user.MaxStorageGb,
|
||||||
user.EmailVerified,
|
user.EmailVerified,
|
||||||
|
domainVerified,
|
||||||
IsTwoFactorEnabled(user, lookup),
|
IsTwoFactorEnabled(user, lookup),
|
||||||
user.AccountRevisionDate,
|
user.AccountRevisionDate,
|
||||||
user.RevisionDate,
|
user.RevisionDate,
|
||||||
@ -100,9 +104,9 @@ public class UserViewModel
|
|||||||
Array.Empty<Cipher>());
|
Array.Empty<Cipher>());
|
||||||
|
|
||||||
public static UserViewModel MapViewModel(User user, bool isTwoFactorEnabled) =>
|
public static UserViewModel MapViewModel(User user, bool isTwoFactorEnabled) =>
|
||||||
MapViewModel(user, isTwoFactorEnabled, Array.Empty<Cipher>());
|
MapViewModel(user, isTwoFactorEnabled, Array.Empty<Cipher>(), false);
|
||||||
|
|
||||||
public static UserViewModel MapViewModel(User user, bool isTwoFactorEnabled, IEnumerable<Cipher> ciphers) =>
|
public static UserViewModel MapViewModel(User user, bool isTwoFactorEnabled, IEnumerable<Cipher> ciphers, bool? domainVerified) =>
|
||||||
new(
|
new(
|
||||||
user.Id,
|
user.Id,
|
||||||
user.Name,
|
user.Name,
|
||||||
@ -112,6 +116,7 @@ public class UserViewModel
|
|||||||
user.Premium,
|
user.Premium,
|
||||||
user.MaxStorageGb,
|
user.MaxStorageGb,
|
||||||
user.EmailVerified,
|
user.EmailVerified,
|
||||||
|
domainVerified,
|
||||||
isTwoFactorEnabled,
|
isTwoFactorEnabled,
|
||||||
user.AccountRevisionDate,
|
user.AccountRevisionDate,
|
||||||
user.RevisionDate,
|
user.RevisionDate,
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
@model UserViewModel
|
@model UserViewModel
|
||||||
<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.Id</code></dd>
|
<dd class="col-sm-8 col-lg-9"><code>@Model.Id</code></dd>
|
||||||
@ -12,6 +12,11 @@
|
|||||||
<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.EmailVerified ? "Yes" : "No")</dd>
|
<dd class="col-sm-8 col-lg-9">@(Model.EmailVerified ? "Yes" : "No")</dd>
|
||||||
|
|
||||||
|
@if(Model.DomainVerified.HasValue){
|
||||||
|
<dt class="col-sm-4 col-lg-3">Domain Verified</dt>
|
||||||
|
<dd class="col-sm-8 col-lg-9">@(Model.DomainVerified.Value == true ? "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">@(Model.TwoFactorEnabled ? "Yes" : "No")</dd>
|
<dd class="col-sm-8 col-lg-9">@(Model.TwoFactorEnabled ? "Yes" : "No")</dd>
|
||||||
|
|
||||||
|
@ -2,15 +2,16 @@
|
|||||||
using Bit.Api.AdminConsole.Models.Response;
|
using Bit.Api.AdminConsole.Models.Response;
|
||||||
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.Groups;
|
using Bit.Core;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Authorization;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces;
|
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization;
|
||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
using Bit.Core.AdminConsole.Services;
|
using Bit.Core.AdminConsole.Services;
|
||||||
using Bit.Core.Context;
|
using Bit.Core.Context;
|
||||||
using Bit.Core.Exceptions;
|
using Bit.Core.Exceptions;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
using Bit.Core.Services;
|
using Bit.Core.Services;
|
||||||
using Bit.Core.Utilities;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
@ -89,11 +90,34 @@ public class GroupsController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("")]
|
[HttpGet("")]
|
||||||
public async Task<ListResponseModel<GroupDetailsResponseModel>> Get(Guid orgId)
|
public async Task<ListResponseModel<GroupDetailsResponseModel>> GetOrganizationGroups(Guid orgId)
|
||||||
{
|
{
|
||||||
var authorized =
|
var authResult = await _authorizationService.AuthorizeAsync(User, new OrganizationScope(orgId), GroupOperations.ReadAll);
|
||||||
(await _authorizationService.AuthorizeAsync(User, GroupOperations.ReadAll(orgId))).Succeeded;
|
if (!authResult.Succeeded)
|
||||||
if (!authorized)
|
{
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_featureService.IsEnabled(FeatureFlagKeys.SecureOrgGroupDetails))
|
||||||
|
{
|
||||||
|
var groups = await _groupRepository.GetManyByOrganizationIdAsync(orgId);
|
||||||
|
var responses = groups.Select(g => new GroupDetailsResponseModel(g, []));
|
||||||
|
return new ListResponseModel<GroupDetailsResponseModel>(responses);
|
||||||
|
}
|
||||||
|
|
||||||
|
var groupDetails = await _groupRepository.GetManyWithCollectionsByOrganizationIdAsync(orgId);
|
||||||
|
var detailResponses = groupDetails.Select(g => new GroupDetailsResponseModel(g.Item1, g.Item2));
|
||||||
|
return new ListResponseModel<GroupDetailsResponseModel>(detailResponses);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("details")]
|
||||||
|
public async Task<ListResponseModel<GroupDetailsResponseModel>> GetOrganizationGroupDetails(Guid orgId)
|
||||||
|
{
|
||||||
|
var authResult = _featureService.IsEnabled(FeatureFlagKeys.SecureOrgGroupDetails)
|
||||||
|
? await _authorizationService.AuthorizeAsync(User, new OrganizationScope(orgId), GroupOperations.ReadAllDetails)
|
||||||
|
: await _authorizationService.AuthorizeAsync(User, new OrganizationScope(orgId), GroupOperations.ReadAll);
|
||||||
|
|
||||||
|
if (!authResult.Succeeded)
|
||||||
{
|
{
|
||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
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;
|
||||||
@ -9,6 +8,7 @@ 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.Authorization;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization;
|
||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
using Bit.Core.Auth.Enums;
|
using Bit.Core.Auth.Enums;
|
||||||
using Bit.Core.Auth.Repositories;
|
using Bit.Core.Auth.Repositories;
|
||||||
@ -53,6 +53,8 @@ public class OrganizationUsersController : Controller
|
|||||||
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
|
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
|
||||||
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
|
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
|
||||||
private readonly IDeleteManagedOrganizationUserAccountCommand _deleteManagedOrganizationUserAccountCommand;
|
private readonly IDeleteManagedOrganizationUserAccountCommand _deleteManagedOrganizationUserAccountCommand;
|
||||||
|
private readonly IGetOrganizationUsersManagementStatusQuery _getOrganizationUsersManagementStatusQuery;
|
||||||
|
private readonly IFeatureService _featureService;
|
||||||
|
|
||||||
public OrganizationUsersController(
|
public OrganizationUsersController(
|
||||||
IOrganizationRepository organizationRepository,
|
IOrganizationRepository organizationRepository,
|
||||||
@ -73,7 +75,9 @@ public class OrganizationUsersController : Controller
|
|||||||
IOrganizationUserUserDetailsQuery organizationUserUserDetailsQuery,
|
IOrganizationUserUserDetailsQuery organizationUserUserDetailsQuery,
|
||||||
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
|
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
|
||||||
IRemoveOrganizationUserCommand removeOrganizationUserCommand,
|
IRemoveOrganizationUserCommand removeOrganizationUserCommand,
|
||||||
IDeleteManagedOrganizationUserAccountCommand deleteManagedOrganizationUserAccountCommand)
|
IDeleteManagedOrganizationUserAccountCommand deleteManagedOrganizationUserAccountCommand,
|
||||||
|
IGetOrganizationUsersManagementStatusQuery getOrganizationUsersManagementStatusQuery,
|
||||||
|
IFeatureService featureService)
|
||||||
{
|
{
|
||||||
_organizationRepository = organizationRepository;
|
_organizationRepository = organizationRepository;
|
||||||
_organizationUserRepository = organizationUserRepository;
|
_organizationUserRepository = organizationUserRepository;
|
||||||
@ -94,29 +98,34 @@ public class OrganizationUsersController : Controller
|
|||||||
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
|
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
|
||||||
_removeOrganizationUserCommand = removeOrganizationUserCommand;
|
_removeOrganizationUserCommand = removeOrganizationUserCommand;
|
||||||
_deleteManagedOrganizationUserAccountCommand = deleteManagedOrganizationUserAccountCommand;
|
_deleteManagedOrganizationUserAccountCommand = deleteManagedOrganizationUserAccountCommand;
|
||||||
|
_getOrganizationUsersManagementStatusQuery = getOrganizationUsersManagementStatusQuery;
|
||||||
|
_featureService = featureService;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id}")]
|
[HttpGet("{id}")]
|
||||||
public async Task<OrganizationUserDetailsResponseModel> Get(string id, bool includeGroups = false)
|
public async Task<OrganizationUserDetailsResponseModel> Get(Guid id, bool includeGroups = false)
|
||||||
{
|
{
|
||||||
var organizationUser = await _organizationUserRepository.GetDetailsByIdWithCollectionsAsync(new Guid(id));
|
var (organizationUser, collections) = await _organizationUserRepository.GetDetailsByIdWithCollectionsAsync(id);
|
||||||
if (organizationUser == null || !await _currentContext.ManageUsers(organizationUser.Item1.OrganizationId))
|
if (organizationUser == null || !await _currentContext.ManageUsers(organizationUser.OrganizationId))
|
||||||
{
|
{
|
||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
}
|
}
|
||||||
|
|
||||||
var response = new OrganizationUserDetailsResponseModel(organizationUser.Item1, organizationUser.Item2);
|
var managedByOrganization = await GetManagedByOrganizationStatusAsync(
|
||||||
|
organizationUser.OrganizationId,
|
||||||
|
[organizationUser.Id]);
|
||||||
|
|
||||||
|
var response = new OrganizationUserDetailsResponseModel(organizationUser, managedByOrganization[organizationUser.Id], collections);
|
||||||
|
|
||||||
if (includeGroups)
|
if (includeGroups)
|
||||||
{
|
{
|
||||||
response.Groups = await _groupRepository.GetManyIdsByUserIdAsync(organizationUser.Item1.Id);
|
response.Groups = await _groupRepository.GetManyIdsByUserIdAsync(organizationUser.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("mini-details")]
|
[HttpGet("mini-details")]
|
||||||
[RequireFeature(FeatureFlagKeys.Pm3478RefactorOrganizationUserApi)]
|
|
||||||
public async Task<ListResponseModel<OrganizationUserUserMiniDetailsResponseModel>> GetMiniDetails(Guid orgId)
|
public async Task<ListResponseModel<OrganizationUserUserMiniDetailsResponseModel>> GetMiniDetails(Guid orgId)
|
||||||
{
|
{
|
||||||
var authorizationResult = await _authorizationService.AuthorizeAsync(User, new OrganizationScope(orgId),
|
var authorizationResult = await _authorizationService.AuthorizeAsync(User, new OrganizationScope(orgId),
|
||||||
@ -150,11 +159,13 @@ public class OrganizationUsersController : Controller
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
var organizationUsersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(organizationUsers);
|
var organizationUsersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(organizationUsers);
|
||||||
|
var organizationUsersManagementStatus = await GetManagedByOrganizationStatusAsync(orgId, organizationUsers.Select(o => o.Id));
|
||||||
var responses = organizationUsers
|
var responses = organizationUsers
|
||||||
.Select(o =>
|
.Select(o =>
|
||||||
{
|
{
|
||||||
var userTwoFactorEnabled = organizationUsersTwoFactorEnabled.FirstOrDefault(u => u.user.Id == o.Id).twoFactorIsEnabled;
|
var userTwoFactorEnabled = organizationUsersTwoFactorEnabled.FirstOrDefault(u => u.user.Id == o.Id).twoFactorIsEnabled;
|
||||||
var orgUser = new OrganizationUserUserDetailsResponseModel(o, userTwoFactorEnabled);
|
var managedByOrganization = organizationUsersManagementStatus[o.Id];
|
||||||
|
var orgUser = new OrganizationUserUserDetailsResponseModel(o, userTwoFactorEnabled, managedByOrganization);
|
||||||
|
|
||||||
return orgUser;
|
return orgUser;
|
||||||
});
|
});
|
||||||
@ -534,7 +545,7 @@ public class OrganizationUsersController : Controller
|
|||||||
[RequireFeature(FeatureFlagKeys.AccountDeprovisioning)]
|
[RequireFeature(FeatureFlagKeys.AccountDeprovisioning)]
|
||||||
[HttpDelete("{id}/delete-account")]
|
[HttpDelete("{id}/delete-account")]
|
||||||
[HttpPost("{id}/delete-account")]
|
[HttpPost("{id}/delete-account")]
|
||||||
public async Task DeleteAccount(Guid orgId, Guid id, [FromBody] SecretVerificationRequestModel model)
|
public async Task DeleteAccount(Guid orgId, Guid id)
|
||||||
{
|
{
|
||||||
if (!await _currentContext.ManageUsers(orgId))
|
if (!await _currentContext.ManageUsers(orgId))
|
||||||
{
|
{
|
||||||
@ -547,19 +558,13 @@ public class OrganizationUsersController : Controller
|
|||||||
throw new UnauthorizedAccessException();
|
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);
|
await _deleteManagedOrganizationUserAccountCommand.DeleteUserAsync(orgId, id, currentUser.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
[RequireFeature(FeatureFlagKeys.AccountDeprovisioning)]
|
[RequireFeature(FeatureFlagKeys.AccountDeprovisioning)]
|
||||||
[HttpDelete("delete-account")]
|
[HttpDelete("delete-account")]
|
||||||
[HttpPost("delete-account")]
|
[HttpPost("delete-account")]
|
||||||
public async Task<ListResponseModel<OrganizationUserBulkResponseModel>> BulkDeleteAccount(Guid orgId, [FromBody] SecureOrganizationUserBulkRequestModel model)
|
public async Task<ListResponseModel<OrganizationUserBulkResponseModel>> BulkDeleteAccount(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model)
|
||||||
{
|
{
|
||||||
if (!await _currentContext.ManageUsers(orgId))
|
if (!await _currentContext.ManageUsers(orgId))
|
||||||
{
|
{
|
||||||
@ -572,12 +577,6 @@ public class OrganizationUsersController : Controller
|
|||||||
throw new UnauthorizedAccessException();
|
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);
|
var results = await _deleteManagedOrganizationUserAccountCommand.DeleteManyUsersAsync(orgId, model.Ids, currentUser.Id);
|
||||||
|
|
||||||
return new ListResponseModel<OrganizationUserBulkResponseModel>(results.Select(r =>
|
return new ListResponseModel<OrganizationUserBulkResponseModel>(results.Select(r =>
|
||||||
@ -682,4 +681,15 @@ public class OrganizationUsersController : Controller
|
|||||||
return new ListResponseModel<OrganizationUserBulkResponseModel>(result.Select(r =>
|
return new ListResponseModel<OrganizationUserBulkResponseModel>(result.Select(r =>
|
||||||
new OrganizationUserBulkResponseModel(r.Item1.Id, r.Item2)));
|
new OrganizationUserBulkResponseModel(r.Item1.Id, r.Item2)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<IDictionary<Guid, bool>> GetManagedByOrganizationStatusAsync(Guid orgId, IEnumerable<Guid> userIds)
|
||||||
|
{
|
||||||
|
if (!_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning))
|
||||||
|
{
|
||||||
|
return userIds.ToDictionary(kvp => kvp, kvp => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
var usersOrganizationManagementStatus = await _getOrganizationUsersManagementStatusQuery.GetUsersOrganizationManagementStatusAsync(orgId, userIds);
|
||||||
|
return usersOrganizationManagementStatus;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -252,6 +252,12 @@ public class OrganizationsController : Controller
|
|||||||
throw new BadRequestException("Your organization's Single Sign-On settings prevent you from leaving.");
|
throw new BadRequestException("Your organization's Single Sign-On settings prevent you from leaving.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
|
||||||
|
&& (await _userService.GetOrganizationsManagingUserAsync(user.Id)).Any(x => x.Id == id))
|
||||||
|
{
|
||||||
|
throw new BadRequestException("Managed user account cannot leave managing organization. Contact your organization administrator for additional details.");
|
||||||
|
}
|
||||||
|
|
||||||
await _removeOrganizationUserCommand.RemoveUserAsync(id, user.Id);
|
await _removeOrganizationUserCommand.RemoveUserAsync(id, user.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,7 +1,11 @@
|
|||||||
using Bit.Api.AdminConsole.Models.Request;
|
using Bit.Api.AdminConsole.Models.Request;
|
||||||
|
using Bit.Api.AdminConsole.Models.Response.Helpers;
|
||||||
|
using Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||||
using Bit.Api.Models.Response;
|
using Bit.Api.Models.Response;
|
||||||
|
using Bit.Core;
|
||||||
|
using Bit.Core.AdminConsole.Entities;
|
||||||
using Bit.Core.AdminConsole.Enums;
|
using Bit.Core.AdminConsole.Enums;
|
||||||
using Bit.Core.AdminConsole.Models.Api.Response;
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
|
||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
using Bit.Core.AdminConsole.Services;
|
using Bit.Core.AdminConsole.Services;
|
||||||
using Bit.Core.Auth.Models.Business.Tokenables;
|
using Bit.Core.Auth.Models.Business.Tokenables;
|
||||||
@ -31,6 +35,8 @@ public class PoliciesController : Controller
|
|||||||
private readonly GlobalSettings _globalSettings;
|
private readonly GlobalSettings _globalSettings;
|
||||||
private readonly IDataProtector _organizationServiceDataProtector;
|
private readonly IDataProtector _organizationServiceDataProtector;
|
||||||
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
|
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
|
||||||
|
private readonly IFeatureService _featureService;
|
||||||
|
private readonly IOrganizationHasVerifiedDomainsQuery _organizationHasVerifiedDomainsQuery;
|
||||||
|
|
||||||
public PoliciesController(
|
public PoliciesController(
|
||||||
IPolicyRepository policyRepository,
|
IPolicyRepository policyRepository,
|
||||||
@ -40,7 +46,9 @@ public class PoliciesController : Controller
|
|||||||
ICurrentContext currentContext,
|
ICurrentContext currentContext,
|
||||||
GlobalSettings globalSettings,
|
GlobalSettings globalSettings,
|
||||||
IDataProtectionProvider dataProtectionProvider,
|
IDataProtectionProvider dataProtectionProvider,
|
||||||
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory)
|
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory,
|
||||||
|
IFeatureService featureService,
|
||||||
|
IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery)
|
||||||
{
|
{
|
||||||
_policyRepository = policyRepository;
|
_policyRepository = policyRepository;
|
||||||
_policyService = policyService;
|
_policyService = policyService;
|
||||||
@ -52,23 +60,29 @@ public class PoliciesController : Controller
|
|||||||
"OrganizationServiceDataProtector");
|
"OrganizationServiceDataProtector");
|
||||||
|
|
||||||
_orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory;
|
_orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory;
|
||||||
|
_featureService = featureService;
|
||||||
|
_organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{type}")]
|
[HttpGet("{type}")]
|
||||||
public async Task<PolicyResponseModel> Get(string orgId, int type)
|
public async Task<PolicyDetailResponseModel> Get(Guid orgId, int type)
|
||||||
{
|
{
|
||||||
var orgIdGuid = new Guid(orgId);
|
if (!await _currentContext.ManagePolicies(orgId))
|
||||||
if (!await _currentContext.ManagePolicies(orgIdGuid))
|
|
||||||
{
|
{
|
||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
}
|
}
|
||||||
var policy = await _policyRepository.GetByOrganizationIdTypeAsync(orgIdGuid, (PolicyType)type);
|
var policy = await _policyRepository.GetByOrganizationIdTypeAsync(orgId, (PolicyType)type);
|
||||||
if (policy == null)
|
if (policy == null)
|
||||||
{
|
{
|
||||||
throw new NotFoundException();
|
return new PolicyDetailResponseModel(new Policy { Type = (PolicyType)type });
|
||||||
}
|
}
|
||||||
|
|
||||||
return new PolicyResponseModel(policy);
|
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) && policy.Type is PolicyType.SingleOrg)
|
||||||
|
{
|
||||||
|
return await policy.GetSingleOrgPolicyDetailResponseAsync(_organizationHasVerifiedDomainsQuery);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new PolicyDetailResponseModel(policy);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("")]
|
[HttpGet("")]
|
||||||
@ -81,8 +95,8 @@ public class PoliciesController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
var policies = await _policyRepository.GetManyByOrganizationIdAsync(orgIdGuid);
|
var policies = await _policyRepository.GetManyByOrganizationIdAsync(orgIdGuid);
|
||||||
var responses = policies.Select(p => new PolicyResponseModel(p));
|
|
||||||
return new ListResponseModel<PolicyResponseModel>(responses);
|
return new ListResponseModel<PolicyResponseModel>(policies.Select(p => new PolicyResponseModel(p)));
|
||||||
}
|
}
|
||||||
|
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
|
@ -1,10 +0,0 @@
|
|||||||
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; }
|
|
||||||
}
|
|
@ -0,0 +1,19 @@
|
|||||||
|
using Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||||
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Bit.Core.AdminConsole.Enums;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
|
||||||
|
|
||||||
|
namespace Bit.Api.AdminConsole.Models.Response.Helpers;
|
||||||
|
|
||||||
|
public static class PolicyDetailResponses
|
||||||
|
{
|
||||||
|
public static async Task<PolicyDetailResponseModel> GetSingleOrgPolicyDetailResponseAsync(this Policy policy, IOrganizationHasVerifiedDomainsQuery hasVerifiedDomainsQuery)
|
||||||
|
{
|
||||||
|
if (policy.Type is not PolicyType.SingleOrg)
|
||||||
|
{
|
||||||
|
throw new ArgumentException($"'{nameof(policy)}' must be of type '{nameof(PolicyType.SingleOrg)}'.", nameof(policy));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new PolicyDetailResponseModel(policy, !await hasVerifiedDomainsQuery.HasVerifiedDomainsAsync(policy.OrganizationId));
|
||||||
|
}
|
||||||
|
}
|
@ -64,20 +64,27 @@ public class OrganizationUserResponseModel : ResponseModel
|
|||||||
|
|
||||||
public class OrganizationUserDetailsResponseModel : OrganizationUserResponseModel
|
public class OrganizationUserDetailsResponseModel : OrganizationUserResponseModel
|
||||||
{
|
{
|
||||||
public OrganizationUserDetailsResponseModel(OrganizationUser organizationUser,
|
public OrganizationUserDetailsResponseModel(
|
||||||
|
OrganizationUser organizationUser,
|
||||||
|
bool managedByOrganization,
|
||||||
IEnumerable<CollectionAccessSelection> collections)
|
IEnumerable<CollectionAccessSelection> collections)
|
||||||
: base(organizationUser, "organizationUserDetails")
|
: base(organizationUser, "organizationUserDetails")
|
||||||
{
|
{
|
||||||
|
ManagedByOrganization = managedByOrganization;
|
||||||
Collections = collections.Select(c => new SelectionReadOnlyResponseModel(c));
|
Collections = collections.Select(c => new SelectionReadOnlyResponseModel(c));
|
||||||
}
|
}
|
||||||
|
|
||||||
public OrganizationUserDetailsResponseModel(OrganizationUserUserDetails organizationUser,
|
public OrganizationUserDetailsResponseModel(OrganizationUserUserDetails organizationUser,
|
||||||
|
bool managedByOrganization,
|
||||||
IEnumerable<CollectionAccessSelection> collections)
|
IEnumerable<CollectionAccessSelection> collections)
|
||||||
: base(organizationUser, "organizationUserDetails")
|
: base(organizationUser, "organizationUserDetails")
|
||||||
{
|
{
|
||||||
|
ManagedByOrganization = managedByOrganization;
|
||||||
Collections = collections.Select(c => new SelectionReadOnlyResponseModel(c));
|
Collections = collections.Select(c => new SelectionReadOnlyResponseModel(c));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool ManagedByOrganization { get; set; }
|
||||||
|
|
||||||
public IEnumerable<SelectionReadOnlyResponseModel> Collections { get; set; }
|
public IEnumerable<SelectionReadOnlyResponseModel> Collections { get; set; }
|
||||||
|
|
||||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
@ -110,7 +117,7 @@ public class OrganizationUserUserMiniDetailsResponseModel : ResponseModel
|
|||||||
public class OrganizationUserUserDetailsResponseModel : OrganizationUserResponseModel
|
public class OrganizationUserUserDetailsResponseModel : OrganizationUserResponseModel
|
||||||
{
|
{
|
||||||
public OrganizationUserUserDetailsResponseModel(OrganizationUserUserDetails organizationUser,
|
public OrganizationUserUserDetailsResponseModel(OrganizationUserUserDetails organizationUser,
|
||||||
bool twoFactorEnabled, string obj = "organizationUserUserDetails")
|
bool twoFactorEnabled, bool managedByOrganization, string obj = "organizationUserUserDetails")
|
||||||
: base(organizationUser, obj)
|
: base(organizationUser, obj)
|
||||||
{
|
{
|
||||||
if (organizationUser == null)
|
if (organizationUser == null)
|
||||||
@ -127,6 +134,7 @@ public class OrganizationUserUserDetailsResponseModel : OrganizationUserResponse
|
|||||||
Groups = organizationUser.Groups;
|
Groups = organizationUser.Groups;
|
||||||
// Prevent reset password when using key connector.
|
// Prevent reset password when using key connector.
|
||||||
ResetPasswordEnrolled = ResetPasswordEnrolled && !organizationUser.UsesKeyConnector;
|
ResetPasswordEnrolled = ResetPasswordEnrolled && !organizationUser.UsesKeyConnector;
|
||||||
|
ManagedByOrganization = managedByOrganization;
|
||||||
}
|
}
|
||||||
|
|
||||||
public string Name { get; set; }
|
public string Name { get; set; }
|
||||||
@ -134,6 +142,11 @@ public class OrganizationUserUserDetailsResponseModel : OrganizationUserResponse
|
|||||||
public string AvatarColor { get; set; }
|
public string AvatarColor { get; set; }
|
||||||
public bool TwoFactorEnabled { get; set; }
|
public bool TwoFactorEnabled { get; set; }
|
||||||
public bool SsoBound { get; set; }
|
public bool SsoBound { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Indicates if the organization manages the user. If a user is "managed" by an organization,
|
||||||
|
/// the organization has greater control over their account, and some user actions are restricted.
|
||||||
|
/// </summary>
|
||||||
|
public bool ManagedByOrganization { get; set; }
|
||||||
public IEnumerable<SelectionReadOnlyResponseModel> Collections { get; set; }
|
public IEnumerable<SelectionReadOnlyResponseModel> Collections { get; set; }
|
||||||
public IEnumerable<Guid> Groups { get; set; }
|
public IEnumerable<Guid> Groups { get; set; }
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,20 @@
|
|||||||
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
|
||||||
|
namespace Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||||
|
|
||||||
|
public class PolicyDetailResponseModel : PolicyResponseModel
|
||||||
|
{
|
||||||
|
public PolicyDetailResponseModel(Policy policy, string obj = "policy") : base(policy, obj)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public PolicyDetailResponseModel(Policy policy, bool canToggleState) : base(policy)
|
||||||
|
{
|
||||||
|
CanToggleState = canToggleState;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Indicates whether the Policy can be enabled/disabled
|
||||||
|
/// </summary>
|
||||||
|
public bool CanToggleState { get; set; } = true;
|
||||||
|
}
|
@ -3,7 +3,7 @@ using Bit.Core.AdminConsole.Entities;
|
|||||||
using Bit.Core.AdminConsole.Enums;
|
using Bit.Core.AdminConsole.Enums;
|
||||||
using Bit.Core.Models.Api;
|
using Bit.Core.Models.Api;
|
||||||
|
|
||||||
namespace Bit.Core.AdminConsole.Models.Api.Response;
|
namespace Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||||
|
|
||||||
public class PolicyResponseModel : ResponseModel
|
public class PolicyResponseModel : ResponseModel
|
||||||
{
|
{
|
@ -71,14 +71,13 @@ public class MembersController : Controller
|
|||||||
[ProducesResponseType((int)HttpStatusCode.NotFound)]
|
[ProducesResponseType((int)HttpStatusCode.NotFound)]
|
||||||
public async Task<IActionResult> Get(Guid id)
|
public async Task<IActionResult> Get(Guid id)
|
||||||
{
|
{
|
||||||
var userDetails = await _organizationUserRepository.GetDetailsByIdWithCollectionsAsync(id);
|
var (orgUser, collections) = await _organizationUserRepository.GetDetailsByIdWithCollectionsAsync(id);
|
||||||
var orgUser = userDetails?.Item1;
|
|
||||||
if (orgUser == null || orgUser.OrganizationId != _currentContext.OrganizationId)
|
if (orgUser == null || orgUser.OrganizationId != _currentContext.OrganizationId)
|
||||||
{
|
{
|
||||||
return new NotFoundResult();
|
return new NotFoundResult();
|
||||||
}
|
}
|
||||||
var response = new MemberResponseModel(orgUser, await _userService.TwoFactorIsEnabledAsync(orgUser),
|
var response = new MemberResponseModel(orgUser, await _userService.TwoFactorIsEnabledAsync(orgUser),
|
||||||
userDetails.Item2);
|
collections);
|
||||||
return new JsonResult(response);
|
return new JsonResult(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -41,14 +41,13 @@ public class PoliciesController : Controller
|
|||||||
[ProducesResponseType((int)HttpStatusCode.NotFound)]
|
[ProducesResponseType((int)HttpStatusCode.NotFound)]
|
||||||
public async Task<IActionResult> Get(PolicyType type)
|
public async Task<IActionResult> Get(PolicyType type)
|
||||||
{
|
{
|
||||||
var policy = await _policyRepository.GetByOrganizationIdTypeAsync(
|
var policy = await _policyRepository.GetByOrganizationIdTypeAsync(_currentContext.OrganizationId.Value, type);
|
||||||
_currentContext.OrganizationId.Value, type);
|
|
||||||
if (policy == null)
|
if (policy == null)
|
||||||
{
|
{
|
||||||
return new NotFoundResult();
|
return new NotFoundResult();
|
||||||
}
|
}
|
||||||
var response = new PolicyResponseModel(policy);
|
|
||||||
return new JsonResult(response);
|
return new JsonResult(new PolicyResponseModel(policy));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -62,9 +61,8 @@ public class PoliciesController : Controller
|
|||||||
public async Task<IActionResult> List()
|
public async Task<IActionResult> List()
|
||||||
{
|
{
|
||||||
var policies = await _policyRepository.GetManyByOrganizationIdAsync(_currentContext.OrganizationId.Value);
|
var policies = await _policyRepository.GetManyByOrganizationIdAsync(_currentContext.OrganizationId.Value);
|
||||||
var policyResponses = policies.Select(p => new PolicyResponseModel(p));
|
|
||||||
var response = new ListResponseModel<PolicyResponseModel>(policyResponses);
|
return new JsonResult(new ListResponseModel<PolicyResponseModel>(policies.Select(p => new PolicyResponseModel(p))));
|
||||||
return new JsonResult(response);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
@ -18,7 +18,6 @@ public abstract class MemberBaseModel
|
|||||||
|
|
||||||
Type = user.Type;
|
Type = user.Type;
|
||||||
ExternalId = user.ExternalId;
|
ExternalId = user.ExternalId;
|
||||||
ResetPasswordEnrolled = user.ResetPasswordKey != null;
|
|
||||||
|
|
||||||
if (Type == OrganizationUserType.Custom)
|
if (Type == OrganizationUserType.Custom)
|
||||||
{
|
{
|
||||||
@ -35,7 +34,6 @@ public abstract class MemberBaseModel
|
|||||||
|
|
||||||
Type = user.Type;
|
Type = user.Type;
|
||||||
ExternalId = user.ExternalId;
|
ExternalId = user.ExternalId;
|
||||||
ResetPasswordEnrolled = user.ResetPasswordKey != null;
|
|
||||||
|
|
||||||
if (Type == OrganizationUserType.Custom)
|
if (Type == OrganizationUserType.Custom)
|
||||||
{
|
{
|
||||||
@ -55,11 +53,7 @@ public abstract class MemberBaseModel
|
|||||||
/// <example>external_id_123456</example>
|
/// <example>external_id_123456</example>
|
||||||
[StringLength(300)]
|
[StringLength(300)]
|
||||||
public string ExternalId { get; set; }
|
public string ExternalId { get; set; }
|
||||||
/// <summary>
|
|
||||||
/// Returns <c>true</c> if the member has enrolled in Password Reset assistance within the organization
|
|
||||||
/// </summary>
|
|
||||||
[Required]
|
|
||||||
public bool ResetPasswordEnrolled { get; set; }
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The member's custom permissions if the member has a Custom role. If not supplied, all custom permissions will
|
/// The member's custom permissions if the member has a Custom role. If not supplied, all custom permissions will
|
||||||
/// default to false.
|
/// default to false.
|
||||||
|
@ -28,6 +28,7 @@ public class MemberResponseModel : MemberBaseModel, IResponseModel
|
|||||||
Email = user.Email;
|
Email = user.Email;
|
||||||
Status = user.Status;
|
Status = user.Status;
|
||||||
Collections = collections?.Select(c => new AssociationWithPermissionsResponseModel(c));
|
Collections = collections?.Select(c => new AssociationWithPermissionsResponseModel(c));
|
||||||
|
ResetPasswordEnrolled = user.ResetPasswordKey != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public MemberResponseModel(OrganizationUserUserDetails user, bool twoFactorEnabled,
|
public MemberResponseModel(OrganizationUserUserDetails user, bool twoFactorEnabled,
|
||||||
@ -45,6 +46,7 @@ public class MemberResponseModel : MemberBaseModel, IResponseModel
|
|||||||
TwoFactorEnabled = twoFactorEnabled;
|
TwoFactorEnabled = twoFactorEnabled;
|
||||||
Status = user.Status;
|
Status = user.Status;
|
||||||
Collections = collections?.Select(c => new AssociationWithPermissionsResponseModel(c));
|
Collections = collections?.Select(c => new AssociationWithPermissionsResponseModel(c));
|
||||||
|
ResetPasswordEnrolled = user.ResetPasswordKey != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -93,4 +95,10 @@ public class MemberResponseModel : MemberBaseModel, IResponseModel
|
|||||||
/// The associated collections that this member can access.
|
/// The associated collections that this member can access.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public IEnumerable<AssociationWithPermissionsResponseModel> Collections { get; set; }
|
public IEnumerable<AssociationWithPermissionsResponseModel> Collections { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns <c>true</c> if the member has enrolled in Password Reset assistance within the organization
|
||||||
|
/// </summary>
|
||||||
|
[Required]
|
||||||
|
public bool ResetPasswordEnrolled { get; }
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<UserSecretsId>bitwarden-Api</UserSecretsId>
|
<UserSecretsId>bitwarden-Api</UserSecretsId>
|
||||||
<MvcRazorCompileOnPublish>false</MvcRazorCompileOnPublish>
|
<MvcRazorCompileOnPublish>false</MvcRazorCompileOnPublish>
|
||||||
@ -35,7 +34,7 @@
|
|||||||
<PackageReference Include="AspNetCore.HealthChecks.SqlServer" Version="8.0.2" />
|
<PackageReference Include="AspNetCore.HealthChecks.SqlServer" Version="8.0.2" />
|
||||||
<PackageReference Include="AspNetCore.HealthChecks.Uris" Version="8.0.1" />
|
<PackageReference Include="AspNetCore.HealthChecks.Uris" Version="8.0.1" />
|
||||||
<PackageReference Include="Azure.Messaging.EventGrid" Version="4.25.0" />
|
<PackageReference Include="Azure.Messaging.EventGrid" Version="4.25.0" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.8.1" />
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.9.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
@ -148,6 +148,13 @@ public class AccountsController : Controller
|
|||||||
throw new BadRequestException("MasterPasswordHash", "Invalid password.");
|
throw new BadRequestException("MasterPasswordHash", "Invalid password.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If Account Deprovisioning is enabled, we need to check if the user is managed by any organization.
|
||||||
|
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
|
||||||
|
&& await _userService.IsManagedByAnyOrganizationAsync(user.Id))
|
||||||
|
{
|
||||||
|
throw new BadRequestException("Cannot change emails for accounts owned by an organization. Contact your organization administrator for additional details.");
|
||||||
|
}
|
||||||
|
|
||||||
await _userService.InitiateEmailChangeAsync(user, model.NewEmail);
|
await _userService.InitiateEmailChangeAsync(user, model.NewEmail);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -165,6 +172,13 @@ public class AccountsController : Controller
|
|||||||
throw new BadRequestException("You cannot change your email when using Key Connector.");
|
throw new BadRequestException("You cannot change your email when using Key Connector.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If Account Deprovisioning is enabled, we need to check if the user is managed by any organization.
|
||||||
|
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
|
||||||
|
&& await _userService.IsManagedByAnyOrganizationAsync(user.Id))
|
||||||
|
{
|
||||||
|
throw new BadRequestException("Cannot change emails for accounts owned by an organization. Contact your organization administrator for additional details.");
|
||||||
|
}
|
||||||
|
|
||||||
var result = await _userService.ChangeEmailAsync(user, model.MasterPasswordHash, model.NewEmail,
|
var result = await _userService.ChangeEmailAsync(user, model.MasterPasswordHash, model.NewEmail,
|
||||||
model.NewMasterPasswordHash, model.Token, model.Key);
|
model.NewMasterPasswordHash, model.Token, model.Key);
|
||||||
if (result.Succeeded)
|
if (result.Succeeded)
|
||||||
@ -566,6 +580,13 @@ public class AccountsController : Controller
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
// If Account Deprovisioning is enabled, we need to check if the user is managed by any organization.
|
||||||
|
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
|
||||||
|
&& await _userService.IsManagedByAnyOrganizationAsync(user.Id))
|
||||||
|
{
|
||||||
|
throw new BadRequestException("Cannot delete accounts owned by an organization. Contact your organization administrator for additional details.");
|
||||||
|
}
|
||||||
|
|
||||||
var result = await _userService.DeleteAsync(user);
|
var result = await _userService.DeleteAsync(user);
|
||||||
if (result.Succeeded)
|
if (result.Succeeded)
|
||||||
{
|
{
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
using Bit.Api.AdminConsole.Models.Request.Organizations;
|
using Bit.Api.AdminConsole.Models.Request.Organizations;
|
||||||
|
using Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||||
using Bit.Api.Auth.Models.Request;
|
using Bit.Api.Auth.Models.Request;
|
||||||
using Bit.Api.Auth.Models.Response;
|
using Bit.Api.Auth.Models.Response;
|
||||||
using Bit.Api.Models.Response;
|
using Bit.Api.Models.Response;
|
||||||
using Bit.Api.Vault.Models.Response;
|
using Bit.Api.Vault.Models.Response;
|
||||||
using Bit.Core.AdminConsole.Entities;
|
using Bit.Core.AdminConsole.Entities;
|
||||||
using Bit.Core.AdminConsole.Models.Api.Response;
|
|
||||||
using Bit.Core.Auth.Services;
|
using Bit.Core.Auth.Services;
|
||||||
using Bit.Core.Exceptions;
|
using Bit.Core.Exceptions;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
|
@ -22,9 +22,9 @@ public abstract class BaseBillingController : Controller
|
|||||||
new ErrorResponseModel(message),
|
new ErrorResponseModel(message),
|
||||||
statusCode: StatusCodes.Status500InternalServerError);
|
statusCode: StatusCodes.Status500InternalServerError);
|
||||||
|
|
||||||
public static JsonHttpResult<ErrorResponseModel> Unauthorized() =>
|
public static JsonHttpResult<ErrorResponseModel> Unauthorized(string message = "Unauthorized.") =>
|
||||||
TypedResults.Json(
|
TypedResults.Json(
|
||||||
new ErrorResponseModel("Unauthorized."),
|
new ErrorResponseModel(message),
|
||||||
statusCode: StatusCodes.Status401Unauthorized);
|
statusCode: StatusCodes.Status401Unauthorized);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -26,7 +26,7 @@ public class OrganizationBillingController(
|
|||||||
[HttpGet("metadata")]
|
[HttpGet("metadata")]
|
||||||
public async Task<IResult> GetMetadataAsync([FromRoute] Guid organizationId)
|
public async Task<IResult> GetMetadataAsync([FromRoute] Guid organizationId)
|
||||||
{
|
{
|
||||||
if (!await currentContext.AccessMembersTab(organizationId))
|
if (!await currentContext.OrganizationUser(organizationId))
|
||||||
{
|
{
|
||||||
return Error.Unauthorized();
|
return Error.Unauthorized();
|
||||||
}
|
}
|
||||||
|
@ -93,7 +93,8 @@ public class ProviderBillingController(
|
|||||||
subscription,
|
subscription,
|
||||||
providerPlans,
|
providerPlans,
|
||||||
taxInformation,
|
taxInformation,
|
||||||
subscriptionSuspension);
|
subscriptionSuspension,
|
||||||
|
provider);
|
||||||
|
|
||||||
return TypedResults.Ok(response);
|
return TypedResults.Ok(response);
|
||||||
}
|
}
|
||||||
|
@ -102,15 +102,27 @@ public class ProviderClientsController(
|
|||||||
|
|
||||||
var clientOrganization = await organizationRepository.GetByIdAsync(providerOrganization.OrganizationId);
|
var clientOrganization = await organizationRepository.GetByIdAsync(providerOrganization.OrganizationId);
|
||||||
|
|
||||||
if (clientOrganization.Seats != requestBody.AssignedSeats)
|
if (clientOrganization is not { Status: OrganizationStatusType.Managed })
|
||||||
{
|
{
|
||||||
await providerBillingService.AssignSeatsToClientOrganization(
|
return Error.ServerError();
|
||||||
provider,
|
|
||||||
clientOrganization,
|
|
||||||
requestBody.AssignedSeats);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var seatAdjustment = requestBody.AssignedSeats - (clientOrganization.Seats ?? 0);
|
||||||
|
|
||||||
|
var seatAdjustmentResultsInPurchase = await providerBillingService.SeatAdjustmentResultsInPurchase(
|
||||||
|
provider,
|
||||||
|
clientOrganization.PlanType,
|
||||||
|
seatAdjustment);
|
||||||
|
|
||||||
|
if (seatAdjustmentResultsInPurchase && !currentContext.ProviderProviderAdmin(provider.Id))
|
||||||
|
{
|
||||||
|
return Error.Unauthorized("Service users cannot purchase additional seats.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await providerBillingService.ScaleSeats(provider, clientOrganization.PlanType, seatAdjustment);
|
||||||
|
|
||||||
clientOrganization.Name = requestBody.Name;
|
clientOrganization.Name = requestBody.Name;
|
||||||
|
clientOrganization.Seats = requestBody.AssignedSeats;
|
||||||
|
|
||||||
await organizationRepository.ReplaceAsync(clientOrganization);
|
await organizationRepository.ReplaceAsync(clientOrganization);
|
||||||
|
|
||||||
|
@ -12,7 +12,7 @@ public class CreateClientOrganizationRequestBody
|
|||||||
[Required(ErrorMessage = "'ownerEmail' must be provided")]
|
[Required(ErrorMessage = "'ownerEmail' must be provided")]
|
||||||
public string OwnerEmail { get; set; }
|
public string OwnerEmail { get; set; }
|
||||||
|
|
||||||
[EnumMatches<PlanType>(PlanType.TeamsMonthly, PlanType.EnterpriseMonthly, ErrorMessage = "'planType' must be Teams (Monthly) or Enterprise (Monthly)")]
|
[EnumMatches<PlanType>(PlanType.TeamsMonthly, PlanType.EnterpriseMonthly, PlanType.EnterpriseAnnually, ErrorMessage = "'planType' must be Teams (Monthly), Enterprise (Monthly) or Enterprise (Annually)")]
|
||||||
public PlanType PlanType { get; set; }
|
public PlanType PlanType { get; set; }
|
||||||
|
|
||||||
[Range(1, int.MaxValue, ErrorMessage = "'seats' must be greater than 0")]
|
[Range(1, int.MaxValue, ErrorMessage = "'seats' must be greater than 0")]
|
||||||
|
@ -4,10 +4,16 @@ namespace Bit.Api.Billing.Models.Responses;
|
|||||||
|
|
||||||
public record OrganizationMetadataResponse(
|
public record OrganizationMetadataResponse(
|
||||||
bool IsEligibleForSelfHost,
|
bool IsEligibleForSelfHost,
|
||||||
bool IsOnSecretsManagerStandalone)
|
bool IsManaged,
|
||||||
|
bool IsOnSecretsManagerStandalone,
|
||||||
|
bool IsSubscriptionUnpaid,
|
||||||
|
bool HasSubscription)
|
||||||
{
|
{
|
||||||
public static OrganizationMetadataResponse From(OrganizationMetadata metadata)
|
public static OrganizationMetadataResponse From(OrganizationMetadata metadata)
|
||||||
=> new(
|
=> new(
|
||||||
metadata.IsEligibleForSelfHost,
|
metadata.IsEligibleForSelfHost,
|
||||||
metadata.IsOnSecretsManagerStandalone);
|
metadata.IsManaged,
|
||||||
|
metadata.IsOnSecretsManagerStandalone,
|
||||||
|
metadata.IsSubscriptionUnpaid,
|
||||||
|
metadata.HasSubscription);
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,7 @@
|
|||||||
using Bit.Core.Billing.Entities;
|
using Bit.Core.AdminConsole.Entities.Provider;
|
||||||
|
using Bit.Core.AdminConsole.Enums.Provider;
|
||||||
|
using Bit.Core.Billing.Entities;
|
||||||
|
using Bit.Core.Billing.Enums;
|
||||||
using Bit.Core.Billing.Models;
|
using Bit.Core.Billing.Models;
|
||||||
using Bit.Core.Utilities;
|
using Bit.Core.Utilities;
|
||||||
using Stripe;
|
using Stripe;
|
||||||
@ -14,7 +17,8 @@ public record ProviderSubscriptionResponse(
|
|||||||
decimal AccountCredit,
|
decimal AccountCredit,
|
||||||
TaxInformation TaxInformation,
|
TaxInformation TaxInformation,
|
||||||
DateTime? CancelAt,
|
DateTime? CancelAt,
|
||||||
SubscriptionSuspension Suspension)
|
SubscriptionSuspension Suspension,
|
||||||
|
ProviderType ProviderType)
|
||||||
{
|
{
|
||||||
private const string _annualCadence = "Annual";
|
private const string _annualCadence = "Annual";
|
||||||
private const string _monthlyCadence = "Monthly";
|
private const string _monthlyCadence = "Monthly";
|
||||||
@ -23,7 +27,8 @@ public record ProviderSubscriptionResponse(
|
|||||||
Subscription subscription,
|
Subscription subscription,
|
||||||
ICollection<ProviderPlan> providerPlans,
|
ICollection<ProviderPlan> providerPlans,
|
||||||
TaxInformation taxInformation,
|
TaxInformation taxInformation,
|
||||||
SubscriptionSuspension subscriptionSuspension)
|
SubscriptionSuspension subscriptionSuspension,
|
||||||
|
Provider provider)
|
||||||
{
|
{
|
||||||
var providerPlanResponses = providerPlans
|
var providerPlanResponses = providerPlans
|
||||||
.Where(providerPlan => providerPlan.IsConfigured())
|
.Where(providerPlan => providerPlan.IsConfigured())
|
||||||
@ -35,6 +40,8 @@ public record ProviderSubscriptionResponse(
|
|||||||
var cadence = plan.IsAnnual ? _annualCadence : _monthlyCadence;
|
var cadence = plan.IsAnnual ? _annualCadence : _monthlyCadence;
|
||||||
return new ProviderPlanResponse(
|
return new ProviderPlanResponse(
|
||||||
plan.Name,
|
plan.Name,
|
||||||
|
plan.Type,
|
||||||
|
plan.ProductTier,
|
||||||
configuredProviderPlan.SeatMinimum,
|
configuredProviderPlan.SeatMinimum,
|
||||||
configuredProviderPlan.PurchasedSeats,
|
configuredProviderPlan.PurchasedSeats,
|
||||||
configuredProviderPlan.AssignedSeats,
|
configuredProviderPlan.AssignedSeats,
|
||||||
@ -53,12 +60,15 @@ public record ProviderSubscriptionResponse(
|
|||||||
accountCredit,
|
accountCredit,
|
||||||
taxInformation,
|
taxInformation,
|
||||||
subscription.CancelAt,
|
subscription.CancelAt,
|
||||||
subscriptionSuspension);
|
subscriptionSuspension,
|
||||||
|
provider.Type);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public record ProviderPlanResponse(
|
public record ProviderPlanResponse(
|
||||||
string PlanName,
|
string PlanName,
|
||||||
|
PlanType Type,
|
||||||
|
ProductTierType ProductTier,
|
||||||
int SeatMinimum,
|
int SeatMinimum,
|
||||||
int PurchasedSeats,
|
int PurchasedSeats,
|
||||||
int AssignedSeats,
|
int AssignedSeats,
|
||||||
|
@ -196,8 +196,8 @@ public class DevicesController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{id}")]
|
[HttpDelete("{id}")]
|
||||||
[HttpPost("{id}/delete")]
|
[HttpPost("{id}/deactivate")]
|
||||||
public async Task Delete(string id)
|
public async Task Deactivate(string id)
|
||||||
{
|
{
|
||||||
var device = await _deviceRepository.GetByIdAsync(new Guid(id), _userService.GetProperUserId(User).Value);
|
var device = await _deviceRepository.GetByIdAsync(new Guid(id), _userService.GetProperUserId(User).Value);
|
||||||
if (device == null)
|
if (device == null)
|
||||||
@ -205,7 +205,7 @@ public class DevicesController : Controller
|
|||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
}
|
}
|
||||||
|
|
||||||
await _deviceService.DeleteAsync(device);
|
await _deviceService.DeactivateAsync(device);
|
||||||
}
|
}
|
||||||
|
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
|
@ -32,6 +32,8 @@ using Bit.Core.Tools.Entities;
|
|||||||
using Bit.Core.Vault.Entities;
|
using Bit.Core.Vault.Entities;
|
||||||
using Bit.Api.Auth.Models.Request.WebAuthn;
|
using Bit.Api.Auth.Models.Request.WebAuthn;
|
||||||
using Bit.Core.Auth.Models.Data;
|
using Bit.Core.Auth.Models.Data;
|
||||||
|
using Bit.Core.Tools.ReportFeatures;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
#if !OSS
|
#if !OSS
|
||||||
@ -176,6 +178,7 @@ public class Startup
|
|||||||
services.AddOrganizationSubscriptionServices();
|
services.AddOrganizationSubscriptionServices();
|
||||||
services.AddCoreLocalizationServices();
|
services.AddCoreLocalizationServices();
|
||||||
services.AddBillingOperations();
|
services.AddBillingOperations();
|
||||||
|
services.AddReportingServices();
|
||||||
|
|
||||||
// Authorization Handlers
|
// Authorization Handlers
|
||||||
services.AddAuthorizationHandlers();
|
services.AddAuthorizationHandlers();
|
||||||
|
@ -1,13 +1,13 @@
|
|||||||
using Bit.Api.Tools.Models.Response;
|
using Bit.Api.Tools.Models;
|
||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Api.Tools.Models.Response;
|
||||||
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
|
||||||
using Bit.Core.Context;
|
using Bit.Core.Context;
|
||||||
using Bit.Core.Exceptions;
|
using Bit.Core.Exceptions;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Tools.Entities;
|
||||||
using Bit.Core.Services;
|
using Bit.Core.Tools.Models.Data;
|
||||||
using Bit.Core.Vault.Queries;
|
using Bit.Core.Tools.ReportFeatures.Interfaces;
|
||||||
using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
using Bit.Core.Tools.ReportFeatures.OrganizationReportMembers.Interfaces;
|
||||||
using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests;
|
using Bit.Core.Tools.ReportFeatures.Requests;
|
||||||
|
using Bit.Core.Tools.Requests;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
@ -17,33 +17,55 @@ namespace Bit.Api.Tools.Controllers;
|
|||||||
[Authorize("Application")]
|
[Authorize("Application")]
|
||||||
public class ReportsController : Controller
|
public class ReportsController : Controller
|
||||||
{
|
{
|
||||||
private readonly IOrganizationUserUserDetailsQuery _organizationUserUserDetailsQuery;
|
|
||||||
private readonly IGroupRepository _groupRepository;
|
|
||||||
private readonly ICollectionRepository _collectionRepository;
|
|
||||||
private readonly ICurrentContext _currentContext;
|
private readonly ICurrentContext _currentContext;
|
||||||
private readonly IOrganizationCiphersQuery _organizationCiphersQuery;
|
private readonly IMemberAccessCipherDetailsQuery _memberAccessCipherDetailsQuery;
|
||||||
private readonly IApplicationCacheService _applicationCacheService;
|
private readonly IAddPasswordHealthReportApplicationCommand _addPwdHealthReportAppCommand;
|
||||||
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
|
private readonly IGetPasswordHealthReportApplicationQuery _getPwdHealthReportAppQuery;
|
||||||
|
|
||||||
public ReportsController(
|
public ReportsController(
|
||||||
IOrganizationUserUserDetailsQuery organizationUserUserDetailsQuery,
|
|
||||||
IGroupRepository groupRepository,
|
|
||||||
ICollectionRepository collectionRepository,
|
|
||||||
ICurrentContext currentContext,
|
ICurrentContext currentContext,
|
||||||
IOrganizationCiphersQuery organizationCiphersQuery,
|
IMemberAccessCipherDetailsQuery memberAccessCipherDetailsQuery,
|
||||||
IApplicationCacheService applicationCacheService,
|
IAddPasswordHealthReportApplicationCommand addPasswordHealthReportApplicationCommand,
|
||||||
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery
|
IGetPasswordHealthReportApplicationQuery getPasswordHealthReportApplicationQuery
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
_organizationUserUserDetailsQuery = organizationUserUserDetailsQuery;
|
|
||||||
_groupRepository = groupRepository;
|
|
||||||
_collectionRepository = collectionRepository;
|
|
||||||
_currentContext = currentContext;
|
_currentContext = currentContext;
|
||||||
_organizationCiphersQuery = organizationCiphersQuery;
|
_memberAccessCipherDetailsQuery = memberAccessCipherDetailsQuery;
|
||||||
_applicationCacheService = applicationCacheService;
|
_addPwdHealthReportAppCommand = addPasswordHealthReportApplicationCommand;
|
||||||
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
|
_getPwdHealthReportAppQuery = getPasswordHealthReportApplicationQuery;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Organization member information containing a list of cipher ids
|
||||||
|
/// assigned
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="orgId">Organzation Id</param>
|
||||||
|
/// <returns>IEnumerable of MemberCipherDetailsResponseModel</returns>
|
||||||
|
/// <exception cref="NotFoundException">If Access reports permission is not assigned</exception>
|
||||||
|
[HttpGet("member-cipher-details/{orgId}")]
|
||||||
|
public async Task<IEnumerable<MemberCipherDetailsResponseModel>> GetMemberCipherDetails(Guid orgId)
|
||||||
|
{
|
||||||
|
// Using the AccessReports permission here until new permissions
|
||||||
|
// are needed for more control over reports
|
||||||
|
if (!await _currentContext.AccessReports(orgId))
|
||||||
|
{
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
var memberCipherDetails = await GetMemberCipherDetails(new MemberAccessCipherDetailsRequest { OrganizationId = orgId });
|
||||||
|
|
||||||
|
var responses = memberCipherDetails.Select(x => new MemberCipherDetailsResponseModel(x));
|
||||||
|
|
||||||
|
return responses;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Access details for an organization member. Includes the member information,
|
||||||
|
/// group collection assignment, and item counts
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="orgId">Organization Id</param>
|
||||||
|
/// <returns>IEnumerable of MemberAccessReportResponseModel</returns>
|
||||||
|
/// <exception cref="NotFoundException">If Access reports permission is not assigned</exception>
|
||||||
[HttpGet("member-access/{orgId}")]
|
[HttpGet("member-access/{orgId}")]
|
||||||
public async Task<IEnumerable<MemberAccessReportResponseModel>> GetMemberAccessReport(Guid orgId)
|
public async Task<IEnumerable<MemberAccessReportResponseModel>> GetMemberAccessReport(Guid orgId)
|
||||||
{
|
{
|
||||||
@ -52,26 +74,91 @@ public class ReportsController : Controller
|
|||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
}
|
}
|
||||||
|
|
||||||
var orgUsers = await _organizationUserUserDetailsQuery.GetOrganizationUserUserDetails(
|
var memberCipherDetails = await GetMemberCipherDetails(new MemberAccessCipherDetailsRequest { OrganizationId = orgId });
|
||||||
new OrganizationUserUserDetailsQueryRequest
|
|
||||||
{
|
|
||||||
OrganizationId = orgId,
|
|
||||||
IncludeCollections = true,
|
|
||||||
IncludeGroups = true
|
|
||||||
});
|
|
||||||
|
|
||||||
var orgGroups = await _groupRepository.GetManyByOrganizationIdAsync(orgId);
|
var responses = memberCipherDetails.Select(x => new MemberAccessReportResponseModel(x));
|
||||||
var orgAbility = await _applicationCacheService.GetOrganizationAbilityAsync(orgId);
|
|
||||||
var orgCollectionsWithAccess = await _collectionRepository.GetManyByOrganizationIdWithAccessAsync(orgId);
|
|
||||||
var orgItems = await _organizationCiphersQuery.GetAllOrganizationCiphers(orgId);
|
|
||||||
var organizationUsersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(orgUsers);
|
|
||||||
|
|
||||||
var reports = MemberAccessReportResponseModel.CreateReport(
|
return responses;
|
||||||
orgGroups,
|
}
|
||||||
orgCollectionsWithAccess,
|
|
||||||
orgItems,
|
/// <summary>
|
||||||
organizationUsersTwoFactorEnabled,
|
/// Contains the organization member info, the cipher ids associated with the member,
|
||||||
orgAbility);
|
/// and details on their collections, groups, and permissions
|
||||||
return reports;
|
/// </summary>
|
||||||
|
/// <param name="request">Request to the MemberAccessCipherDetailsQuery</param>
|
||||||
|
/// <returns>IEnumerable of MemberAccessCipherDetails</returns>
|
||||||
|
private async Task<IEnumerable<MemberAccessCipherDetails>> GetMemberCipherDetails(MemberAccessCipherDetailsRequest request)
|
||||||
|
{
|
||||||
|
var memberCipherDetails =
|
||||||
|
await _memberAccessCipherDetailsQuery.GetMemberAccessCipherDetails(request);
|
||||||
|
return memberCipherDetails;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get the password health report applications for an organization
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="orgId">A valid Organization Id</param>
|
||||||
|
/// <returns>An Enumerable of PasswordHealthReportApplication </returns>
|
||||||
|
/// <exception cref="NotFoundException">If the user lacks access</exception>
|
||||||
|
/// <exception cref="BadRequestException">If the organization Id is not valid</exception>
|
||||||
|
[HttpGet("password-health-report-applications/{orgId}")]
|
||||||
|
public async Task<IEnumerable<PasswordHealthReportApplication>> GetPasswordHealthReportApplications(Guid orgId)
|
||||||
|
{
|
||||||
|
if (!await _currentContext.AccessReports(orgId))
|
||||||
|
{
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return await _getPwdHealthReportAppQuery.GetPasswordHealthReportApplicationAsync(orgId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds a new record into PasswordHealthReportApplication
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">A single instance of PasswordHealthReportApplication Model</param>
|
||||||
|
/// <returns>A single instance of PasswordHealthReportApplication</returns>
|
||||||
|
/// <exception cref="BadRequestException">If the organization Id is not valid</exception>
|
||||||
|
/// <exception cref="NotFoundException">If the user lacks access</exception>
|
||||||
|
[HttpPost("password-health-report-application")]
|
||||||
|
public async Task<PasswordHealthReportApplication> AddPasswordHealthReportApplication(
|
||||||
|
[FromBody] PasswordHealthReportApplicationModel request)
|
||||||
|
{
|
||||||
|
if (!await _currentContext.AccessReports(request.OrganizationId))
|
||||||
|
{
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
var commandRequest = new AddPasswordHealthReportApplicationRequest
|
||||||
|
{
|
||||||
|
OrganizationId = request.OrganizationId,
|
||||||
|
Url = request.Url
|
||||||
|
};
|
||||||
|
|
||||||
|
return await _addPwdHealthReportAppCommand.AddPasswordHealthReportApplicationAsync(commandRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds multiple records into PasswordHealthReportApplication
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">A enumerable of PasswordHealthReportApplicationModel</param>
|
||||||
|
/// <returns>An Enumerable of PasswordHealthReportApplication</returns>
|
||||||
|
/// <exception cref="NotFoundException">If user does not have access to the OrganizationId</exception>
|
||||||
|
/// <exception cref="BadRequestException">If the organization Id is not valid</exception>
|
||||||
|
[HttpPost("password-health-report-applications")]
|
||||||
|
public async Task<IEnumerable<PasswordHealthReportApplication>> AddPasswordHealthReportApplications(
|
||||||
|
[FromBody] IEnumerable<PasswordHealthReportApplicationModel> request)
|
||||||
|
{
|
||||||
|
if (request.Any(_ => _currentContext.AccessReports(_.OrganizationId).Result == false))
|
||||||
|
{
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
var commandRequests = request.Select(request => new AddPasswordHealthReportApplicationRequest
|
||||||
|
{
|
||||||
|
OrganizationId = request.OrganizationId,
|
||||||
|
Url = request.Url
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
return await _addPwdHealthReportAppCommand.AddPasswordHealthReportApplicationAsync(commandRequests);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,7 @@
|
|||||||
|
namespace Bit.Api.Tools.Models;
|
||||||
|
|
||||||
|
public class PasswordHealthReportApplicationModel
|
||||||
|
{
|
||||||
|
public Guid OrganizationId { get; set; }
|
||||||
|
public string Url { get; set; }
|
||||||
|
}
|
@ -1,30 +1,7 @@
|
|||||||
using Bit.Core.AdminConsole.Entities;
|
using Bit.Core.Tools.Models.Data;
|
||||||
using Bit.Core.Entities;
|
|
||||||
using Bit.Core.Models.Data;
|
|
||||||
using Bit.Core.Models.Data.Organizations;
|
|
||||||
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
|
||||||
using Bit.Core.Vault.Models.Data;
|
|
||||||
|
|
||||||
namespace Bit.Api.Tools.Models.Response;
|
namespace Bit.Api.Tools.Models.Response;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Member access details. The individual item for the detailed member access
|
|
||||||
/// report. A collection can be assigned directly to a user without a group or
|
|
||||||
/// the user can be assigned to a collection through a group. Group level permissions
|
|
||||||
/// can override collection level permissions.
|
|
||||||
/// </summary>
|
|
||||||
public class MemberAccessReportAccessDetails
|
|
||||||
{
|
|
||||||
public Guid? CollectionId { get; set; }
|
|
||||||
public Guid? GroupId { get; set; }
|
|
||||||
public string GroupName { get; set; }
|
|
||||||
public string CollectionName { get; set; }
|
|
||||||
public int ItemCount { get; set; }
|
|
||||||
public bool? ReadOnly { get; set; }
|
|
||||||
public bool? HidePasswords { get; set; }
|
|
||||||
public bool? Manage { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Contains the collections and group collections a user has access to including
|
/// Contains the collections and group collections a user has access to including
|
||||||
/// the permission level for the collection and group collection.
|
/// the permission level for the collection and group collection.
|
||||||
@ -40,134 +17,18 @@ public class MemberAccessReportResponseModel
|
|||||||
public int TotalItemCount { get; set; }
|
public int TotalItemCount { get; set; }
|
||||||
public Guid? UserGuid { get; set; }
|
public Guid? UserGuid { get; set; }
|
||||||
public bool UsesKeyConnector { get; set; }
|
public bool UsesKeyConnector { get; set; }
|
||||||
public IEnumerable<MemberAccessReportAccessDetails> AccessDetails { get; set; }
|
public IEnumerable<MemberAccessDetails> AccessDetails { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
public MemberAccessReportResponseModel(MemberAccessCipherDetails memberAccessCipherDetails)
|
||||||
/// Generates a report for all members of an organization. Containing summary information
|
|
||||||
/// such as item, collection, and group counts. As well as detailed information on the
|
|
||||||
/// user and group collections along with their permissions
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="orgGroups">Organization groups collection</param>
|
|
||||||
/// <param name="orgCollectionsWithAccess">Collections for the organization and the groups/users and permissions</param>
|
|
||||||
/// <param name="orgItems">Cipher items for the organization with the collections associated with them</param>
|
|
||||||
/// <param name="organizationUsersTwoFactorEnabled">Organization users and two factor status</param>
|
|
||||||
/// <param name="orgAbility">Organization ability for account recovery status</param>
|
|
||||||
/// <returns>List of the MemberAccessReportResponseModel</returns>;
|
|
||||||
public static IEnumerable<MemberAccessReportResponseModel> CreateReport(
|
|
||||||
ICollection<Group> orgGroups,
|
|
||||||
ICollection<Tuple<Collection, CollectionAccessDetails>> orgCollectionsWithAccess,
|
|
||||||
IEnumerable<CipherOrganizationDetailsWithCollections> orgItems,
|
|
||||||
IEnumerable<(OrganizationUserUserDetails user, bool twoFactorIsEnabled)> organizationUsersTwoFactorEnabled,
|
|
||||||
OrganizationAbility orgAbility)
|
|
||||||
{
|
{
|
||||||
var orgUsers = organizationUsersTwoFactorEnabled.Select(x => x.user);
|
this.UserName = memberAccessCipherDetails.UserName;
|
||||||
// Create a dictionary to lookup the group names later.
|
this.Email = memberAccessCipherDetails.Email;
|
||||||
var groupNameDictionary = orgGroups.ToDictionary(x => x.Id, x => x.Name);
|
this.TwoFactorEnabled = memberAccessCipherDetails.TwoFactorEnabled;
|
||||||
|
this.AccountRecoveryEnabled = memberAccessCipherDetails.AccountRecoveryEnabled;
|
||||||
// Get collections grouped and into a dictionary for counts
|
this.GroupsCount = memberAccessCipherDetails.GroupsCount;
|
||||||
var collectionItems = orgItems
|
this.CollectionsCount = memberAccessCipherDetails.CollectionsCount;
|
||||||
.SelectMany(x => x.CollectionIds,
|
this.TotalItemCount = memberAccessCipherDetails.TotalItemCount;
|
||||||
(x, b) => new { CipherId = x.Id, CollectionId = b })
|
this.UserGuid = memberAccessCipherDetails.UserGuid;
|
||||||
.GroupBy(y => y.CollectionId,
|
this.AccessDetails = memberAccessCipherDetails.AccessDetails;
|
||||||
(key, g) => new { CollectionId = key, Ciphers = g });
|
|
||||||
var collectionItemCounts = collectionItems.ToDictionary(x => x.CollectionId, x => x.Ciphers.Count());
|
|
||||||
|
|
||||||
|
|
||||||
// Loop through the org users and populate report and access data
|
|
||||||
var memberAccessReport = new List<MemberAccessReportResponseModel>();
|
|
||||||
foreach (var user in orgUsers)
|
|
||||||
{
|
|
||||||
// Take the collections/groups and create the access details items
|
|
||||||
var groupAccessDetails = new List<MemberAccessReportAccessDetails>();
|
|
||||||
var userCollectionAccessDetails = new List<MemberAccessReportAccessDetails>();
|
|
||||||
foreach (var tCollect in orgCollectionsWithAccess)
|
|
||||||
{
|
|
||||||
var itemCounts = collectionItemCounts.TryGetValue(tCollect.Item1.Id, out var itemCount) ? itemCount : 0;
|
|
||||||
if (tCollect.Item2.Groups.Count() > 0)
|
|
||||||
{
|
|
||||||
var groupDetails = tCollect.Item2.Groups.Where((tCollectGroups) => user.Groups.Contains(tCollectGroups.Id)).Select(x =>
|
|
||||||
new MemberAccessReportAccessDetails
|
|
||||||
{
|
|
||||||
CollectionId = tCollect.Item1.Id,
|
|
||||||
CollectionName = tCollect.Item1.Name,
|
|
||||||
GroupId = x.Id,
|
|
||||||
GroupName = groupNameDictionary[x.Id],
|
|
||||||
ReadOnly = x.ReadOnly,
|
|
||||||
HidePasswords = x.HidePasswords,
|
|
||||||
Manage = x.Manage,
|
|
||||||
ItemCount = itemCounts,
|
|
||||||
});
|
|
||||||
groupAccessDetails.AddRange(groupDetails);
|
|
||||||
}
|
|
||||||
|
|
||||||
// All collections assigned to users and their permissions
|
|
||||||
if (tCollect.Item2.Users.Count() > 0)
|
|
||||||
{
|
|
||||||
var userCollectionDetails = tCollect.Item2.Users.Where((tCollectUser) => tCollectUser.Id == user.Id).Select(x =>
|
|
||||||
new MemberAccessReportAccessDetails
|
|
||||||
{
|
|
||||||
CollectionId = tCollect.Item1.Id,
|
|
||||||
CollectionName = tCollect.Item1.Name,
|
|
||||||
ReadOnly = x.ReadOnly,
|
|
||||||
HidePasswords = x.HidePasswords,
|
|
||||||
Manage = x.Manage,
|
|
||||||
ItemCount = itemCounts,
|
|
||||||
});
|
|
||||||
userCollectionAccessDetails.AddRange(userCollectionDetails);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var report = new MemberAccessReportResponseModel
|
|
||||||
{
|
|
||||||
UserName = user.Name,
|
|
||||||
Email = user.Email,
|
|
||||||
TwoFactorEnabled = organizationUsersTwoFactorEnabled.FirstOrDefault(u => u.user.Id == user.Id).twoFactorIsEnabled,
|
|
||||||
// Both the user's ResetPasswordKey must be set and the organization can UseResetPassword
|
|
||||||
AccountRecoveryEnabled = !string.IsNullOrEmpty(user.ResetPasswordKey) && orgAbility.UseResetPassword,
|
|
||||||
UserGuid = user.Id,
|
|
||||||
UsesKeyConnector = user.UsesKeyConnector
|
|
||||||
};
|
|
||||||
|
|
||||||
var userAccessDetails = new List<MemberAccessReportAccessDetails>();
|
|
||||||
if (user.Groups.Any())
|
|
||||||
{
|
|
||||||
var userGroups = groupAccessDetails.Where(x => user.Groups.Contains(x.GroupId.GetValueOrDefault()));
|
|
||||||
userAccessDetails.AddRange(userGroups);
|
|
||||||
}
|
|
||||||
|
|
||||||
// There can be edge cases where groups don't have a collection
|
|
||||||
var groupsWithoutCollections = user.Groups.Where(x => !userAccessDetails.Any(y => x == y.GroupId));
|
|
||||||
if (groupsWithoutCollections.Count() > 0)
|
|
||||||
{
|
|
||||||
var emptyGroups = groupsWithoutCollections.Select(x => new MemberAccessReportAccessDetails
|
|
||||||
{
|
|
||||||
GroupId = x,
|
|
||||||
GroupName = groupNameDictionary[x],
|
|
||||||
ItemCount = 0
|
|
||||||
});
|
|
||||||
userAccessDetails.AddRange(emptyGroups);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.Collections.Any())
|
|
||||||
{
|
|
||||||
var userCollections = userCollectionAccessDetails.Where(x => user.Collections.Any(y => x.CollectionId == y.Id));
|
|
||||||
userAccessDetails.AddRange(userCollections);
|
|
||||||
}
|
|
||||||
report.AccessDetails = userAccessDetails;
|
|
||||||
|
|
||||||
report.TotalItemCount = collectionItems
|
|
||||||
.Where(x => report.AccessDetails.Any(y => x.CollectionId == y.CollectionId))
|
|
||||||
.SelectMany(x => x.Ciphers)
|
|
||||||
.GroupBy(g => g.CipherId).Select(grp => grp.FirstOrDefault())
|
|
||||||
.Count();
|
|
||||||
|
|
||||||
// Distinct items only
|
|
||||||
var distinctItems = report.AccessDetails.Where(x => x.CollectionId.HasValue).Select(x => x.CollectionId).Distinct();
|
|
||||||
report.CollectionsCount = distinctItems.Count();
|
|
||||||
report.GroupsCount = report.AccessDetails.Select(x => x.GroupId).Where(y => y.HasValue).Distinct().Count();
|
|
||||||
memberAccessReport.Add(report);
|
|
||||||
}
|
|
||||||
return memberAccessReport;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,24 @@
|
|||||||
|
using Bit.Core.Tools.Models.Data;
|
||||||
|
|
||||||
|
namespace Bit.Api.Tools.Models.Response;
|
||||||
|
|
||||||
|
public class MemberCipherDetailsResponseModel
|
||||||
|
{
|
||||||
|
public string UserName { get; set; }
|
||||||
|
public string Email { get; set; }
|
||||||
|
public bool UsesKeyConnector { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A distinct list of the cipher ids associated with
|
||||||
|
/// the organization member
|
||||||
|
/// </summary>
|
||||||
|
public IEnumerable<string> CipherIds { get; set; }
|
||||||
|
|
||||||
|
public MemberCipherDetailsResponseModel(MemberAccessCipherDetails memberAccessCipherDetails)
|
||||||
|
{
|
||||||
|
this.UserName = memberAccessCipherDetails.UserName;
|
||||||
|
this.Email = memberAccessCipherDetails.Email;
|
||||||
|
this.UsesKeyConnector = memberAccessCipherDetails.UsesKeyConnector;
|
||||||
|
this.CipherIds = memberAccessCipherDetails.CipherIds;
|
||||||
|
}
|
||||||
|
}
|
@ -1,5 +1,5 @@
|
|||||||
using Bit.Api.Vault.AuthorizationHandlers.Collections;
|
using Bit.Api.Vault.AuthorizationHandlers.Collections;
|
||||||
using Bit.Api.Vault.AuthorizationHandlers.Groups;
|
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Authorization;
|
||||||
using Bit.Core.IdentityServer;
|
using Bit.Core.IdentityServer;
|
||||||
using Bit.Core.Settings;
|
using Bit.Core.Settings;
|
||||||
using Bit.Core.Utilities;
|
using Bit.Core.Utilities;
|
||||||
|
@ -1,62 +0,0 @@
|
|||||||
#nullable enable
|
|
||||||
using Bit.Core.Context;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
|
|
||||||
namespace Bit.Api.Vault.AuthorizationHandlers.Groups;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Handles authorization logic for Group operations.
|
|
||||||
/// This uses new logic implemented in the Flexible Collections initiative.
|
|
||||||
/// </summary>
|
|
||||||
public class GroupAuthorizationHandler : AuthorizationHandler<GroupOperationRequirement>
|
|
||||||
{
|
|
||||||
private readonly ICurrentContext _currentContext;
|
|
||||||
|
|
||||||
public GroupAuthorizationHandler(ICurrentContext currentContext)
|
|
||||||
{
|
|
||||||
_currentContext = currentContext;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context,
|
|
||||||
GroupOperationRequirement requirement)
|
|
||||||
{
|
|
||||||
// Acting user is not authenticated, fail
|
|
||||||
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(GroupOperations.ReadAll):
|
|
||||||
await CanReadAllAsync(context, requirement, org);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task CanReadAllAsync(AuthorizationHandlerContext context, GroupOperationRequirement requirement,
|
|
||||||
CurrentContextOrganization? org)
|
|
||||||
{
|
|
||||||
// All users of an organization can read all groups belonging to the organization for collection access management
|
|
||||||
if (org is not null)
|
|
||||||
{
|
|
||||||
context.Succeed(requirement);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Allow provider users to read all groups 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.Groups;
|
|
||||||
|
|
||||||
public class GroupOperationRequirement : OperationAuthorizationRequirement
|
|
||||||
{
|
|
||||||
public Guid OrganizationId { get; init; }
|
|
||||||
|
|
||||||
public GroupOperationRequirement(string name, Guid organizationId)
|
|
||||||
{
|
|
||||||
Name = name;
|
|
||||||
OrganizationId = organizationId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static class GroupOperations
|
|
||||||
{
|
|
||||||
public static GroupOperationRequirement ReadAll(Guid organizationId)
|
|
||||||
{
|
|
||||||
return new GroupOperationRequirement(nameof(ReadAll), organizationId);
|
|
||||||
}
|
|
||||||
}
|
|
@ -99,7 +99,10 @@ public class CiphersController : Controller
|
|||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
}
|
}
|
||||||
|
|
||||||
return new CipherMiniResponseModel(cipher, _globalSettings, cipher.OrganizationUseTotp);
|
var collectionCiphers = await _collectionCipherRepository.GetManyByOrganizationIdAsync(cipher.OrganizationId.Value);
|
||||||
|
var collectionCiphersGroupDict = collectionCiphers.GroupBy(c => c.CipherId).ToDictionary(s => s.Key);
|
||||||
|
|
||||||
|
return new CipherMiniDetailsResponseModel(cipher, _globalSettings, collectionCiphersGroupDict, cipher.OrganizationUseTotp);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id}/full-details")]
|
[HttpGet("{id}/full-details")]
|
||||||
@ -600,10 +603,10 @@ public class CiphersController : Controller
|
|||||||
|
|
||||||
[HttpPut("{id}/collections-admin")]
|
[HttpPut("{id}/collections-admin")]
|
||||||
[HttpPost("{id}/collections-admin")]
|
[HttpPost("{id}/collections-admin")]
|
||||||
public async Task PutCollectionsAdmin(string id, [FromBody] CipherCollectionsRequestModel model)
|
public async Task<CipherMiniDetailsResponseModel> PutCollectionsAdmin(string id, [FromBody] CipherCollectionsRequestModel model)
|
||||||
{
|
{
|
||||||
var userId = _userService.GetProperUserId(User).Value;
|
var userId = _userService.GetProperUserId(User).Value;
|
||||||
var cipher = await _cipherRepository.GetByIdAsync(new Guid(id));
|
var cipher = await _cipherRepository.GetOrganizationDetailsByIdAsync(new Guid(id));
|
||||||
|
|
||||||
if (cipher == null || !cipher.OrganizationId.HasValue ||
|
if (cipher == null || !cipher.OrganizationId.HasValue ||
|
||||||
!await CanEditCipherAsAdminAsync(cipher.OrganizationId.Value, new[] { cipher.Id }))
|
!await CanEditCipherAsAdminAsync(cipher.OrganizationId.Value, new[] { cipher.Id }))
|
||||||
@ -621,6 +624,11 @@ public class CiphersController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
await _cipherService.SaveCollectionsAsync(cipher, collectionIds, userId, true);
|
await _cipherService.SaveCollectionsAsync(cipher, collectionIds, userId, true);
|
||||||
|
|
||||||
|
var collectionCiphers = await _collectionCipherRepository.GetManyByOrganizationIdAsync(cipher.OrganizationId.Value);
|
||||||
|
var collectionCiphersGroupDict = collectionCiphers.GroupBy(c => c.CipherId).ToDictionary(s => s.Key);
|
||||||
|
|
||||||
|
return new CipherMiniDetailsResponseModel(cipher, _globalSettings, collectionCiphersGroupDict, cipher.OrganizationUseTotp);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("bulk-collections")]
|
[HttpPost("bulk-collections")]
|
||||||
|
@ -1,7 +1,9 @@
|
|||||||
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;
|
||||||
|
using Bit.Core.Context;
|
||||||
using Bit.Core.Entities;
|
using Bit.Core.Entities;
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.Exceptions;
|
using Bit.Core.Exceptions;
|
||||||
@ -10,6 +12,7 @@ using Bit.Core.Repositories;
|
|||||||
using Bit.Core.Services;
|
using Bit.Core.Services;
|
||||||
using Bit.Core.Settings;
|
using Bit.Core.Settings;
|
||||||
using Bit.Core.Tools.Repositories;
|
using Bit.Core.Tools.Repositories;
|
||||||
|
using Bit.Core.Vault.Models.Data;
|
||||||
using Bit.Core.Vault.Repositories;
|
using Bit.Core.Vault.Repositories;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@ -30,6 +33,8 @@ 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 ICurrentContext _currentContext;
|
||||||
|
private readonly Version _sshKeyCipherMinimumVersion = new(Constants.SSHKeyCipherMinimumVersion);
|
||||||
private readonly IFeatureService _featureService;
|
private readonly IFeatureService _featureService;
|
||||||
|
|
||||||
public SyncController(
|
public SyncController(
|
||||||
@ -43,6 +48,7 @@ public class SyncController : Controller
|
|||||||
IPolicyRepository policyRepository,
|
IPolicyRepository policyRepository,
|
||||||
ISendRepository sendRepository,
|
ISendRepository sendRepository,
|
||||||
GlobalSettings globalSettings,
|
GlobalSettings globalSettings,
|
||||||
|
ICurrentContext currentContext,
|
||||||
IFeatureService featureService)
|
IFeatureService featureService)
|
||||||
{
|
{
|
||||||
_userService = userService;
|
_userService = userService;
|
||||||
@ -55,6 +61,7 @@ public class SyncController : Controller
|
|||||||
_policyRepository = policyRepository;
|
_policyRepository = policyRepository;
|
||||||
_sendRepository = sendRepository;
|
_sendRepository = sendRepository;
|
||||||
_globalSettings = globalSettings;
|
_globalSettings = globalSettings;
|
||||||
|
_currentContext = currentContext;
|
||||||
_featureService = featureService;
|
_featureService = featureService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -77,7 +84,8 @@ public class SyncController : Controller
|
|||||||
var hasEnabledOrgs = organizationUserDetails.Any(o => o.Enabled);
|
var hasEnabledOrgs = organizationUserDetails.Any(o => o.Enabled);
|
||||||
|
|
||||||
var folders = await _folderRepository.GetManyByUserIdAsync(user.Id);
|
var folders = await _folderRepository.GetManyByUserIdAsync(user.Id);
|
||||||
var ciphers = await _cipherRepository.GetManyByUserIdAsync(user.Id, withOrganizations: hasEnabledOrgs);
|
var allCiphers = await _cipherRepository.GetManyByUserIdAsync(user.Id, withOrganizations: hasEnabledOrgs);
|
||||||
|
var ciphers = FilterSSHKeys(allCiphers);
|
||||||
var sends = await _sendRepository.GetManyByUserIdAsync(user.Id);
|
var sends = await _sendRepository.GetManyByUserIdAsync(user.Id);
|
||||||
|
|
||||||
IEnumerable<CollectionDetails> collections = null;
|
IEnumerable<CollectionDetails> collections = null;
|
||||||
@ -101,4 +109,16 @@ public class SyncController : Controller
|
|||||||
folders, collections, ciphers, collectionCiphersGroupDict, excludeDomains, policies, sends);
|
folders, collections, ciphers, collectionCiphersGroupDict, excludeDomains, policies, sends);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ICollection<CipherDetails> FilterSSHKeys(ICollection<CipherDetails> ciphers)
|
||||||
|
{
|
||||||
|
if (_currentContext.ClientVersion >= _sshKeyCipherMinimumVersion)
|
||||||
|
{
|
||||||
|
return ciphers;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return ciphers.Where(c => c.Type != Core.Vault.Enums.CipherType.SSHKey).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
26
src/Api/Vault/Models/CipherSSHKeyModel.cs
Normal file
26
src/Api/Vault/Models/CipherSSHKeyModel.cs
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
using Bit.Core.Utilities;
|
||||||
|
using Bit.Core.Vault.Models.Data;
|
||||||
|
|
||||||
|
namespace Bit.Api.Vault.Models;
|
||||||
|
|
||||||
|
public class CipherSSHKeyModel
|
||||||
|
{
|
||||||
|
public CipherSSHKeyModel() { }
|
||||||
|
|
||||||
|
public CipherSSHKeyModel(CipherSSHKeyData data)
|
||||||
|
{
|
||||||
|
PrivateKey = data.PrivateKey;
|
||||||
|
PublicKey = data.PublicKey;
|
||||||
|
KeyFingerprint = data.KeyFingerprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
[EncryptedString]
|
||||||
|
[EncryptedStringLength(5000)]
|
||||||
|
public string PrivateKey { get; set; }
|
||||||
|
[EncryptedString]
|
||||||
|
[EncryptedStringLength(5000)]
|
||||||
|
public string PublicKey { get; set; }
|
||||||
|
[EncryptedString]
|
||||||
|
[EncryptedStringLength(1000)]
|
||||||
|
public string KeyFingerprint { get; set; }
|
||||||
|
}
|
@ -37,6 +37,7 @@ public class CipherRequestModel
|
|||||||
public CipherCardModel Card { get; set; }
|
public CipherCardModel Card { get; set; }
|
||||||
public CipherIdentityModel Identity { get; set; }
|
public CipherIdentityModel Identity { get; set; }
|
||||||
public CipherSecureNoteModel SecureNote { get; set; }
|
public CipherSecureNoteModel SecureNote { get; set; }
|
||||||
|
public CipherSSHKeyModel SSHKey { get; set; }
|
||||||
public DateTime? LastKnownRevisionDate { get; set; } = null;
|
public DateTime? LastKnownRevisionDate { get; set; } = null;
|
||||||
|
|
||||||
public CipherDetails ToCipherDetails(Guid userId, bool allowOrgIdSet = true)
|
public CipherDetails ToCipherDetails(Guid userId, bool allowOrgIdSet = true)
|
||||||
@ -82,6 +83,9 @@ public class CipherRequestModel
|
|||||||
case CipherType.SecureNote:
|
case CipherType.SecureNote:
|
||||||
existingCipher.Data = JsonSerializer.Serialize(ToCipherSecureNoteData(), JsonHelpers.IgnoreWritingNull);
|
existingCipher.Data = JsonSerializer.Serialize(ToCipherSecureNoteData(), JsonHelpers.IgnoreWritingNull);
|
||||||
break;
|
break;
|
||||||
|
case CipherType.SSHKey:
|
||||||
|
existingCipher.Data = JsonSerializer.Serialize(ToCipherSSHKeyData(), JsonHelpers.IgnoreWritingNull);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
throw new ArgumentException("Unsupported type: " + nameof(Type) + ".");
|
throw new ArgumentException("Unsupported type: " + nameof(Type) + ".");
|
||||||
}
|
}
|
||||||
@ -230,6 +234,21 @@ public class CipherRequestModel
|
|||||||
Type = SecureNote.Type,
|
Type = SecureNote.Type,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private CipherSSHKeyData ToCipherSSHKeyData()
|
||||||
|
{
|
||||||
|
return new CipherSSHKeyData
|
||||||
|
{
|
||||||
|
Name = Name,
|
||||||
|
Notes = Notes,
|
||||||
|
Fields = Fields?.Select(f => f.ToCipherFieldData()),
|
||||||
|
PasswordHistory = PasswordHistory?.Select(ph => ph.ToCipherPasswordHistoryData()),
|
||||||
|
|
||||||
|
PrivateKey = SSHKey.PrivateKey,
|
||||||
|
PublicKey = SSHKey.PublicKey,
|
||||||
|
KeyFingerprint = SSHKey.KeyFingerprint,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class CipherWithIdRequestModel : CipherRequestModel
|
public class CipherWithIdRequestModel : CipherRequestModel
|
||||||
|
@ -48,6 +48,12 @@ public class CipherMiniResponseModel : ResponseModel
|
|||||||
cipherData = identityData;
|
cipherData = identityData;
|
||||||
Identity = new CipherIdentityModel(identityData);
|
Identity = new CipherIdentityModel(identityData);
|
||||||
break;
|
break;
|
||||||
|
case CipherType.SSHKey:
|
||||||
|
var sshKeyData = JsonSerializer.Deserialize<CipherSSHKeyData>(cipher.Data);
|
||||||
|
Data = sshKeyData;
|
||||||
|
cipherData = sshKeyData;
|
||||||
|
SSHKey = new CipherSSHKeyModel(sshKeyData);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
throw new ArgumentException("Unsupported " + nameof(Type) + ".");
|
throw new ArgumentException("Unsupported " + nameof(Type) + ".");
|
||||||
}
|
}
|
||||||
@ -76,6 +82,7 @@ public class CipherMiniResponseModel : ResponseModel
|
|||||||
public CipherCardModel Card { get; set; }
|
public CipherCardModel Card { get; set; }
|
||||||
public CipherIdentityModel Identity { get; set; }
|
public CipherIdentityModel Identity { get; set; }
|
||||||
public CipherSecureNoteModel SecureNote { get; set; }
|
public CipherSecureNoteModel SecureNote { get; set; }
|
||||||
|
public CipherSSHKeyModel SSHKey { get; set; }
|
||||||
public IEnumerable<CipherFieldModel> Fields { get; set; }
|
public IEnumerable<CipherFieldModel> Fields { get; set; }
|
||||||
public IEnumerable<CipherPasswordHistoryModel> PasswordHistory { get; set; }
|
public IEnumerable<CipherPasswordHistoryModel> PasswordHistory { get; set; }
|
||||||
public IEnumerable<AttachmentResponseModel> Attachments { get; set; }
|
public IEnumerable<AttachmentResponseModel> Attachments { get; set; }
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
using Bit.Api.Models.Response;
|
using Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||||
|
using Bit.Api.Models.Response;
|
||||||
using Bit.Api.Tools.Models.Response;
|
using Bit.Api.Tools.Models.Response;
|
||||||
using Bit.Core.AdminConsole.Entities;
|
using Bit.Core.AdminConsole.Entities;
|
||||||
using Bit.Core.AdminConsole.Models.Api.Response;
|
|
||||||
using Bit.Core.AdminConsole.Models.Data.Provider;
|
using Bit.Core.AdminConsole.Models.Data.Provider;
|
||||||
using Bit.Core.Entities;
|
using Bit.Core.Entities;
|
||||||
using Bit.Core.Models.Api;
|
using Bit.Core.Models.Api;
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
using Bit.Billing.Constants;
|
using Bit.Billing.Constants;
|
||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
using Bit.Core.Billing.Entities;
|
using Bit.Core.Billing.Entities;
|
||||||
using Bit.Core.Billing.Enums;
|
|
||||||
using Bit.Core.Billing.Repositories;
|
using Bit.Core.Billing.Repositories;
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.Utilities;
|
using Bit.Core.Utilities;
|
||||||
@ -42,96 +41,64 @@ public class ProviderEventService(
|
|||||||
case HandledStripeWebhook.InvoiceCreated:
|
case HandledStripeWebhook.InvoiceCreated:
|
||||||
{
|
{
|
||||||
var clients =
|
var clients =
|
||||||
(await providerOrganizationRepository.GetManyDetailsByProviderAsync(parsedProviderId))
|
await providerOrganizationRepository.GetManyDetailsByProviderAsync(parsedProviderId);
|
||||||
.Where(providerOrganization => providerOrganization.Status == OrganizationStatusType.Managed);
|
|
||||||
|
|
||||||
var providerPlans = await providerPlanRepository.GetByProviderId(parsedProviderId);
|
var providerPlans = await providerPlanRepository.GetByProviderId(parsedProviderId);
|
||||||
|
|
||||||
var enterpriseProviderPlan =
|
var invoiceItems = new List<ProviderInvoiceItem>();
|
||||||
providerPlans.FirstOrDefault(providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly);
|
|
||||||
|
|
||||||
var teamsProviderPlan =
|
foreach (var client in clients)
|
||||||
providerPlans.FirstOrDefault(providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly);
|
|
||||||
|
|
||||||
if (enterpriseProviderPlan == null || !enterpriseProviderPlan.IsConfigured() ||
|
|
||||||
teamsProviderPlan == null || !teamsProviderPlan.IsConfigured())
|
|
||||||
{
|
{
|
||||||
logger.LogError("Provider {ProviderID} is missing or has misconfigured provider plans", parsedProviderId);
|
if (client.Status != OrganizationStatusType.Managed)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
throw new Exception("Cannot record invoice line items for Provider with missing or misconfigured provider plans");
|
var plan = StaticStore.Plans.Single(x => x.Name == client.Plan && providerPlans.Any(y => y.PlanType == x.Type));
|
||||||
|
|
||||||
|
var discountedPercentage = (100 - (invoice.Discount?.Coupon?.PercentOff ?? 0)) / 100;
|
||||||
|
|
||||||
|
var discountedSeatPrice = plan.PasswordManager.ProviderPortalSeatPrice * discountedPercentage;
|
||||||
|
|
||||||
|
invoiceItems.Add(new ProviderInvoiceItem
|
||||||
|
{
|
||||||
|
ProviderId = parsedProviderId,
|
||||||
|
InvoiceId = invoice.Id,
|
||||||
|
InvoiceNumber = invoice.Number,
|
||||||
|
ClientId = client.OrganizationId,
|
||||||
|
ClientName = client.OrganizationName,
|
||||||
|
PlanName = client.Plan,
|
||||||
|
AssignedSeats = client.Seats ?? 0,
|
||||||
|
UsedSeats = client.OccupiedSeats ?? 0,
|
||||||
|
Total = (client.Seats ?? 0) * discountedSeatPrice
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
var enterprisePlan = StaticStore.GetPlan(PlanType.EnterpriseMonthly);
|
foreach (var providerPlan in providerPlans.Where(x => x.PurchasedSeats is null or 0))
|
||||||
|
|
||||||
var teamsPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
|
|
||||||
|
|
||||||
var discountedPercentage = (100 - (invoice.Discount?.Coupon?.PercentOff ?? 0)) / 100;
|
|
||||||
|
|
||||||
var discountedEnterpriseSeatPrice = enterprisePlan.PasswordManager.ProviderPortalSeatPrice * discountedPercentage;
|
|
||||||
|
|
||||||
var discountedTeamsSeatPrice = teamsPlan.PasswordManager.ProviderPortalSeatPrice * discountedPercentage;
|
|
||||||
|
|
||||||
var invoiceItems = clients.Select(client => new ProviderInvoiceItem
|
|
||||||
{
|
{
|
||||||
ProviderId = parsedProviderId,
|
var plan = StaticStore.GetPlan(providerPlan.PlanType);
|
||||||
InvoiceId = invoice.Id,
|
|
||||||
InvoiceNumber = invoice.Number,
|
|
||||||
ClientId = client.OrganizationId,
|
|
||||||
ClientName = client.OrganizationName,
|
|
||||||
PlanName = client.Plan,
|
|
||||||
AssignedSeats = client.Seats ?? 0,
|
|
||||||
UsedSeats = client.OccupiedSeats ?? 0,
|
|
||||||
Total = client.Plan == enterprisePlan.Name
|
|
||||||
? (client.Seats ?? 0) * discountedEnterpriseSeatPrice
|
|
||||||
: (client.Seats ?? 0) * discountedTeamsSeatPrice
|
|
||||||
}).ToList();
|
|
||||||
|
|
||||||
if (enterpriseProviderPlan.PurchasedSeats is null or 0)
|
var clientSeats = invoiceItems
|
||||||
{
|
.Where(item => item.PlanName == plan.Name)
|
||||||
var enterpriseClientSeats = invoiceItems
|
|
||||||
.Where(item => item.PlanName == enterprisePlan.Name)
|
|
||||||
.Sum(item => item.AssignedSeats);
|
.Sum(item => item.AssignedSeats);
|
||||||
|
|
||||||
var unassignedEnterpriseSeats = enterpriseProviderPlan.SeatMinimum - enterpriseClientSeats ?? 0;
|
var unassignedSeats = providerPlan.SeatMinimum - clientSeats ?? 0;
|
||||||
|
|
||||||
if (unassignedEnterpriseSeats > 0)
|
var discountedPercentage = (100 - (invoice.Discount?.Coupon?.PercentOff ?? 0)) / 100;
|
||||||
|
|
||||||
|
var discountedSeatPrice = plan.PasswordManager.ProviderPortalSeatPrice * discountedPercentage;
|
||||||
|
|
||||||
|
invoiceItems.Add(new ProviderInvoiceItem
|
||||||
{
|
{
|
||||||
invoiceItems.Add(new ProviderInvoiceItem
|
ProviderId = parsedProviderId,
|
||||||
{
|
InvoiceId = invoice.Id,
|
||||||
ProviderId = parsedProviderId,
|
InvoiceNumber = invoice.Number,
|
||||||
InvoiceId = invoice.Id,
|
ClientName = "Unassigned seats",
|
||||||
InvoiceNumber = invoice.Number,
|
PlanName = plan.Name,
|
||||||
ClientName = "Unassigned seats",
|
AssignedSeats = unassignedSeats,
|
||||||
PlanName = enterprisePlan.Name,
|
UsedSeats = 0,
|
||||||
AssignedSeats = unassignedEnterpriseSeats,
|
Total = unassignedSeats * discountedSeatPrice
|
||||||
UsedSeats = 0,
|
});
|
||||||
Total = unassignedEnterpriseSeats * discountedEnterpriseSeatPrice
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (teamsProviderPlan.PurchasedSeats is null or 0)
|
|
||||||
{
|
|
||||||
var teamsClientSeats = invoiceItems
|
|
||||||
.Where(item => item.PlanName == teamsPlan.Name)
|
|
||||||
.Sum(item => item.AssignedSeats);
|
|
||||||
|
|
||||||
var unassignedTeamsSeats = teamsProviderPlan.SeatMinimum - teamsClientSeats ?? 0;
|
|
||||||
|
|
||||||
if (unassignedTeamsSeats > 0)
|
|
||||||
{
|
|
||||||
invoiceItems.Add(new ProviderInvoiceItem
|
|
||||||
{
|
|
||||||
ProviderId = parsedProviderId,
|
|
||||||
InvoiceId = invoice.Id,
|
|
||||||
InvoiceNumber = invoice.Number,
|
|
||||||
ClientName = "Unassigned seats",
|
|
||||||
PlanName = teamsPlan.Name,
|
|
||||||
AssignedSeats = unassignedTeamsSeats,
|
|
||||||
UsedSeats = 0,
|
|
||||||
Total = unassignedTeamsSeats * discountedTeamsSeatPrice
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await Task.WhenAll(invoiceItems.Select(providerInvoiceItemRepository.CreateAsync));
|
await Task.WhenAll(invoiceItems.Select(providerInvoiceItemRepository.CreateAsync));
|
||||||
|
@ -4,8 +4,10 @@ namespace Bit.Core.AdminConsole.Enums.Provider;
|
|||||||
|
|
||||||
public enum ProviderType : byte
|
public enum ProviderType : byte
|
||||||
{
|
{
|
||||||
[Display(ShortName = "MSP", Name = "Managed Service Provider", Description = "Access to clients organization")]
|
[Display(ShortName = "MSP", Name = "Managed Service Provider", Description = "Access to clients organization", Order = 0)]
|
||||||
Msp = 0,
|
Msp = 0,
|
||||||
[Display(ShortName = "Reseller", Name = "Reseller", Description = "Access to clients billing")]
|
[Display(ShortName = "Reseller", Name = "Reseller", Description = "Access to clients billing", Order = 1000)]
|
||||||
Reseller = 1,
|
Reseller = 1,
|
||||||
|
[Display(ShortName = "MOE", Name = "Multi-organization Enterprise", Description = "Access to multiple organizations", Order = 1)]
|
||||||
|
MultiOrganizationEnterprise = 2,
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,43 @@
|
|||||||
|
#nullable enable
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization;
|
||||||
|
using Bit.Core.Context;
|
||||||
|
using Bit.Core.Enums;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.Groups.Authorization;
|
||||||
|
|
||||||
|
public class GroupAuthorizationHandler(ICurrentContext currentContext)
|
||||||
|
: AuthorizationHandler<GroupOperationRequirement, OrganizationScope>
|
||||||
|
{
|
||||||
|
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context,
|
||||||
|
GroupOperationRequirement requirement, OrganizationScope organizationScope)
|
||||||
|
{
|
||||||
|
var authorized = requirement switch
|
||||||
|
{
|
||||||
|
not null when requirement.Name == nameof(GroupOperations.ReadAll) =>
|
||||||
|
await CanReadAllAsync(organizationScope),
|
||||||
|
not null when requirement.Name == nameof(GroupOperations.ReadAllDetails) =>
|
||||||
|
await CanViewGroupDetailsAsync(organizationScope),
|
||||||
|
_ => false
|
||||||
|
};
|
||||||
|
|
||||||
|
if (requirement is not null && authorized)
|
||||||
|
{
|
||||||
|
context.Succeed(requirement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> CanReadAllAsync(OrganizationScope organizationScope) =>
|
||||||
|
currentContext.GetOrganization(organizationScope) is not null
|
||||||
|
|| await currentContext.ProviderUserForOrgAsync(organizationScope);
|
||||||
|
|
||||||
|
private async Task<bool> CanViewGroupDetailsAsync(OrganizationScope organizationScope) =>
|
||||||
|
currentContext.GetOrganization(organizationScope) is
|
||||||
|
{ Type: OrganizationUserType.Owner } or
|
||||||
|
{ Type: OrganizationUserType.Admin } or
|
||||||
|
{
|
||||||
|
Permissions: { ManageGroups: true } or
|
||||||
|
{ ManageUsers: true }
|
||||||
|
} ||
|
||||||
|
await currentContext.ProviderUserForOrgAsync(organizationScope);
|
||||||
|
}
|
@ -0,0 +1,17 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization.Infrastructure;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.Groups.Authorization;
|
||||||
|
|
||||||
|
public class GroupOperationRequirement : OperationAuthorizationRequirement
|
||||||
|
{
|
||||||
|
public GroupOperationRequirement(string name)
|
||||||
|
{
|
||||||
|
Name = name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class GroupOperations
|
||||||
|
{
|
||||||
|
public static readonly GroupOperationRequirement ReadAll = new(nameof(ReadAll));
|
||||||
|
public static readonly GroupOperationRequirement ReadAllDetails = new(nameof(ReadAllDetails));
|
||||||
|
}
|
@ -0,0 +1,6 @@
|
|||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
|
||||||
|
|
||||||
|
public interface IOrganizationHasVerifiedDomainsQuery
|
||||||
|
{
|
||||||
|
Task<bool> HasVerifiedDomainsAsync(Guid orgId);
|
||||||
|
}
|
@ -0,0 +1,10 @@
|
|||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
|
||||||
|
using Bit.Core.Repositories;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains;
|
||||||
|
|
||||||
|
public class OrganizationHasVerifiedDomainsQuery(IOrganizationDomainRepository domainRepository) : IOrganizationHasVerifiedDomainsQuery
|
||||||
|
{
|
||||||
|
public async Task<bool> HasVerifiedDomainsAsync(Guid orgId) =>
|
||||||
|
(await domainRepository.GetDomainsByOrganizationIdAsync(orgId)).Any(od => od.VerifiedDate is not null);
|
||||||
|
}
|
@ -1,4 +1,7 @@
|
|||||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Bit.Core.AdminConsole.Enums;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
|
||||||
|
using Bit.Core.AdminConsole.Services;
|
||||||
using Bit.Core.Entities;
|
using Bit.Core.Entities;
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.Exceptions;
|
using Bit.Core.Exceptions;
|
||||||
@ -15,6 +18,8 @@ public class VerifyOrganizationDomainCommand : IVerifyOrganizationDomainCommand
|
|||||||
private readonly IDnsResolverService _dnsResolverService;
|
private readonly IDnsResolverService _dnsResolverService;
|
||||||
private readonly IEventService _eventService;
|
private readonly IEventService _eventService;
|
||||||
private readonly IGlobalSettings _globalSettings;
|
private readonly IGlobalSettings _globalSettings;
|
||||||
|
private readonly IPolicyService _policyService;
|
||||||
|
private readonly IFeatureService _featureService;
|
||||||
private readonly ILogger<VerifyOrganizationDomainCommand> _logger;
|
private readonly ILogger<VerifyOrganizationDomainCommand> _logger;
|
||||||
|
|
||||||
public VerifyOrganizationDomainCommand(
|
public VerifyOrganizationDomainCommand(
|
||||||
@ -22,12 +27,16 @@ public class VerifyOrganizationDomainCommand : IVerifyOrganizationDomainCommand
|
|||||||
IDnsResolverService dnsResolverService,
|
IDnsResolverService dnsResolverService,
|
||||||
IEventService eventService,
|
IEventService eventService,
|
||||||
IGlobalSettings globalSettings,
|
IGlobalSettings globalSettings,
|
||||||
|
IPolicyService policyService,
|
||||||
|
IFeatureService featureService,
|
||||||
ILogger<VerifyOrganizationDomainCommand> logger)
|
ILogger<VerifyOrganizationDomainCommand> logger)
|
||||||
{
|
{
|
||||||
_organizationDomainRepository = organizationDomainRepository;
|
_organizationDomainRepository = organizationDomainRepository;
|
||||||
_dnsResolverService = dnsResolverService;
|
_dnsResolverService = dnsResolverService;
|
||||||
_eventService = eventService;
|
_eventService = eventService;
|
||||||
_globalSettings = globalSettings;
|
_globalSettings = globalSettings;
|
||||||
|
_policyService = policyService;
|
||||||
|
_featureService = featureService;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -102,6 +111,8 @@ public class VerifyOrganizationDomainCommand : IVerifyOrganizationDomainCommand
|
|||||||
if (await _dnsResolverService.ResolveAsync(domain.DomainName, domain.Txt))
|
if (await _dnsResolverService.ResolveAsync(domain.DomainName, domain.Txt))
|
||||||
{
|
{
|
||||||
domain.SetVerifiedDate();
|
domain.SetVerifiedDate();
|
||||||
|
|
||||||
|
await EnableSingleOrganizationPolicyAsync(domain.OrganizationId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception e)
|
catch (Exception e)
|
||||||
@ -112,4 +123,13 @@ public class VerifyOrganizationDomainCommand : IVerifyOrganizationDomainCommand
|
|||||||
|
|
||||||
return domain;
|
return domain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task EnableSingleOrganizationPolicyAsync(Guid organizationId)
|
||||||
|
{
|
||||||
|
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning))
|
||||||
|
{
|
||||||
|
await _policyService.SaveAsync(
|
||||||
|
new Policy { OrganizationId = organizationId, Type = PolicyType.SingleOrg, Enabled = true }, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
#nullable enable
|
#nullable enable
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization;
|
||||||
using Bit.Core.Context;
|
using Bit.Core.Context;
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.Services;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
|
||||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
|
||||||
@ -10,12 +10,10 @@ public class OrganizationUserUserDetailsAuthorizationHandler
|
|||||||
: AuthorizationHandler<OrganizationUserUserDetailsOperationRequirement, OrganizationScope>
|
: AuthorizationHandler<OrganizationUserUserDetailsOperationRequirement, OrganizationScope>
|
||||||
{
|
{
|
||||||
private readonly ICurrentContext _currentContext;
|
private readonly ICurrentContext _currentContext;
|
||||||
private readonly IFeatureService _featureService;
|
|
||||||
|
|
||||||
public OrganizationUserUserDetailsAuthorizationHandler(ICurrentContext currentContext, IFeatureService featureService)
|
public OrganizationUserUserDetailsAuthorizationHandler(ICurrentContext currentContext)
|
||||||
{
|
{
|
||||||
_currentContext = currentContext;
|
_currentContext = currentContext;
|
||||||
_featureService = featureService;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context,
|
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context,
|
||||||
@ -37,29 +35,6 @@ public class OrganizationUserUserDetailsAuthorizationHandler
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async Task<bool> CanReadAllAsync(Guid organizationId)
|
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
|
// Admins can access this for general user management
|
||||||
var organization = _currentContext.GetOrganization(organizationId);
|
var organization = _currentContext.GetOrganization(organizationId);
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
using Bit.Core.Context;
|
using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization;
|
||||||
using Bit.Core.Services;
|
using Bit.Core.Context;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
|
||||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
|
||||||
@ -7,14 +7,10 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authoriza
|
|||||||
public class OrganizationUserUserMiniDetailsAuthorizationHandler :
|
public class OrganizationUserUserMiniDetailsAuthorizationHandler :
|
||||||
AuthorizationHandler<OrganizationUserUserMiniDetailsOperationRequirement, OrganizationScope>
|
AuthorizationHandler<OrganizationUserUserMiniDetailsOperationRequirement, OrganizationScope>
|
||||||
{
|
{
|
||||||
private readonly IApplicationCacheService _applicationCacheService;
|
|
||||||
private readonly ICurrentContext _currentContext;
|
private readonly ICurrentContext _currentContext;
|
||||||
|
|
||||||
public OrganizationUserUserMiniDetailsAuthorizationHandler(
|
public OrganizationUserUserMiniDetailsAuthorizationHandler(ICurrentContext currentContext)
|
||||||
IApplicationCacheService applicationCacheService,
|
|
||||||
ICurrentContext currentContext)
|
|
||||||
{
|
{
|
||||||
_applicationCacheService = applicationCacheService;
|
|
||||||
_currentContext = currentContext;
|
_currentContext = currentContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -87,8 +87,7 @@ public class SavePolicyCommand : ISavePolicyCommand
|
|||||||
if (currentPolicy is not { Enabled: true } && policyUpdate.Enabled)
|
if (currentPolicy is not { Enabled: true } && policyUpdate.Enabled)
|
||||||
{
|
{
|
||||||
var missingRequiredPolicyTypes = validator.RequiredPolicies
|
var missingRequiredPolicyTypes = validator.RequiredPolicies
|
||||||
.Where(requiredPolicyType =>
|
.Where(requiredPolicyType => savedPoliciesDict.GetValueOrDefault(requiredPolicyType) is not { Enabled: true })
|
||||||
savedPoliciesDict.GetValueOrDefault(requiredPolicyType) is not { Enabled: true })
|
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
if (missingRequiredPolicyTypes.Count != 0)
|
if (missingRequiredPolicyTypes.Count != 0)
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
using Bit.Core.AdminConsole.Entities;
|
using Bit.Core.AdminConsole.Entities;
|
||||||
using Bit.Core.AdminConsole.Enums;
|
using Bit.Core.AdminConsole.Enums;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
|
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
|
||||||
using Bit.Core.Auth.Enums;
|
using Bit.Core.Auth.Enums;
|
||||||
@ -23,7 +24,9 @@ public class SingleOrgPolicyValidator : IPolicyValidator
|
|||||||
private readonly IOrganizationRepository _organizationRepository;
|
private readonly IOrganizationRepository _organizationRepository;
|
||||||
private readonly ISsoConfigRepository _ssoConfigRepository;
|
private readonly ISsoConfigRepository _ssoConfigRepository;
|
||||||
private readonly ICurrentContext _currentContext;
|
private readonly ICurrentContext _currentContext;
|
||||||
|
private readonly IFeatureService _featureService;
|
||||||
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
|
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
|
||||||
|
private readonly IOrganizationHasVerifiedDomainsQuery _organizationHasVerifiedDomainsQuery;
|
||||||
|
|
||||||
public SingleOrgPolicyValidator(
|
public SingleOrgPolicyValidator(
|
||||||
IOrganizationUserRepository organizationUserRepository,
|
IOrganizationUserRepository organizationUserRepository,
|
||||||
@ -31,14 +34,18 @@ public class SingleOrgPolicyValidator : IPolicyValidator
|
|||||||
IOrganizationRepository organizationRepository,
|
IOrganizationRepository organizationRepository,
|
||||||
ISsoConfigRepository ssoConfigRepository,
|
ISsoConfigRepository ssoConfigRepository,
|
||||||
ICurrentContext currentContext,
|
ICurrentContext currentContext,
|
||||||
IRemoveOrganizationUserCommand removeOrganizationUserCommand)
|
IFeatureService featureService,
|
||||||
|
IRemoveOrganizationUserCommand removeOrganizationUserCommand,
|
||||||
|
IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery)
|
||||||
{
|
{
|
||||||
_organizationUserRepository = organizationUserRepository;
|
_organizationUserRepository = organizationUserRepository;
|
||||||
_mailService = mailService;
|
_mailService = mailService;
|
||||||
_organizationRepository = organizationRepository;
|
_organizationRepository = organizationRepository;
|
||||||
_ssoConfigRepository = ssoConfigRepository;
|
_ssoConfigRepository = ssoConfigRepository;
|
||||||
_currentContext = currentContext;
|
_currentContext = currentContext;
|
||||||
|
_featureService = featureService;
|
||||||
_removeOrganizationUserCommand = removeOrganizationUserCommand;
|
_removeOrganizationUserCommand = removeOrganizationUserCommand;
|
||||||
|
_organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery;
|
||||||
}
|
}
|
||||||
|
|
||||||
public IEnumerable<PolicyType> RequiredPolicies => [];
|
public IEnumerable<PolicyType> RequiredPolicies => [];
|
||||||
@ -93,9 +100,21 @@ public class SingleOrgPolicyValidator : IPolicyValidator
|
|||||||
if (policyUpdate is not { Enabled: true })
|
if (policyUpdate is not { Enabled: true })
|
||||||
{
|
{
|
||||||
var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(policyUpdate.OrganizationId);
|
var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(policyUpdate.OrganizationId);
|
||||||
return ssoConfig.ValidateDecryptionOptionsNotEnabled([MemberDecryptionType.KeyConnector]);
|
|
||||||
|
var validateDecryptionErrorMessage = ssoConfig.ValidateDecryptionOptionsNotEnabled([MemberDecryptionType.KeyConnector]);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(validateDecryptionErrorMessage))
|
||||||
|
{
|
||||||
|
return validateDecryptionErrorMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
|
||||||
|
&& await _organizationHasVerifiedDomainsQuery.HasVerifiedDomainsAsync(policyUpdate.OrganizationId))
|
||||||
|
{
|
||||||
|
return "The Single organization policy is required for organizations that have enabled domain verification.";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return "";
|
return string.Empty;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
#nullable enable
|
#nullable enable
|
||||||
|
|
||||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A typed wrapper for an organization Guid. This is used for authorization checks
|
/// A typed wrapper for an organization Guid. This is used for authorization checks
|
@ -1,4 +1,5 @@
|
|||||||
using Bit.Core.AdminConsole.Entities.Provider;
|
using Bit.Core.AdminConsole.Entities.Provider;
|
||||||
|
using Bit.Core.Billing.Enums;
|
||||||
|
|
||||||
namespace Bit.Core.AdminConsole.Providers.Interfaces;
|
namespace Bit.Core.AdminConsole.Providers.Interfaces;
|
||||||
|
|
||||||
@ -6,4 +7,5 @@ public interface ICreateProviderCommand
|
|||||||
{
|
{
|
||||||
Task CreateMspAsync(Provider provider, string ownerEmail, int teamsMinimumSeats, int enterpriseMinimumSeats);
|
Task CreateMspAsync(Provider provider, string ownerEmail, int teamsMinimumSeats, int enterpriseMinimumSeats);
|
||||||
Task CreateResellerAsync(Provider provider);
|
Task CreateResellerAsync(Provider provider);
|
||||||
|
Task CreateMultiOrganizationEnterpriseAsync(Provider provider, string ownerEmail, PlanType plan, int minimumSeats);
|
||||||
}
|
}
|
||||||
|
@ -22,8 +22,7 @@ public interface IOrganizationUserRepository : IRepository<OrganizationUser, Gui
|
|||||||
Task<OrganizationUser?> GetByOrganizationAsync(Guid organizationId, Guid userId);
|
Task<OrganizationUser?> GetByOrganizationAsync(Guid organizationId, Guid userId);
|
||||||
Task<Tuple<OrganizationUser?, ICollection<CollectionAccessSelection>>> GetByIdWithCollectionsAsync(Guid id);
|
Task<Tuple<OrganizationUser?, ICollection<CollectionAccessSelection>>> GetByIdWithCollectionsAsync(Guid id);
|
||||||
Task<OrganizationUserUserDetails?> GetDetailsByIdAsync(Guid id);
|
Task<OrganizationUserUserDetails?> GetDetailsByIdAsync(Guid id);
|
||||||
Task<Tuple<OrganizationUserUserDetails?, ICollection<CollectionAccessSelection>>>
|
Task<(OrganizationUserUserDetails? OrganizationUser, ICollection<CollectionAccessSelection> Collections)> GetDetailsByIdWithCollectionsAsync(Guid id);
|
||||||
GetDetailsByIdWithCollectionsAsync(Guid id);
|
|
||||||
Task<ICollection<OrganizationUserUserDetails>> GetManyDetailsByOrganizationAsync(Guid organizationId, bool includeGroups = false, bool includeCollections = false);
|
Task<ICollection<OrganizationUserUserDetails>> GetManyDetailsByOrganizationAsync(Guid organizationId, bool includeGroups = false, bool includeCollections = false);
|
||||||
Task<ICollection<OrganizationUserOrganizationDetails>> GetManyDetailsByUserAsync(Guid userId,
|
Task<ICollection<OrganizationUserOrganizationDetails>> GetManyDetailsByUserAsync(Guid userId,
|
||||||
OrganizationUserStatusType? status = null);
|
OrganizationUserStatusType? status = null);
|
||||||
|
@ -4,8 +4,4 @@ public interface IOrganizationDomainService
|
|||||||
{
|
{
|
||||||
Task ValidateOrganizationsDomainAsync();
|
Task ValidateOrganizationsDomainAsync();
|
||||||
Task OrganizationDomainMaintenanceAsync();
|
Task OrganizationDomainMaintenanceAsync();
|
||||||
/// <summary>
|
|
||||||
/// Indicates if the organization has any verified domains.
|
|
||||||
/// </summary>
|
|
||||||
Task<bool> HasVerifiedDomainsAsync(Guid orgId);
|
|
||||||
}
|
}
|
||||||
|
@ -106,12 +106,6 @@ public class OrganizationDomainService : IOrganizationDomainService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> HasVerifiedDomainsAsync(Guid orgId)
|
|
||||||
{
|
|
||||||
var orgDomains = await _domainRepository.GetDomainsByOrganizationIdAsync(orgId);
|
|
||||||
return orgDomains.Any(od => od.VerifiedDate != null);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<List<string>> GetAdminEmailsAsync(Guid organizationId)
|
private async Task<List<string>> GetAdminEmailsAsync(Guid organizationId)
|
||||||
{
|
{
|
||||||
var orgUsers = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId);
|
var orgUsers = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId);
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
using Bit.Core.AdminConsole.Entities;
|
using Bit.Core.AdminConsole.Entities;
|
||||||
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.OrganizationDomains.Interfaces;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
|
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
|
||||||
@ -32,6 +33,7 @@ public class PolicyService : IPolicyService
|
|||||||
private readonly IFeatureService _featureService;
|
private readonly IFeatureService _featureService;
|
||||||
private readonly ISavePolicyCommand _savePolicyCommand;
|
private readonly ISavePolicyCommand _savePolicyCommand;
|
||||||
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
|
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
|
||||||
|
private readonly IOrganizationHasVerifiedDomainsQuery _organizationHasVerifiedDomainsQuery;
|
||||||
|
|
||||||
public PolicyService(
|
public PolicyService(
|
||||||
IApplicationCacheService applicationCacheService,
|
IApplicationCacheService applicationCacheService,
|
||||||
@ -45,7 +47,8 @@ public class PolicyService : IPolicyService
|
|||||||
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
|
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
|
||||||
IFeatureService featureService,
|
IFeatureService featureService,
|
||||||
ISavePolicyCommand savePolicyCommand,
|
ISavePolicyCommand savePolicyCommand,
|
||||||
IRemoveOrganizationUserCommand removeOrganizationUserCommand)
|
IRemoveOrganizationUserCommand removeOrganizationUserCommand,
|
||||||
|
IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery)
|
||||||
{
|
{
|
||||||
_applicationCacheService = applicationCacheService;
|
_applicationCacheService = applicationCacheService;
|
||||||
_eventService = eventService;
|
_eventService = eventService;
|
||||||
@ -59,6 +62,7 @@ public class PolicyService : IPolicyService
|
|||||||
_featureService = featureService;
|
_featureService = featureService;
|
||||||
_savePolicyCommand = savePolicyCommand;
|
_savePolicyCommand = savePolicyCommand;
|
||||||
_removeOrganizationUserCommand = removeOrganizationUserCommand;
|
_removeOrganizationUserCommand = removeOrganizationUserCommand;
|
||||||
|
_organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SaveAsync(Policy policy, Guid? savingUserId)
|
public async Task SaveAsync(Policy policy, Guid? savingUserId)
|
||||||
@ -239,6 +243,7 @@ public class PolicyService : IPolicyService
|
|||||||
case PolicyType.SingleOrg:
|
case PolicyType.SingleOrg:
|
||||||
if (!policy.Enabled)
|
if (!policy.Enabled)
|
||||||
{
|
{
|
||||||
|
await HasVerifiedDomainsAsync(org);
|
||||||
await RequiredBySsoAsync(org);
|
await RequiredBySsoAsync(org);
|
||||||
await RequiredByVaultTimeoutAsync(org);
|
await RequiredByVaultTimeoutAsync(org);
|
||||||
await RequiredByKeyConnectorAsync(org);
|
await RequiredByKeyConnectorAsync(org);
|
||||||
@ -279,6 +284,15 @@ public class PolicyService : IPolicyService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task HasVerifiedDomainsAsync(Organization org)
|
||||||
|
{
|
||||||
|
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
|
||||||
|
&& await _organizationHasVerifiedDomainsQuery.HasVerifiedDomainsAsync(org.Id))
|
||||||
|
{
|
||||||
|
throw new BadRequestException("The Single organization policy is required for organizations that have enabled domain verification.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task SetPolicyConfiguration(Policy policy)
|
private async Task SetPolicyConfiguration(Policy policy)
|
||||||
{
|
{
|
||||||
await _policyRepository.UpsertAsync(policy);
|
await _policyRepository.UpsertAsync(policy);
|
||||||
|
@ -6,6 +6,14 @@ using Bit.Core.Utilities;
|
|||||||
namespace Bit.Core.Auth.Models.Api.Request.Accounts;
|
namespace Bit.Core.Auth.Models.Api.Request.Accounts;
|
||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
public enum RegisterFinishTokenType : byte
|
||||||
|
{
|
||||||
|
EmailVerification = 1,
|
||||||
|
OrganizationInvite = 2,
|
||||||
|
OrgSponsoredFreeFamilyPlan = 3,
|
||||||
|
EmergencyAccessInvite = 4,
|
||||||
|
ProviderInvite = 5,
|
||||||
|
}
|
||||||
|
|
||||||
public class RegisterFinishRequestModel : IValidatableObject
|
public class RegisterFinishRequestModel : IValidatableObject
|
||||||
{
|
{
|
||||||
@ -36,6 +44,10 @@ public class RegisterFinishRequestModel : IValidatableObject
|
|||||||
public string? AcceptEmergencyAccessInviteToken { get; set; }
|
public string? AcceptEmergencyAccessInviteToken { get; set; }
|
||||||
public Guid? AcceptEmergencyAccessId { get; set; }
|
public Guid? AcceptEmergencyAccessId { get; set; }
|
||||||
|
|
||||||
|
public string? ProviderInviteToken { get; set; }
|
||||||
|
|
||||||
|
public Guid? ProviderUserId { get; set; }
|
||||||
|
|
||||||
public User ToUser()
|
public User ToUser()
|
||||||
{
|
{
|
||||||
var user = new User
|
var user = new User
|
||||||
@ -54,6 +66,32 @@ public class RegisterFinishRequestModel : IValidatableObject
|
|||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public RegisterFinishTokenType GetTokenType()
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(EmailVerificationToken))
|
||||||
|
{
|
||||||
|
return RegisterFinishTokenType.EmailVerification;
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrEmpty(OrgInviteToken) && OrganizationUserId.HasValue)
|
||||||
|
{
|
||||||
|
return RegisterFinishTokenType.OrganizationInvite;
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrWhiteSpace(OrgSponsoredFreeFamilyPlanToken))
|
||||||
|
{
|
||||||
|
return RegisterFinishTokenType.OrgSponsoredFreeFamilyPlan;
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrWhiteSpace(AcceptEmergencyAccessInviteToken) && AcceptEmergencyAccessId.HasValue)
|
||||||
|
{
|
||||||
|
return RegisterFinishTokenType.EmergencyAccessInvite;
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrWhiteSpace(ProviderInviteToken) && ProviderUserId.HasValue)
|
||||||
|
{
|
||||||
|
return RegisterFinishTokenType.ProviderInvite;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException("Invalid token type.");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
||||||
{
|
{
|
||||||
|
@ -0,0 +1,7 @@
|
|||||||
|
using Bit.Core.Models.Mail;
|
||||||
|
|
||||||
|
namespace Bit.Core.Auth.Models.Mail;
|
||||||
|
|
||||||
|
public class CannotDeleteManagedAccountViewModel : BaseMailModel
|
||||||
|
{
|
||||||
|
}
|
@ -61,4 +61,16 @@ public interface IRegisterUserCommand
|
|||||||
public Task<IdentityResult> RegisterUserViaAcceptEmergencyAccessInviteToken(User user, string masterPasswordHash,
|
public Task<IdentityResult> RegisterUserViaAcceptEmergencyAccessInviteToken(User user, string masterPasswordHash,
|
||||||
string acceptEmergencyAccessInviteToken, Guid acceptEmergencyAccessId);
|
string acceptEmergencyAccessInviteToken, Guid acceptEmergencyAccessId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new user with a given master password hash, sends a welcome email, and raises the signup reference event.
|
||||||
|
/// If a valid token is provided, the user will be created with their email verified.
|
||||||
|
/// If the token is invalid or expired, an error will be thrown.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="user">The <see cref="User"/> to create</param>
|
||||||
|
/// <param name="masterPasswordHash">The hashed master password the user entered</param>
|
||||||
|
/// <param name="providerInviteToken">The provider invite token sent to the user via email</param>
|
||||||
|
/// <param name="providerUserId">The provider user id which is used to validate the invite token</param>
|
||||||
|
/// <returns><see cref="IdentityResult"/></returns>
|
||||||
|
public Task<IdentityResult> RegisterUserViaProviderInviteToken(User user, string masterPasswordHash, string providerInviteToken, Guid providerUserId);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -32,6 +32,7 @@ public class RegisterUserCommand : IRegisterUserCommand
|
|||||||
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
|
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
|
||||||
private readonly IDataProtectorTokenFactory<RegistrationEmailVerificationTokenable> _registrationEmailVerificationTokenDataFactory;
|
private readonly IDataProtectorTokenFactory<RegistrationEmailVerificationTokenable> _registrationEmailVerificationTokenDataFactory;
|
||||||
private readonly IDataProtector _organizationServiceDataProtector;
|
private readonly IDataProtector _organizationServiceDataProtector;
|
||||||
|
private readonly IDataProtector _providerServiceDataProtector;
|
||||||
|
|
||||||
private readonly ICurrentContext _currentContext;
|
private readonly ICurrentContext _currentContext;
|
||||||
|
|
||||||
@ -75,6 +76,8 @@ public class RegisterUserCommand : IRegisterUserCommand
|
|||||||
|
|
||||||
_validateRedemptionTokenCommand = validateRedemptionTokenCommand;
|
_validateRedemptionTokenCommand = validateRedemptionTokenCommand;
|
||||||
_emergencyAccessInviteTokenDataFactory = emergencyAccessInviteTokenDataFactory;
|
_emergencyAccessInviteTokenDataFactory = emergencyAccessInviteTokenDataFactory;
|
||||||
|
|
||||||
|
_providerServiceDataProtector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -303,6 +306,25 @@ public class RegisterUserCommand : IRegisterUserCommand
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IdentityResult> RegisterUserViaProviderInviteToken(User user, string masterPasswordHash,
|
||||||
|
string providerInviteToken, Guid providerUserId)
|
||||||
|
{
|
||||||
|
ValidateOpenRegistrationAllowed();
|
||||||
|
ValidateProviderInviteToken(providerInviteToken, providerUserId, user.Email);
|
||||||
|
|
||||||
|
user.EmailVerified = true;
|
||||||
|
user.ApiKey = CoreHelpers.SecureRandomString(30); // API key can't be null.
|
||||||
|
|
||||||
|
var result = await _userService.CreateUserAsync(user, masterPasswordHash);
|
||||||
|
if (result == IdentityResult.Success)
|
||||||
|
{
|
||||||
|
await _mailService.SendWelcomeEmailAsync(user);
|
||||||
|
await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.Signup, user, _currentContext));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
private void ValidateOpenRegistrationAllowed()
|
private void ValidateOpenRegistrationAllowed()
|
||||||
{
|
{
|
||||||
// We validate open registration on send of initial email and here b/c a user could technically start the
|
// We validate open registration on send of initial email and here b/c a user could technically start the
|
||||||
@ -333,6 +355,15 @@ public class RegisterUserCommand : IRegisterUserCommand
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void ValidateProviderInviteToken(string providerInviteToken, Guid providerUserId, string userEmail)
|
||||||
|
{
|
||||||
|
if (!CoreHelpers.TokenIsValid("ProviderUserInvite", _providerServiceDataProtector, providerInviteToken, userEmail, providerUserId,
|
||||||
|
_globalSettings.OrganizationInviteExpirationHours))
|
||||||
|
{
|
||||||
|
throw new BadRequestException("Invalid provider invite token.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
private RegistrationEmailVerificationTokenable ValidateRegistrationEmailVerificationTokenable(string emailVerificationToken, string userEmail)
|
private RegistrationEmailVerificationTokenable ValidateRegistrationEmailVerificationTokenable(string emailVerificationToken, string userEmail)
|
||||||
{
|
{
|
||||||
|
@ -11,11 +11,13 @@ namespace Bit.Core.Billing.Extensions;
|
|||||||
public static class BillingExtensions
|
public static class BillingExtensions
|
||||||
{
|
{
|
||||||
public static bool IsBillable(this Provider provider) =>
|
public static bool IsBillable(this Provider provider) =>
|
||||||
provider is
|
provider.SupportsConsolidatedBilling() && provider.Status == ProviderStatusType.Billable;
|
||||||
{
|
|
||||||
Type: ProviderType.Msp,
|
public static bool SupportsConsolidatedBilling(this Provider provider)
|
||||||
Status: ProviderStatusType.Billable
|
=> provider.Type.SupportsConsolidatedBilling();
|
||||||
};
|
|
||||||
|
public static bool SupportsConsolidatedBilling(this ProviderType providerType)
|
||||||
|
=> providerType is ProviderType.Msp or ProviderType.MultiOrganizationEnterprise;
|
||||||
|
|
||||||
public static bool IsValidClient(this Organization organization)
|
public static bool IsValidClient(this Organization organization)
|
||||||
=> organization is
|
=> organization is
|
||||||
@ -44,5 +46,5 @@ public static class BillingExtensions
|
|||||||
};
|
};
|
||||||
|
|
||||||
public static bool SupportsConsolidatedBilling(this PlanType planType)
|
public static bool SupportsConsolidatedBilling(this PlanType planType)
|
||||||
=> planType is PlanType.TeamsMonthly or PlanType.EnterpriseMonthly;
|
=> planType is PlanType.TeamsMonthly or PlanType.EnterpriseMonthly or PlanType.EnterpriseAnnually;
|
||||||
}
|
}
|
||||||
|
@ -8,6 +8,7 @@ using Bit.Core.Billing.Enums;
|
|||||||
using Bit.Core.Billing.Migration.Models;
|
using Bit.Core.Billing.Migration.Models;
|
||||||
using Bit.Core.Billing.Repositories;
|
using Bit.Core.Billing.Repositories;
|
||||||
using Bit.Core.Billing.Services;
|
using Bit.Core.Billing.Services;
|
||||||
|
using Bit.Core.Billing.Services.Contracts;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
using Bit.Core.Services;
|
using Bit.Core.Services;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
@ -307,7 +308,14 @@ public class ProviderMigrator(
|
|||||||
.FirstOrDefault(providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly)?
|
.FirstOrDefault(providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly)?
|
||||||
.SeatMinimum ?? 0;
|
.SeatMinimum ?? 0;
|
||||||
|
|
||||||
await providerBillingService.UpdateSeatMinimums(provider, enterpriseSeatMinimum, teamsSeatMinimum);
|
var updateSeatMinimumsCommand = new UpdateProviderSeatMinimumsCommand(
|
||||||
|
provider.Id,
|
||||||
|
provider.GatewaySubscriptionId,
|
||||||
|
[
|
||||||
|
(Plan: PlanType.EnterpriseMonthly, SeatsMinimum: enterpriseSeatMinimum),
|
||||||
|
(Plan: PlanType.TeamsMonthly, SeatsMinimum: teamsSeatMinimum)
|
||||||
|
]);
|
||||||
|
await providerBillingService.UpdateSeatMinimums(updateSeatMinimumsCommand);
|
||||||
|
|
||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
"CB: Updated Stripe subscription for provider ({ProviderID}) with current seat minimums", provider.Id);
|
"CB: Updated Stripe subscription for provider ({ProviderID}) with current seat minimums", provider.Id);
|
||||||
@ -325,13 +333,16 @@ public class ProviderMigrator(
|
|||||||
|
|
||||||
var organizationCancellationCredit = organizationCustomers.Sum(customer => customer.Balance);
|
var organizationCancellationCredit = organizationCustomers.Sum(customer => customer.Balance);
|
||||||
|
|
||||||
await stripeAdapter.CustomerBalanceTransactionCreate(provider.GatewayCustomerId,
|
if (organizationCancellationCredit != 0)
|
||||||
new CustomerBalanceTransactionCreateOptions
|
{
|
||||||
{
|
await stripeAdapter.CustomerBalanceTransactionCreate(provider.GatewayCustomerId,
|
||||||
Amount = organizationCancellationCredit,
|
new CustomerBalanceTransactionCreateOptions
|
||||||
Currency = "USD",
|
{
|
||||||
Description = "Unused, prorated time for client organization subscriptions."
|
Amount = organizationCancellationCredit,
|
||||||
});
|
Currency = "USD",
|
||||||
|
Description = "Unused, prorated time for client organization subscriptions."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
var migrationRecords = await Task.WhenAll(organizations.Select(organization =>
|
var migrationRecords = await Task.WhenAll(organizations.Select(organization =>
|
||||||
clientOrganizationMigrationRecordRepository.GetByOrganizationId(organization.Id)));
|
clientOrganizationMigrationRecordRepository.GetByOrganizationId(organization.Id)));
|
||||||
|
@ -2,9 +2,7 @@
|
|||||||
|
|
||||||
public record OrganizationMetadata(
|
public record OrganizationMetadata(
|
||||||
bool IsEligibleForSelfHost,
|
bool IsEligibleForSelfHost,
|
||||||
bool IsOnSecretsManagerStandalone)
|
bool IsManaged,
|
||||||
{
|
bool IsOnSecretsManagerStandalone,
|
||||||
public static OrganizationMetadata Default() => new(
|
bool IsSubscriptionUnpaid,
|
||||||
IsEligibleForSelfHost: false,
|
bool HasSubscription);
|
||||||
IsOnSecretsManagerStandalone: false);
|
|
||||||
}
|
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user