mirror of
https://github.com/bitwarden/server.git
synced 2024-11-21 12:05:42 +01:00
Merge branch 'main' into main
This commit is contained in:
commit
b50ad7cc68
@ -7,7 +7,7 @@
|
||||
"commands": ["swagger"]
|
||||
},
|
||||
"dotnet-ef": {
|
||||
"version": "8.0.6",
|
||||
"version": "8.0.7",
|
||||
"commands": ["dotnet-ef"]
|
||||
}
|
||||
}
|
||||
|
@ -3,6 +3,13 @@
|
||||
"dockerComposeFile": "../../.devcontainer/bitwarden_common/docker-compose.yml",
|
||||
"service": "bitwarden_server",
|
||||
"workspaceFolder": "/workspace",
|
||||
"mounts": [
|
||||
{
|
||||
"source": "../../dev/.data/keys",
|
||||
"target": "/home/vscode/.aspnet/DataProtection-Keys",
|
||||
"type": "bind"
|
||||
}
|
||||
],
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"settings": {},
|
||||
|
@ -3,8 +3,16 @@
|
||||
"dockerComposeFile": [
|
||||
"../../.devcontainer/bitwarden_common/docker-compose.yml",
|
||||
"../../.devcontainer/internal_dev/docker-compose.override.yml"
|
||||
], "service": "bitwarden_server",
|
||||
],
|
||||
"service": "bitwarden_server",
|
||||
"workspaceFolder": "/workspace",
|
||||
"mounts": [
|
||||
{
|
||||
"source": "../../dev/.data/keys",
|
||||
"target": "/home/vscode/.aspnet/DataProtection-Keys",
|
||||
"type": "bind"
|
||||
}
|
||||
],
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"settings": {},
|
||||
|
9
.github/renovate.json
vendored
9
.github/renovate.json
vendored
@ -41,9 +41,9 @@
|
||||
"reviewers": ["team:team-auth-dev"]
|
||||
},
|
||||
{
|
||||
"matchPackageNames": ["bootstrap", "del", "gulp"],
|
||||
"matchPackageNames": ["bootstrap"],
|
||||
"matchUpdateTypes": ["major"],
|
||||
"description": "Lock bootstrap, del, and gulp major versions due to ASP.NET conflicts",
|
||||
"description": "Lock bootstrap major versions due to ASP.NET conflicts",
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
@ -59,8 +59,6 @@
|
||||
"DuoUniversal",
|
||||
"Fido2.AspNet",
|
||||
"Duende.IdentityServer",
|
||||
"Microsoft.Azure.Cosmos",
|
||||
"Microsoft.Extensions.Caching.StackExchangeRedis",
|
||||
"Microsoft.Extensions.Identity.Stores",
|
||||
"Otp.NET",
|
||||
"Sustainsys.Saml2.AspNetCore2",
|
||||
@ -112,12 +110,15 @@
|
||||
"dbup-sqlserver",
|
||||
"dotnet-ef",
|
||||
"linq2db.EntityFrameworkCore",
|
||||
"Microsoft.Azure.Cosmos",
|
||||
"Microsoft.Data.SqlClient",
|
||||
"Microsoft.EntityFrameworkCore.Design",
|
||||
"Microsoft.EntityFrameworkCore.InMemory",
|
||||
"Microsoft.EntityFrameworkCore.Relational",
|
||||
"Microsoft.EntityFrameworkCore.Sqlite",
|
||||
"Microsoft.EntityFrameworkCore.SqlServer",
|
||||
"Microsoft.Extensions.Caching.SqlServer",
|
||||
"Microsoft.Extensions.Caching.StackExchangeRedis",
|
||||
"Npgsql.EntityFrameworkCore.PostgreSQL",
|
||||
"Pomelo.EntityFrameworkCore.MySql"
|
||||
],
|
||||
|
@ -18,7 +18,7 @@ jobs:
|
||||
copy_finalization_scripts: ${{ steps.check-finalization-scripts-existence.outputs.copy_finalization_scripts }}
|
||||
steps:
|
||||
- name: Log in to Azure
|
||||
uses: Azure/login@de95379fe4dadc2defb305917eaa7e5dde727294 # v1.5.1
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
|
||||
|
||||
@ -30,7 +30,7 @@ jobs:
|
||||
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
|
||||
|
||||
- name: Check out branch
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
with:
|
||||
token: ${{ steps.retrieve-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
|
||||
|
||||
@ -54,7 +54,7 @@ jobs:
|
||||
if: ${{ needs.setup.outputs.copy_finalization_scripts == 'true' }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@ -94,7 +94,7 @@ jobs:
|
||||
echo "moved_files=$moved_files" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Log in to Azure - production subscription
|
||||
uses: Azure/login@de95379fe4dadc2defb305917eaa7e5dde727294 # v1.5.1
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
|
||||
|
||||
@ -108,7 +108,7 @@ jobs:
|
||||
devops-alerts-slack-webhook-url"
|
||||
|
||||
- name: Import GPG keys
|
||||
uses: crazy-max/ghaction-import-gpg@82a020f1f7f605c65dd2449b392a52c3fcfef7ef # v6.0.0
|
||||
uses: crazy-max/ghaction-import-gpg@01dd5d3ca463c7f10f7f4f7b4f177225ac661ee4 # v6.1.0
|
||||
with:
|
||||
gpg_private_key: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key }}
|
||||
passphrase: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key-passphrase }}
|
||||
@ -154,7 +154,7 @@ jobs:
|
||||
|
||||
- name: Notify Slack about creation of PR
|
||||
if: ${{ steps.commit.outputs.pr_needed == 'true' }}
|
||||
uses: act10ns/slack@ed1309ab9862e57e9e583e51c7889486b9a00b0f # v2.0.0
|
||||
uses: act10ns/slack@44541246747a30eb3102d87f7a4cc5471b0ffb7d # v2.1.0
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ steps.retrieve-secrets.outputs.devops-alerts-slack-webhook-url }}
|
||||
with:
|
||||
|
30
.github/workflows/build.yml
vendored
30
.github/workflows/build.yml
vendored
@ -19,7 +19,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # v3.2.0
|
||||
@ -68,13 +68,13 @@ jobs:
|
||||
node: true
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # v3.2.0
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@5e21ff4d9bc1a8cf6de233a3057d20ec6b3fb69d # v3.8.1
|
||||
uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3.8.2
|
||||
with:
|
||||
cache: "npm"
|
||||
cache-dependency-path: "**/package-lock.json"
|
||||
@ -173,7 +173,7 @@ jobs:
|
||||
dotnet: true
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
|
||||
- name: Check branch to publish
|
||||
env:
|
||||
@ -190,7 +190,7 @@ jobs:
|
||||
|
||||
########## ACRs ##########
|
||||
- name: Log in to Azure - production subscription
|
||||
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
|
||||
|
||||
@ -198,7 +198,7 @@ jobs:
|
||||
run: az acr login -n bitwardenprod
|
||||
|
||||
- name: Log in to Azure - CI subscription
|
||||
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
|
||||
|
||||
@ -275,14 +275,14 @@ jobs:
|
||||
|
||||
- name: Scan Docker image
|
||||
id: container-scan
|
||||
uses: anchore/scan-action@3343887d815d7b07465f6fdcd395bd66508d486a # v3.6.4
|
||||
uses: anchore/scan-action@d43cc1dfea6a99ed123bf8f3133f1797c9b44492 # v4.1.0
|
||||
with:
|
||||
image: ${{ steps.image-tags.outputs.primary_tag }}
|
||||
fail-build: false
|
||||
output-format: sarif
|
||||
|
||||
- name: Upload Grype results to GitHub
|
||||
uses: github/codeql-action/upload-sarif@b7bf0a3ed3ecfa44160715d7c442788f65f0f923 # v3.23.2
|
||||
uses: github/codeql-action/upload-sarif@2d790406f505036ef40ecba973cc774a50395aac # v3.25.13
|
||||
with:
|
||||
sarif_file: ${{ steps.container-scan.outputs.sarif }}
|
||||
|
||||
@ -292,13 +292,13 @@ jobs:
|
||||
needs: build-docker
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # v3.2.0
|
||||
|
||||
- name: Log in to Azure - production subscription
|
||||
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
|
||||
|
||||
@ -426,7 +426,7 @@ jobs:
|
||||
- win-x64
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # v3.2.0
|
||||
@ -465,7 +465,7 @@ jobs:
|
||||
needs: build-docker
|
||||
steps:
|
||||
- name: Log in to Azure - CI subscription
|
||||
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
|
||||
|
||||
@ -498,7 +498,7 @@ jobs:
|
||||
needs: build-docker
|
||||
steps:
|
||||
- name: Log in to Azure - CI subscription
|
||||
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
|
||||
|
||||
@ -547,7 +547,7 @@ jobs:
|
||||
run: exit 1
|
||||
|
||||
- name: Log in to Azure - CI subscription
|
||||
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
if: failure()
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
|
||||
@ -561,7 +561,7 @@ jobs:
|
||||
secrets: "devops-alerts-slack-webhook-url"
|
||||
|
||||
- name: Notify Slack on failure
|
||||
uses: act10ns/slack@ed1309ab9862e57e9e583e51c7889486b9a00b0f # v2.0.0
|
||||
uses: act10ns/slack@44541246747a30eb3102d87f7a4cc5471b0ffb7d # v2.1.0
|
||||
if: failure()
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ steps.retrieve-secrets.outputs.devops-alerts-slack-webhook-url }}
|
||||
|
2
.github/workflows/cleanup-after-pr.yml
vendored
2
.github/workflows/cleanup-after-pr.yml
vendored
@ -14,7 +14,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Log in to Azure - production subscription
|
||||
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
|
||||
|
||||
|
2
.github/workflows/cleanup-rc-branch.yml
vendored
2
.github/workflows/cleanup-rc-branch.yml
vendored
@ -24,7 +24,7 @@ jobs:
|
||||
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
|
||||
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
with:
|
||||
ref: main
|
||||
token: ${{ steps.retrieve-bot-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
|
||||
|
4
.github/workflows/code-references.yml
vendored
4
.github/workflows/code-references.yml
vendored
@ -16,11 +16,11 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
|
||||
- name: Collect
|
||||
id: collect
|
||||
uses: launchdarkly/find-code-references-in-pull-request@2e9333c88539377cfbe818c265ba8b9ebced3c91 # v1.1.0
|
||||
uses: launchdarkly/find-code-references-in-pull-request@d008aa4f321d8cd35314d9cb095388dcfde84439 # v2.0.0
|
||||
with:
|
||||
project-key: default
|
||||
environment-key: dev
|
||||
|
@ -13,7 +13,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Log in to Azure
|
||||
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
|
||||
|
||||
@ -80,7 +80,7 @@ jobs:
|
||||
run: exit 1
|
||||
|
||||
- name: Log in to Azure - CI subscription
|
||||
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
if: failure()
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
|
||||
@ -94,7 +94,7 @@ jobs:
|
||||
secrets: "devops-alerts-slack-webhook-url"
|
||||
|
||||
- name: Notify Slack on failure
|
||||
uses: act10ns/slack@ed1309ab9862e57e9e583e51c7889486b9a00b0f # v2.0.0
|
||||
uses: act10ns/slack@44541246747a30eb3102d87f7a4cc5471b0ffb7d # v2.1.0
|
||||
if: failure()
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ steps.retrieve-secrets.outputs.devops-alerts-slack-webhook-url }}
|
||||
|
2
.github/workflows/protect-files.yml
vendored
2
.github/workflows/protect-files.yml
vendored
@ -29,7 +29,7 @@ jobs:
|
||||
label: "DB-migrations-changed"
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
|
149
.github/workflows/release.yml
vendored
149
.github/workflows/release.yml
vendored
@ -27,7 +27,7 @@ jobs:
|
||||
branch-name: ${{ steps.branch.outputs.branch-name }}
|
||||
steps:
|
||||
- name: Branch check
|
||||
if: ${{ github.event.inputs.release_type != 'Dry Run' }}
|
||||
if: ${{ inputs.release_type != 'Dry Run' }}
|
||||
run: |
|
||||
if [[ "$GITHUB_REF" != "refs/heads/rc" ]] && [[ "$GITHUB_REF" != "refs/heads/hotfix-rc" ]]; then
|
||||
echo "==================================="
|
||||
@ -37,13 +37,13 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
|
||||
- name: Check release version
|
||||
id: version
|
||||
uses: bitwarden/gh-actions/release-version-check@main
|
||||
with:
|
||||
release-type: ${{ github.event.inputs.release_type }}
|
||||
release-type: ${{ inputs.release_type }}
|
||||
project-type: dotnet
|
||||
file: Directory.Build.props
|
||||
|
||||
@ -53,125 +53,6 @@ jobs:
|
||||
BRANCH_NAME=$(basename ${{ github.ref }})
|
||||
echo "branch-name=$BRANCH_NAME" >> $GITHUB_OUTPUT
|
||||
|
||||
deploy:
|
||||
name: Deploy
|
||||
runs-on: ubuntu-22.04
|
||||
needs: setup
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: Admin
|
||||
- name: Api
|
||||
- name: Billing
|
||||
- name: Events
|
||||
- name: Identity
|
||||
- name: Sso
|
||||
steps:
|
||||
- name: Setup
|
||||
id: setup
|
||||
run: |
|
||||
NAME_LOWER=$(echo "${{ matrix.name }}" | awk '{print tolower($0)}')
|
||||
echo "Matrix name: ${{ matrix.name }}"
|
||||
echo "NAME_LOWER: $NAME_LOWER"
|
||||
echo "name_lower=$NAME_LOWER" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create GitHub deployment for ${{ matrix.name }}
|
||||
if: ${{ github.event.inputs.release_type != 'Dry Run' }}
|
||||
uses: chrnorm/deployment-action@d42cde7132fcec920de534fffc3be83794335c00 # v2.0.5
|
||||
id: deployment
|
||||
with:
|
||||
token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
initial-status: "in_progress"
|
||||
environment: "Production Cloud"
|
||||
task: "deploy"
|
||||
description: "Deploy from ${{ needs.setup.outputs.branch-name }} branch"
|
||||
|
||||
- name: Download latest release ${{ matrix.name }} asset
|
||||
if: ${{ github.event.inputs.release_type != 'Dry Run' }}
|
||||
uses: bitwarden/gh-actions/download-artifacts@main
|
||||
with:
|
||||
workflow: build.yml
|
||||
workflow_conclusion: success
|
||||
branch: ${{ needs.setup.outputs.branch-name }}
|
||||
artifacts: ${{ matrix.name }}.zip
|
||||
|
||||
- name: Dry run - Download latest release ${{ matrix.name }} asset
|
||||
if: ${{ github.event.inputs.release_type == 'Dry Run' }}
|
||||
uses: bitwarden/gh-actions/download-artifacts@main
|
||||
with:
|
||||
workflow: build.yml
|
||||
workflow_conclusion: success
|
||||
branch: main
|
||||
artifacts: ${{ matrix.name }}.zip
|
||||
|
||||
- name: Log in to Azure - CI subscription
|
||||
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
|
||||
|
||||
- name: Retrieve secrets
|
||||
id: retrieve-secrets
|
||||
env:
|
||||
VAULT_NAME: "bitwarden-ci"
|
||||
run: |
|
||||
webapp_name=$(
|
||||
az keyvault secret show --vault-name $VAULT_NAME \
|
||||
--name appservices-${{ steps.setup.outputs.name_lower }}-webapp-name \
|
||||
--query value --output tsv
|
||||
)
|
||||
publish_profile=$(
|
||||
az keyvault secret show --vault-name $VAULT_NAME \
|
||||
--name appservices-${{ steps.setup.outputs.name_lower }}-webapp-publish-profile \
|
||||
--query value --output tsv
|
||||
)
|
||||
echo "::add-mask::$webapp_name"
|
||||
echo "webapp-name=$webapp_name" >> $GITHUB_OUTPUT
|
||||
echo "::add-mask::$publish_profile"
|
||||
echo "publish-profile=$publish_profile" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Log in to Azure
|
||||
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
|
||||
|
||||
- name: Deploy app
|
||||
uses: azure/webapps-deploy@4bca689e4c7129e55923ea9c45401b22dc6aa96f # v2.2.11
|
||||
with:
|
||||
app-name: ${{ steps.retrieve-secrets.outputs.webapp-name }}
|
||||
publish-profile: ${{ steps.retrieve-secrets.outputs.publish-profile }}
|
||||
package: ./${{ matrix.name }}.zip
|
||||
slot-name: "staging"
|
||||
|
||||
- name: Start staging slot
|
||||
if: ${{ github.event.inputs.release_type != 'Dry Run' }}
|
||||
env:
|
||||
SERVICE: ${{ matrix.name }}
|
||||
WEBAPP_NAME: ${{ steps.retrieve-secrets.outputs.webapp-name }}
|
||||
run: |
|
||||
if [[ "$SERVICE" = "Api" ]] || [[ "$SERVICE" = "Identity" ]]; then
|
||||
RESOURCE_GROUP=bitwardenappservices
|
||||
else
|
||||
RESOURCE_GROUP=bitwarden
|
||||
fi
|
||||
az webapp start -n $WEBAPP_NAME -g $RESOURCE_GROUP -s staging
|
||||
|
||||
- name: Update ${{ matrix.name }} deployment status to success
|
||||
if: ${{ github.event.inputs.release_type != 'Dry Run' && success() }}
|
||||
uses: chrnorm/deployment-status@2afb7d27101260f4a764219439564d954d10b5b0 # v2.0.1
|
||||
with:
|
||||
token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
state: "success"
|
||||
deployment-id: ${{ steps.deployment.outputs.deployment_id }}
|
||||
|
||||
- name: Update ${{ matrix.name }} deployment status to failure
|
||||
if: ${{ github.event.inputs.release_type != 'Dry Run' && failure() }}
|
||||
uses: chrnorm/deployment-status@2afb7d27101260f4a764219439564d954d10b5b0 # v2.0.1
|
||||
with:
|
||||
token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
state: "failure"
|
||||
deployment-id: ${{ steps.deployment.outputs.deployment_id }}
|
||||
|
||||
release-docker:
|
||||
name: Build Docker images
|
||||
runs-on: ubuntu-22.04
|
||||
@ -202,7 +83,7 @@ jobs:
|
||||
steps:
|
||||
- name: Print environment
|
||||
env:
|
||||
RELEASE_OPTION: ${{ github.event.inputs.release_type }}
|
||||
RELEASE_OPTION: ${{ inputs.release_type }}
|
||||
run: |
|
||||
whoami
|
||||
docker --version
|
||||
@ -211,7 +92,7 @@ jobs:
|
||||
echo "Github Release Option: $RELEASE_OPTION"
|
||||
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
|
||||
- name: Set up project name
|
||||
id: setup
|
||||
@ -223,7 +104,7 @@ jobs:
|
||||
|
||||
########## ACR PROD ##########
|
||||
- name: Log in to Azure - production subscription
|
||||
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
|
||||
|
||||
@ -234,7 +115,7 @@ jobs:
|
||||
env:
|
||||
PROJECT_NAME: ${{ steps.setup.outputs.project_name }}
|
||||
run: |
|
||||
if [[ "${{ github.event.inputs.release_type }}" == "Dry Run" ]]; then
|
||||
if [[ "${{ inputs.release_type }}" == "Dry Run" ]]; then
|
||||
docker pull $_AZ_REGISTRY/$PROJECT_NAME:latest
|
||||
else
|
||||
docker pull $_AZ_REGISTRY/$PROJECT_NAME:$_BRANCH_NAME
|
||||
@ -244,7 +125,7 @@ jobs:
|
||||
env:
|
||||
PROJECT_NAME: ${{ steps.setup.outputs.project_name }}
|
||||
run: |
|
||||
if [[ "${{ github.event.inputs.release_type }}" == "Dry Run" ]]; then
|
||||
if [[ "${{ inputs.release_type }}" == "Dry Run" ]]; then
|
||||
docker tag $_AZ_REGISTRY/$PROJECT_NAME:latest $_AZ_REGISTRY/$PROJECT_NAME:dryrun
|
||||
else
|
||||
docker tag $_AZ_REGISTRY/$PROJECT_NAME:$_BRANCH_NAME $_AZ_REGISTRY/$PROJECT_NAME:$_RELEASE_VERSION
|
||||
@ -255,7 +136,7 @@ jobs:
|
||||
env:
|
||||
PROJECT_NAME: ${{ steps.setup.outputs.project_name }}
|
||||
run: |
|
||||
if [[ "${{ github.event.inputs.release_type }}" == "Dry Run" ]]; then
|
||||
if [[ "${{ inputs.release_type }}" == "Dry Run" ]]; then
|
||||
docker push $_AZ_REGISTRY/$PROJECT_NAME:dryrun
|
||||
else
|
||||
docker push $_AZ_REGISTRY/$PROJECT_NAME:$_RELEASE_VERSION
|
||||
@ -268,12 +149,10 @@ jobs:
|
||||
release:
|
||||
name: Create GitHub release
|
||||
runs-on: ubuntu-22.04
|
||||
needs:
|
||||
- setup
|
||||
- deploy
|
||||
needs: setup
|
||||
steps:
|
||||
- name: Download latest release Docker stubs
|
||||
if: ${{ github.event.inputs.release_type != 'Dry Run' }}
|
||||
if: ${{ inputs.release_type != 'Dry Run' }}
|
||||
uses: bitwarden/gh-actions/download-artifacts@main
|
||||
with:
|
||||
workflow: build.yml
|
||||
@ -286,7 +165,7 @@ jobs:
|
||||
swagger.json"
|
||||
|
||||
- name: Dry Run - Download latest release Docker stubs
|
||||
if: ${{ github.event.inputs.release_type == 'Dry Run' }}
|
||||
if: ${{ inputs.release_type == 'Dry Run' }}
|
||||
uses: bitwarden/gh-actions/download-artifacts@main
|
||||
with:
|
||||
workflow: build.yml
|
||||
@ -299,8 +178,8 @@ jobs:
|
||||
swagger.json"
|
||||
|
||||
- name: Create release
|
||||
if: ${{ github.event.inputs.release_type != 'Dry Run' }}
|
||||
uses: ncipollo/release-action@6c75be85e571768fa31b40abf38de58ba0397db5 # v1.13.0
|
||||
if: ${{ inputs.release_type != 'Dry Run' }}
|
||||
uses: ncipollo/release-action@2c591bcc8ecdcd2db72b97d6147f871fcd833ba5 # v1.14.0
|
||||
with:
|
||||
artifacts: "docker-stub-US.zip,
|
||||
docker-stub-US-sha256.txt,
|
||||
|
34
.github/workflows/scan.yml
vendored
34
.github/workflows/scan.yml
vendored
@ -26,12 +26,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Scan with Checkmarx
|
||||
uses: checkmarx/ast-github-action@749fec53e0db0f6404a97e2e0807c3e80e3583a7 #2.0.23
|
||||
uses: checkmarx/ast-github-action@4c637b1cb6b6b63637c7b99578c9fceefebbb08d # 2.0.30
|
||||
env:
|
||||
INCREMENTAL: "${{ contains(github.event_name, 'pull_request') && '--sast-incremental' || '' }}"
|
||||
with:
|
||||
@ -46,7 +46,7 @@ jobs:
|
||||
--output-path . ${{ env.INCREMENTAL }}
|
||||
|
||||
- name: Upload Checkmarx results to GitHub
|
||||
uses: github/codeql-action/upload-sarif@1b1aada464948af03b950897e5eb522f92603cc2 # v3.24.9
|
||||
uses: github/codeql-action/upload-sarif@2d790406f505036ef40ecba973cc774a50395aac # v3.25.13
|
||||
with:
|
||||
sarif_file: cx_result.sarif
|
||||
|
||||
@ -59,19 +59,33 @@ jobs:
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@99b8673ff64fbf99d8d325f52d9a5bdedb8483e9 # v4.2.1
|
||||
with:
|
||||
java-version: 17
|
||||
distribution: "zulu"
|
||||
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
|
||||
|
||||
- name: Install SonarCloud scanner
|
||||
run: dotnet tool install dotnet-sonarscanner -g
|
||||
|
||||
- name: Scan with SonarCloud
|
||||
uses: sonarsource/sonarcloud-github-action@49e6cd3b187936a73b8280d59ffd9da69df63ec9 # v2.1.1
|
||||
env:
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
args: >
|
||||
-Dsonar.organization=${{ github.repository_owner }}
|
||||
-Dsonar.projectKey=${{ github.repository_owner }}_${{ github.event.repository.name }}
|
||||
-Dsonar.tests=test/
|
||||
run: |
|
||||
dotnet-sonarscanner begin /k:"${{ github.repository_owner }}_${{ github.event.repository.name }}" \
|
||||
/d:sonar.test.inclusions=test/,bitwarden_license/test/ \
|
||||
/d:sonar.exclusions=test/,bitwarden_license/test/ \
|
||||
/o:"${{ github.repository_owner }}" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" \
|
||||
/d:sonar.host.url="https://sonarcloud.io"
|
||||
dotnet build
|
||||
dotnet-sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}"
|
||||
|
64
.github/workflows/stop-staging-slots.yml
vendored
64
.github/workflows/stop-staging-slots.yml
vendored
@ -1,64 +0,0 @@
|
||||
---
|
||||
name: Stop staging slots
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs: {}
|
||||
|
||||
jobs:
|
||||
stop-slots:
|
||||
name: Stop slots
|
||||
runs-on: ubuntu-22.04
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: Api
|
||||
- name: Admin
|
||||
- name: Billing
|
||||
- name: Events
|
||||
- name: Sso
|
||||
- name: Identity
|
||||
steps:
|
||||
- name: Setup
|
||||
id: setup
|
||||
run: |
|
||||
NAME_LOWER=$(echo "${{ matrix.name }}" | awk '{print tolower($0)}')
|
||||
echo "Matrix name: ${{ matrix.name }}"
|
||||
echo "NAME_LOWER: $NAME_LOWER"
|
||||
echo "name_lower=$NAME_LOWER" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Log in to Azure - CI subscription
|
||||
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
|
||||
|
||||
- name: Retrieve secrets
|
||||
id: retrieve-secrets
|
||||
env:
|
||||
VAULT_NAME: "bitwarden-ci"
|
||||
run: |
|
||||
webapp_name=$(
|
||||
az keyvault secret show --vault-name $VAULT_NAME \
|
||||
--name appservices-${{ steps.setup.outputs.name_lower }}-webapp-name \
|
||||
--query value --output tsv
|
||||
)
|
||||
echo "::add-mask::$webapp_name"
|
||||
echo "webapp-name=$webapp_name" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Log in to Azure
|
||||
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
|
||||
|
||||
- name: Stop staging slot
|
||||
env:
|
||||
SERVICE: ${{ matrix.name }}
|
||||
WEBAPP_NAME: ${{ steps.retrieve-secrets.outputs.webapp-name }}
|
||||
run: |
|
||||
if [[ "$SERVICE" = "Api" ]] || [[ "$SERVICE" = "Identity" ]]; then
|
||||
RESOURCE_GROUP=bitwardenappservices
|
||||
else
|
||||
RESOURCE_GROUP=bitwarden
|
||||
fi
|
||||
az webapp stop -n $WEBAPP_NAME -g $RESOURCE_GROUP -s staging
|
6
.github/workflows/test-database.yml
vendored
6
.github/workflows/test-database.yml
vendored
@ -36,7 +36,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # v3.2.0
|
||||
@ -98,7 +98,7 @@ jobs:
|
||||
shell: pwsh
|
||||
|
||||
- name: Report test results
|
||||
uses: dorny/test-reporter@c9b3d0e2bd2a4e96aaf424dbaa31c46b42318226 # v1.6.0
|
||||
uses: dorny/test-reporter@31a54ee7ebcacc03a09ea97a7e5465a47b84aea5 # v1.9.1
|
||||
if: always()
|
||||
with:
|
||||
name: Test Results
|
||||
@ -117,7 +117,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # v3.2.0
|
||||
|
6
.github/workflows/test.yml
vendored
6
.github/workflows/test.yml
vendored
@ -22,7 +22,7 @@ jobs:
|
||||
NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # v3.2.0
|
||||
@ -44,7 +44,7 @@ jobs:
|
||||
run: dotnet test ./bitwarden_license/test --configuration Debug --logger "trx;LogFileName=bw-test-results.trx" /p:CoverletOutputFormatter="cobertura" --collect:"XPlat Code Coverage"
|
||||
|
||||
- name: Report test results
|
||||
uses: dorny/test-reporter@c9b3d0e2bd2a4e96aaf424dbaa31c46b42318226 # v1.6.0
|
||||
uses: dorny/test-reporter@31a54ee7ebcacc03a09ea97a7e5465a47b84aea5 # v1.9.1
|
||||
if: always()
|
||||
with:
|
||||
name: Test Results
|
||||
@ -53,6 +53,6 @@ jobs:
|
||||
fail-on-error: true
|
||||
|
||||
- name: Upload to codecov.io
|
||||
uses: codecov/codecov-action@0cfda1dd0a4ad9efc75517f399d859cd1ea4ced1 # v4.0.2
|
||||
uses: codecov/codecov-action@e28ff129e5465c2c0dcc6f003fc735cb6ae0c673 # v4.5.0
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
10
.github/workflows/version-bump.yml
vendored
10
.github/workflows/version-bump.yml
vendored
@ -39,9 +39,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Check out branch
|
||||
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
|
||||
with:
|
||||
ref: main
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
|
||||
- name: Check if RC branch exists
|
||||
if: ${{ inputs.cut_rc_branch == true }}
|
||||
@ -54,7 +52,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Log in to Azure - CI subscription
|
||||
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
|
||||
|
||||
@ -68,7 +66,7 @@ jobs:
|
||||
github-pat-bitwarden-devops-bot-repo-scope"
|
||||
|
||||
- name: Import GPG key
|
||||
uses: crazy-max/ghaction-import-gpg@82a020f1f7f605c65dd2449b392a52c3fcfef7ef # v6.0.0
|
||||
uses: crazy-max/ghaction-import-gpg@01dd5d3ca463c7f10f7f4f7b4f177225ac661ee4 # v6.1.0
|
||||
with:
|
||||
gpg_private_key: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key }}
|
||||
passphrase: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key-passphrase }}
|
||||
@ -225,7 +223,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Check out branch
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
with:
|
||||
ref: main
|
||||
|
||||
|
6
.gitignore
vendored
6
.gitignore
vendored
@ -205,12 +205,10 @@ mail_dist/
|
||||
src/Core/Properties/launchSettings.json
|
||||
*.override.env
|
||||
**/*.DS_Store
|
||||
src/Admin/wwwroot/lib
|
||||
src/Admin/wwwroot/css
|
||||
src/Admin/wwwroot/assets
|
||||
.vscode/*
|
||||
**/.vscode/*
|
||||
bitwarden_license/src/Sso/wwwroot/lib
|
||||
bitwarden_license/src/Sso/wwwroot/css
|
||||
bitwarden_license/src/Sso/wwwroot/assets
|
||||
.github/test/build.secrets
|
||||
**/CoverageOutput/
|
||||
.idea/*
|
||||
|
33
.vscode/tasks.json
vendored
33
.vscode/tasks.json
vendored
@ -3,6 +3,7 @@
|
||||
"tasks": [
|
||||
{
|
||||
"label": "buildIdentityApi",
|
||||
"hide": true,
|
||||
"dependsOrder": "sequence",
|
||||
"dependsOn": [
|
||||
"buildIdentity",
|
||||
@ -14,6 +15,7 @@
|
||||
},
|
||||
{
|
||||
"label": "buildIdentityApiAdmin",
|
||||
"hide": true,
|
||||
"dependsOrder": "sequence",
|
||||
"dependsOn": [
|
||||
"buildIdentity",
|
||||
@ -26,6 +28,7 @@
|
||||
},
|
||||
{
|
||||
"label": "buildFullServer",
|
||||
"hide": true,
|
||||
"dependsOrder": "sequence",
|
||||
"dependsOn": [
|
||||
"buildAdmin",
|
||||
@ -40,6 +43,7 @@
|
||||
},
|
||||
{
|
||||
"label": "buildSelfHostBit",
|
||||
"hide": true,
|
||||
"dependsOrder": "sequence",
|
||||
"dependsOn": [
|
||||
"buildAdmin",
|
||||
@ -52,6 +56,7 @@
|
||||
},
|
||||
{
|
||||
"label": "buildSelfHostOss",
|
||||
"hide": true,
|
||||
"dependsOrder": "sequence",
|
||||
"dependsOn": [
|
||||
"buildAdmin",
|
||||
@ -62,6 +67,7 @@
|
||||
},
|
||||
{
|
||||
"label": "buildIcons",
|
||||
"hide": true,
|
||||
"command": "dotnet",
|
||||
"type": "process",
|
||||
"args": [
|
||||
@ -74,6 +80,7 @@
|
||||
},
|
||||
{
|
||||
"label": "buildPortal",
|
||||
"hide": true,
|
||||
"command": "dotnet",
|
||||
"type": "process",
|
||||
"args": [
|
||||
@ -86,6 +93,7 @@
|
||||
},
|
||||
{
|
||||
"label": "buildSso",
|
||||
"hide": true,
|
||||
"command": "dotnet",
|
||||
"type": "process",
|
||||
"args": [
|
||||
@ -98,6 +106,7 @@
|
||||
},
|
||||
{
|
||||
"label": "buildEvents",
|
||||
"hide": true,
|
||||
"command": "dotnet",
|
||||
"type": "process",
|
||||
"args": [
|
||||
@ -110,6 +119,7 @@
|
||||
},
|
||||
{
|
||||
"label": "buildEventsProcessor",
|
||||
"hide": true,
|
||||
"command": "dotnet",
|
||||
"type": "process",
|
||||
"args": [
|
||||
@ -122,6 +132,7 @@
|
||||
},
|
||||
{
|
||||
"label": "buildAdmin",
|
||||
"hide": true,
|
||||
"command": "dotnet",
|
||||
"type": "process",
|
||||
"args": [
|
||||
@ -134,6 +145,7 @@
|
||||
},
|
||||
{
|
||||
"label": "buildIdentity",
|
||||
"hide": true,
|
||||
"command": "dotnet",
|
||||
"type": "process",
|
||||
"args": [
|
||||
@ -146,6 +158,7 @@
|
||||
},
|
||||
{
|
||||
"label": "buildAPI",
|
||||
"hide": true,
|
||||
"command": "dotnet",
|
||||
"type": "process",
|
||||
"args": [
|
||||
@ -162,6 +175,7 @@
|
||||
},
|
||||
{
|
||||
"label": "buildNotifications",
|
||||
"hide": true,
|
||||
"command": "dotnet",
|
||||
"type": "process",
|
||||
"args": [
|
||||
@ -178,6 +192,7 @@
|
||||
},
|
||||
{
|
||||
"label": "buildBilling",
|
||||
"hide": true,
|
||||
"command": "dotnet",
|
||||
"type": "process",
|
||||
"args": [
|
||||
@ -192,20 +207,6 @@
|
||||
"isDefault": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "clean",
|
||||
"type": "shell",
|
||||
"command": "dotnet clean",
|
||||
"presentation": {
|
||||
"echo": true,
|
||||
"reveal": "always",
|
||||
"focus": false,
|
||||
"panel": "shared",
|
||||
"showReuseMessage": true,
|
||||
"clear": false
|
||||
},
|
||||
"problemMatcher": "$msCompile"
|
||||
},
|
||||
{
|
||||
"label": "test",
|
||||
"type": "shell",
|
||||
@ -225,13 +226,15 @@
|
||||
"problemMatcher": "$msCompile"
|
||||
},
|
||||
{
|
||||
"label": "Setup Secrets",
|
||||
"label": "Set Up Secrets",
|
||||
"detail": "A task to run setup_secrets.ps1",
|
||||
"type": "shell",
|
||||
"command": "pwsh -WorkingDirectory ${workspaceFolder}/dev -Command '${workspaceFolder}/dev/setup_secrets.ps1 -clear:$${input:setupSecretsClear}'",
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Install Dev Cert",
|
||||
"detail": "A task to install the Bitwarden developer cert to run your local install as an admin.",
|
||||
"type": "shell",
|
||||
"command": "dotnet tool install -g dotnet-certificate-tool -g && certificate-tool add --file ${workspaceFolder}/dev/dev.pfx --password '${input:certPassword}'",
|
||||
"problemMatcher": []
|
||||
|
@ -3,7 +3,7 @@
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
|
||||
<Version>2024.7.0</Version>
|
||||
<Version>2024.7.4</Version>
|
||||
|
||||
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
|
@ -9,6 +9,7 @@ using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
@ -45,6 +46,7 @@ public class ProviderService : IProviderService
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IDataProtectorTokenFactory<ProviderDeleteTokenable> _providerDeleteTokenDataFactory;
|
||||
private readonly IApplicationCacheService _applicationCacheService;
|
||||
private readonly IProviderBillingService _providerBillingService;
|
||||
|
||||
public ProviderService(IProviderRepository providerRepository, IProviderUserRepository providerUserRepository,
|
||||
IProviderOrganizationRepository providerOrganizationRepository, IUserRepository userRepository,
|
||||
@ -53,7 +55,7 @@ public class ProviderService : IProviderService
|
||||
IOrganizationRepository organizationRepository, GlobalSettings globalSettings,
|
||||
ICurrentContext currentContext, IStripeAdapter stripeAdapter, IFeatureService featureService,
|
||||
IDataProtectorTokenFactory<ProviderDeleteTokenable> providerDeleteTokenDataFactory,
|
||||
IApplicationCacheService applicationCacheService)
|
||||
IApplicationCacheService applicationCacheService, IProviderBillingService providerBillingService)
|
||||
{
|
||||
_providerRepository = providerRepository;
|
||||
_providerUserRepository = providerUserRepository;
|
||||
@ -71,9 +73,10 @@ public class ProviderService : IProviderService
|
||||
_featureService = featureService;
|
||||
_providerDeleteTokenDataFactory = providerDeleteTokenDataFactory;
|
||||
_applicationCacheService = applicationCacheService;
|
||||
_providerBillingService = providerBillingService;
|
||||
}
|
||||
|
||||
public async Task<Provider> CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key)
|
||||
public async Task<Provider> CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TaxInfo taxInfo = null)
|
||||
{
|
||||
var owner = await _userService.GetUserByIdAsync(ownerUserId);
|
||||
if (owner == null)
|
||||
@ -98,8 +101,24 @@ public class ProviderService : IProviderService
|
||||
throw new BadRequestException("Invalid owner.");
|
||||
}
|
||||
|
||||
provider.Status = ProviderStatusType.Created;
|
||||
await _providerRepository.UpsertAsync(provider);
|
||||
if (!_featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling))
|
||||
{
|
||||
provider.Status = ProviderStatusType.Created;
|
||||
await _providerRepository.UpsertAsync(provider);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (taxInfo == null || string.IsNullOrEmpty(taxInfo.BillingAddressCountry) || string.IsNullOrEmpty(taxInfo.BillingAddressPostalCode))
|
||||
{
|
||||
throw new BadRequestException("Both address and postal code are required to set up your provider.");
|
||||
}
|
||||
var customer = await _providerBillingService.SetupCustomer(provider, taxInfo);
|
||||
provider.GatewayCustomerId = customer.Id;
|
||||
var subscription = await _providerBillingService.SetupSubscription(provider);
|
||||
provider.GatewaySubscriptionId = subscription.Id;
|
||||
provider.Status = ProviderStatusType.Billable;
|
||||
await _providerRepository.UpsertAsync(provider);
|
||||
}
|
||||
|
||||
providerUser.Key = key;
|
||||
await _providerUserRepository.ReplaceAsync(providerUser);
|
||||
@ -544,9 +563,9 @@ public class ProviderService : IProviderService
|
||||
await _providerOrganizationRepository.CreateAsync(providerOrganization);
|
||||
await _eventService.LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_Created);
|
||||
|
||||
// If using Flexible Collections, give the owner Can Manage access over the default collection
|
||||
// Give the owner Can Manage access over the default collection
|
||||
// The orgUser is not available when the org is created so we have to do it here as part of the invite
|
||||
var defaultOwnerAccess = organization.FlexibleCollections && defaultCollection != null
|
||||
var defaultOwnerAccess = defaultCollection != null
|
||||
?
|
||||
[
|
||||
new CollectionAccessSelection
|
||||
@ -566,10 +585,6 @@ public class ProviderService : IProviderService
|
||||
new OrganizationUserInvite
|
||||
{
|
||||
Emails = new[] { clientOwnerEmail },
|
||||
|
||||
// If using Flexible Collections, AccessAll is deprecated and set to false.
|
||||
// If not using Flexible Collections, set AccessAll to true (previous behavior)
|
||||
AccessAll = !organization.FlexibleCollections,
|
||||
Type = OrganizationUserType.Owner,
|
||||
Permissions = null,
|
||||
Collections = defaultOwnerAccess,
|
||||
|
@ -9,11 +9,11 @@ using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Entities;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Billing.Repositories;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Business;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
@ -22,7 +22,6 @@ using Bit.Core.Utilities;
|
||||
using CsvHelper;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Stripe;
|
||||
using static Bit.Core.Billing.Utilities;
|
||||
|
||||
namespace Bit.Commercial.Core.Billing;
|
||||
|
||||
@ -69,67 +68,6 @@ public class ProviderBillingService(
|
||||
await organizationRepository.ReplaceAsync(organization);
|
||||
}
|
||||
|
||||
public async Task CreateCustomer(
|
||||
Provider provider,
|
||||
TaxInfo taxInfo)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(provider);
|
||||
ArgumentNullException.ThrowIfNull(taxInfo);
|
||||
|
||||
if (string.IsNullOrEmpty(taxInfo.BillingAddressCountry) ||
|
||||
string.IsNullOrEmpty(taxInfo.BillingAddressPostalCode))
|
||||
{
|
||||
logger.LogError("Cannot create customer for provider ({ProviderID}) without both a country and postal code", provider.Id);
|
||||
|
||||
throw ContactSupport();
|
||||
}
|
||||
|
||||
var providerDisplayName = provider.DisplayName();
|
||||
|
||||
var customerCreateOptions = new CustomerCreateOptions
|
||||
{
|
||||
Address = new AddressOptions
|
||||
{
|
||||
Country = taxInfo.BillingAddressCountry,
|
||||
PostalCode = taxInfo.BillingAddressPostalCode,
|
||||
Line1 = taxInfo.BillingAddressLine1,
|
||||
Line2 = taxInfo.BillingAddressLine2,
|
||||
City = taxInfo.BillingAddressCity,
|
||||
State = taxInfo.BillingAddressState
|
||||
},
|
||||
Description = provider.DisplayBusinessName(),
|
||||
Email = provider.BillingEmail,
|
||||
InvoiceSettings = new CustomerInvoiceSettingsOptions
|
||||
{
|
||||
CustomFields =
|
||||
[
|
||||
new CustomerInvoiceSettingsCustomFieldOptions
|
||||
{
|
||||
Name = provider.SubscriberType(),
|
||||
Value = providerDisplayName.Length <= 30
|
||||
? providerDisplayName
|
||||
: providerDisplayName[..30]
|
||||
}
|
||||
]
|
||||
},
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
{ "region", globalSettings.BaseServiceUri.CloudRegion }
|
||||
},
|
||||
TaxIdData = taxInfo.HasTaxId ?
|
||||
[
|
||||
new CustomerTaxIdDataOptions { Type = taxInfo.TaxIdType, Value = taxInfo.TaxIdNumber }
|
||||
]
|
||||
: null
|
||||
};
|
||||
|
||||
var customer = await stripeAdapter.CustomerCreateAsync(customerCreateOptions);
|
||||
|
||||
provider.GatewayCustomerId = customer.Id;
|
||||
|
||||
await providerRepository.ReplaceAsync(provider);
|
||||
}
|
||||
|
||||
public async Task CreateCustomerForClientOrganization(
|
||||
Provider provider,
|
||||
Organization organization)
|
||||
@ -204,15 +142,14 @@ public class ProviderBillingService(
|
||||
public async Task<byte[]> GenerateClientInvoiceReport(
|
||||
string invoiceId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(invoiceId))
|
||||
{
|
||||
throw new ArgumentNullException(nameof(invoiceId));
|
||||
}
|
||||
ArgumentException.ThrowIfNullOrEmpty(invoiceId);
|
||||
|
||||
var invoiceItems = await providerInvoiceItemRepository.GetByInvoiceId(invoiceId);
|
||||
|
||||
if (invoiceItems.Count == 0)
|
||||
{
|
||||
logger.LogError("No provider invoice item records were found for invoice ({InvoiceID})", invoiceId);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -245,14 +182,14 @@ public class ProviderBillingService(
|
||||
"Could not find provider ({ID}) when retrieving assigned seat total",
|
||||
providerId);
|
||||
|
||||
throw ContactSupport();
|
||||
throw new BillingException();
|
||||
}
|
||||
|
||||
if (provider.Type == ProviderType.Reseller)
|
||||
{
|
||||
logger.LogError("Assigned seats cannot be retrieved for reseller-type provider ({ID})", providerId);
|
||||
|
||||
throw ContactSupport("Consolidated billing does not support reseller-type providers");
|
||||
throw new BillingException();
|
||||
}
|
||||
|
||||
var providerOrganizations = await providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId);
|
||||
@ -264,39 +201,6 @@ public class ProviderBillingService(
|
||||
.Sum(providerOrganization => providerOrganization.Seats ?? 0);
|
||||
}
|
||||
|
||||
public async Task<ConsolidatedBillingSubscriptionDTO> GetConsolidatedBillingSubscription(
|
||||
Provider provider)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(provider);
|
||||
|
||||
var subscription = await subscriberService.GetSubscription(provider, new SubscriptionGetOptions
|
||||
{
|
||||
Expand = ["customer", "test_clock"]
|
||||
});
|
||||
|
||||
if (subscription == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
|
||||
|
||||
var configuredProviderPlans = providerPlans
|
||||
.Where(providerPlan => providerPlan.IsConfigured())
|
||||
.Select(ConfiguredProviderPlanDTO.From)
|
||||
.ToList();
|
||||
|
||||
var taxInformation = await subscriberService.GetTaxInformation(provider);
|
||||
|
||||
var suspension = await GetSuspensionAsync(stripeAdapter, subscription);
|
||||
|
||||
return new ConsolidatedBillingSubscriptionDTO(
|
||||
configuredProviderPlans,
|
||||
subscription,
|
||||
taxInformation,
|
||||
suspension);
|
||||
}
|
||||
|
||||
public async Task ScaleSeats(
|
||||
Provider provider,
|
||||
PlanType planType,
|
||||
@ -308,14 +212,14 @@ public class ProviderBillingService(
|
||||
{
|
||||
logger.LogError("Non-MSP provider ({ProviderID}) cannot scale their seats", provider.Id);
|
||||
|
||||
throw ContactSupport();
|
||||
throw new BillingException();
|
||||
}
|
||||
|
||||
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 ContactSupport();
|
||||
throw new BillingException();
|
||||
}
|
||||
|
||||
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
|
||||
@ -326,7 +230,7 @@ public class ProviderBillingService(
|
||||
{
|
||||
logger.LogError("Cannot scale provider ({ProviderID}) seats for plan type {PlanType} when their matching provider plan is not configured", provider.Id, planType);
|
||||
|
||||
throw ContactSupport();
|
||||
throw new BillingException();
|
||||
}
|
||||
|
||||
var seatMinimum = providerPlan.SeatMinimum.GetValueOrDefault(0);
|
||||
@ -362,7 +266,7 @@ public class ProviderBillingService(
|
||||
{
|
||||
logger.LogError("Service user for provider ({ProviderID}) cannot scale a provider's seat count over the seat minimum", provider.Id);
|
||||
|
||||
throw ContactSupport();
|
||||
throw new BillingException();
|
||||
}
|
||||
|
||||
await update(
|
||||
@ -393,7 +297,71 @@ public class ProviderBillingService(
|
||||
}
|
||||
}
|
||||
|
||||
public async Task StartSubscription(
|
||||
public async Task<Customer> SetupCustomer(
|
||||
Provider provider,
|
||||
TaxInfo taxInfo)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(provider);
|
||||
ArgumentNullException.ThrowIfNull(taxInfo);
|
||||
|
||||
if (string.IsNullOrEmpty(taxInfo.BillingAddressCountry) ||
|
||||
string.IsNullOrEmpty(taxInfo.BillingAddressPostalCode))
|
||||
{
|
||||
logger.LogError("Cannot create customer for provider ({ProviderID}) without both a country and postal code", provider.Id);
|
||||
|
||||
throw new BillingException();
|
||||
}
|
||||
|
||||
var providerDisplayName = provider.DisplayName();
|
||||
|
||||
var customerCreateOptions = new CustomerCreateOptions
|
||||
{
|
||||
Address = new AddressOptions
|
||||
{
|
||||
Country = taxInfo.BillingAddressCountry,
|
||||
PostalCode = taxInfo.BillingAddressPostalCode,
|
||||
Line1 = taxInfo.BillingAddressLine1,
|
||||
Line2 = taxInfo.BillingAddressLine2,
|
||||
City = taxInfo.BillingAddressCity,
|
||||
State = taxInfo.BillingAddressState
|
||||
},
|
||||
Description = provider.DisplayBusinessName(),
|
||||
Email = provider.BillingEmail,
|
||||
InvoiceSettings = new CustomerInvoiceSettingsOptions
|
||||
{
|
||||
CustomFields =
|
||||
[
|
||||
new CustomerInvoiceSettingsCustomFieldOptions
|
||||
{
|
||||
Name = provider.SubscriberType(),
|
||||
Value = providerDisplayName?.Length <= 30
|
||||
? providerDisplayName
|
||||
: providerDisplayName?[..30]
|
||||
}
|
||||
]
|
||||
},
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
{ "region", globalSettings.BaseServiceUri.CloudRegion }
|
||||
},
|
||||
TaxIdData = taxInfo.HasTaxId ?
|
||||
[
|
||||
new CustomerTaxIdDataOptions { Type = taxInfo.TaxIdType, Value = taxInfo.TaxIdNumber }
|
||||
]
|
||||
: null
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
return await stripeAdapter.CustomerCreateAsync(customerCreateOptions);
|
||||
}
|
||||
catch (StripeException stripeException) when (stripeException.StripeError?.Code == StripeConstants.ErrorCodes.TaxIdInvalid)
|
||||
{
|
||||
throw new BadRequestException("Your tax ID wasn't recognized for your selected country. Please ensure your country and tax ID are valid.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Subscription> SetupSubscription(
|
||||
Provider provider)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(provider);
|
||||
@ -406,7 +374,7 @@ public class ProviderBillingService(
|
||||
{
|
||||
logger.LogError("Cannot start subscription for provider ({ProviderID}) that has no configured plans", provider.Id);
|
||||
|
||||
throw ContactSupport();
|
||||
throw new BillingException();
|
||||
}
|
||||
|
||||
var subscriptionItemOptionsList = new List<SubscriptionItemOptions>();
|
||||
@ -418,7 +386,7 @@ public class ProviderBillingService(
|
||||
{
|
||||
logger.LogError("Cannot start subscription for provider ({ProviderID}) that has no configured Teams plan", provider.Id);
|
||||
|
||||
throw ContactSupport();
|
||||
throw new BillingException();
|
||||
}
|
||||
|
||||
var teamsPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
|
||||
@ -436,7 +404,7 @@ public class ProviderBillingService(
|
||||
{
|
||||
logger.LogError("Cannot start subscription for provider ({ProviderID}) that has no configured Enterprise plan", provider.Id);
|
||||
|
||||
throw ContactSupport();
|
||||
throw new BillingException();
|
||||
}
|
||||
|
||||
var enterprisePlan = StaticStore.GetPlan(PlanType.EnterpriseMonthly);
|
||||
@ -465,22 +433,140 @@ public class ProviderBillingService(
|
||||
ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations
|
||||
};
|
||||
|
||||
var subscription = await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
|
||||
|
||||
provider.GatewaySubscriptionId = subscription.Id;
|
||||
|
||||
if (subscription.Status == StripeConstants.SubscriptionStatus.Incomplete)
|
||||
try
|
||||
{
|
||||
await providerRepository.ReplaceAsync(provider);
|
||||
var subscription = await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
|
||||
|
||||
logger.LogError("Started incomplete provider ({ProviderID}) subscription ({SubscriptionID})", provider.Id, subscription.Id);
|
||||
if (subscription.Status == StripeConstants.SubscriptionStatus.Active)
|
||||
{
|
||||
return subscription;
|
||||
}
|
||||
|
||||
throw ContactSupport();
|
||||
logger.LogError(
|
||||
"Newly created provider ({ProviderID}) subscription ({SubscriptionID}) has inactive status: {Status}",
|
||||
provider.Id,
|
||||
subscription.Id,
|
||||
subscription.Status);
|
||||
|
||||
throw new BillingException();
|
||||
}
|
||||
catch (StripeException stripeException) when (stripeException.StripeError?.Code == StripeConstants.ErrorCodes.CustomerTaxLocationInvalid)
|
||||
{
|
||||
throw new BadRequestException("Your location wasn't recognized. Please ensure your country and postal code are valid.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task UpdateSeatMinimums(
|
||||
Provider provider,
|
||||
int enterpriseSeatMinimum,
|
||||
int teamsSeatMinimum)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(provider);
|
||||
|
||||
if (enterpriseSeatMinimum < 0 || teamsSeatMinimum < 0)
|
||||
{
|
||||
throw new BadRequestException("Provider seat minimums must be at least 0.");
|
||||
}
|
||||
|
||||
provider.Status = ProviderStatusType.Billable;
|
||||
var subscription = await stripeAdapter.SubscriptionGetAsync(provider.GatewaySubscriptionId);
|
||||
|
||||
await providerRepository.ReplaceAsync(provider);
|
||||
var subscriptionItemOptionsList = new List<SubscriptionItemOptions>();
|
||||
|
||||
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
|
||||
|
||||
var enterpriseProviderPlan =
|
||||
providerPlans.Single(providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly);
|
||||
|
||||
if (enterpriseProviderPlan.SeatMinimum != enterpriseSeatMinimum)
|
||||
{
|
||||
var enterprisePriceId = StaticStore.GetPlan(PlanType.EnterpriseMonthly).PasswordManager
|
||||
.StripeProviderPortalSeatPlanId;
|
||||
|
||||
var enterpriseSubscriptionItem = subscription.Items.First(item => item.Price.Id == enterprisePriceId);
|
||||
|
||||
if (enterpriseProviderPlan.PurchasedSeats == 0)
|
||||
{
|
||||
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
|
||||
{
|
||||
Id = enterpriseSubscriptionItem.Id,
|
||||
Price = enterprisePriceId,
|
||||
Quantity = enterpriseSeatMinimum
|
||||
});
|
||||
}
|
||||
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)
|
||||
{
|
||||
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)
|
||||
{
|
||||
await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId,
|
||||
new SubscriptionUpdateOptions { Items = subscriptionItemOptionsList });
|
||||
}
|
||||
}
|
||||
|
||||
private Func<int, int, Task> CurrySeatScalingUpdate(
|
||||
|
@ -0,0 +1,63 @@
|
||||
#nullable enable
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.SecretsManager.AuthorizationRequirements;
|
||||
using Bit.Core.SecretsManager.Entities;
|
||||
using Bit.Core.SecretsManager.Queries.Interfaces;
|
||||
using Bit.Core.SecretsManager.Repositories;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace Bit.Commercial.Core.SecretsManager.AuthorizationHandlers.Secrets;
|
||||
|
||||
public class
|
||||
BulkSecretAuthorizationHandler : AuthorizationHandler<BulkSecretOperationRequirement, IReadOnlyList<Secret>>
|
||||
{
|
||||
private readonly IAccessClientQuery _accessClientQuery;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
private readonly ISecretRepository _secretRepository;
|
||||
|
||||
public BulkSecretAuthorizationHandler(ICurrentContext currentContext, IAccessClientQuery accessClientQuery,
|
||||
ISecretRepository secretRepository)
|
||||
{
|
||||
_currentContext = currentContext;
|
||||
_accessClientQuery = accessClientQuery;
|
||||
_secretRepository = secretRepository;
|
||||
}
|
||||
|
||||
|
||||
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context,
|
||||
BulkSecretOperationRequirement requirement,
|
||||
IReadOnlyList<Secret> resources)
|
||||
{
|
||||
// Ensure all secrets belong to the same organization.
|
||||
var organizationId = resources[0].OrganizationId;
|
||||
if (resources.Any(secret => secret.OrganizationId != organizationId) ||
|
||||
!_currentContext.AccessSecretsManager(organizationId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
switch (requirement)
|
||||
{
|
||||
case not null when requirement == BulkSecretOperations.ReadAll:
|
||||
await CanReadAllAsync(context, requirement, resources, organizationId);
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentException("Unsupported operation requirement type provided.", nameof(requirement));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CanReadAllAsync(AuthorizationHandlerContext context,
|
||||
BulkSecretOperationRequirement requirement, IReadOnlyList<Secret> resources, Guid organizationId)
|
||||
{
|
||||
var (accessClient, userId) = await _accessClientQuery.GetAccessClientAsync(context.User, organizationId);
|
||||
|
||||
var secretsAccess =
|
||||
await _secretRepository.AccessToSecretsAsync(resources.Select(s => s.Id), userId, accessClient);
|
||||
|
||||
if (secretsAccess.Count == resources.Count &&
|
||||
secretsAccess.All(a => a.Value.Read))
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,34 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||
using Bit.Core.SecretsManager.Commands.Requests.Interfaces;
|
||||
using Bit.Core.Services;
|
||||
|
||||
namespace Bit.Commercial.Core.SecretsManager.Commands.Requests;
|
||||
|
||||
public class RequestSMAccessCommand : IRequestSMAccessCommand
|
||||
{
|
||||
private readonly IMailService _mailService;
|
||||
|
||||
public RequestSMAccessCommand(
|
||||
IMailService mailService)
|
||||
{
|
||||
_mailService = mailService;
|
||||
}
|
||||
|
||||
public async Task SendRequestAccessToSM(Organization organization, ICollection<OrganizationUserUserDetails> orgUsers, User user, string emailContent)
|
||||
{
|
||||
var emailList = orgUsers.Where(o => o.Type <= OrganizationUserType.Admin)
|
||||
.Select(a => a.Email).Distinct().ToList();
|
||||
|
||||
if (!emailList.Any())
|
||||
{
|
||||
throw new BadRequestException("The organization is in an invalid state. Please contact Customer Support.");
|
||||
}
|
||||
|
||||
var userRequestingAccess = user.Name ?? user.Email;
|
||||
await _mailService.SendRequestSMAccessToAdminEmailAsync(emailList, organization.Name, userRequestingAccess, emailContent);
|
||||
}
|
||||
}
|
@ -6,6 +6,7 @@ using Bit.Commercial.Core.SecretsManager.Commands.AccessPolicies;
|
||||
using Bit.Commercial.Core.SecretsManager.Commands.AccessTokens;
|
||||
using Bit.Commercial.Core.SecretsManager.Commands.Porting;
|
||||
using Bit.Commercial.Core.SecretsManager.Commands.Projects;
|
||||
using Bit.Commercial.Core.SecretsManager.Commands.Requests;
|
||||
using Bit.Commercial.Core.SecretsManager.Commands.Secrets;
|
||||
using Bit.Commercial.Core.SecretsManager.Commands.ServiceAccounts;
|
||||
using Bit.Commercial.Core.SecretsManager.Commands.Trash;
|
||||
@ -18,6 +19,7 @@ using Bit.Core.SecretsManager.Commands.AccessPolicies.Interfaces;
|
||||
using Bit.Core.SecretsManager.Commands.AccessTokens.Interfaces;
|
||||
using Bit.Core.SecretsManager.Commands.Porting.Interfaces;
|
||||
using Bit.Core.SecretsManager.Commands.Projects.Interfaces;
|
||||
using Bit.Core.SecretsManager.Commands.Requests.Interfaces;
|
||||
using Bit.Core.SecretsManager.Commands.Secrets.Interfaces;
|
||||
using Bit.Core.SecretsManager.Commands.ServiceAccounts.Interfaces;
|
||||
using Bit.Core.SecretsManager.Commands.Trash.Interfaces;
|
||||
@ -43,6 +45,7 @@ public static class SecretsManagerCollectionExtensions
|
||||
services.AddScoped<IAuthorizationHandler, ServiceAccountGrantedPoliciesAuthorizationHandler>();
|
||||
services.AddScoped<IAuthorizationHandler, ProjectServiceAccountsAccessPoliciesAuthorizationHandler>();
|
||||
services.AddScoped<IAuthorizationHandler, SecretAccessPoliciesUpdatesAuthorizationHandler>();
|
||||
services.AddScoped<IAuthorizationHandler, BulkSecretAuthorizationHandler>();
|
||||
services.AddScoped<IAccessClientQuery, AccessClientQuery>();
|
||||
services.AddScoped<IMaxProjectsQuery, MaxProjectsQuery>();
|
||||
services.AddScoped<ISameOrganizationQuery, SameOrganizationQuery>();
|
||||
@ -55,6 +58,7 @@ public static class SecretsManagerCollectionExtensions
|
||||
services.AddScoped<IUpdateSecretCommand, UpdateSecretCommand>();
|
||||
services.AddScoped<IDeleteSecretCommand, DeleteSecretCommand>();
|
||||
services.AddScoped<ICreateProjectCommand, CreateProjectCommand>();
|
||||
services.AddScoped<IRequestSMAccessCommand, RequestSMAccessCommand>();
|
||||
services.AddScoped<IUpdateProjectCommand, UpdateProjectCommand>();
|
||||
services.AddScoped<IDeleteProjectCommand, DeleteProjectCommand>();
|
||||
services.AddScoped<ICreateServiceAccountCommand, CreateServiceAccountCommand>();
|
||||
|
@ -169,6 +169,58 @@ public class ProjectRepository : Repository<Core.SecretsManager.Entities.Project
|
||||
return await accessQuery.ToDictionaryAsync(pa => pa.Id, pa => (pa.Read, pa.Write));
|
||||
}
|
||||
|
||||
public async Task<int> GetProjectCountByOrganizationIdAsync(Guid organizationId, Guid userId,
|
||||
AccessClientType accessType)
|
||||
{
|
||||
await using var scope = ServiceScopeFactory.CreateAsyncScope();
|
||||
var dbContext = GetDatabaseContext(scope);
|
||||
var query = dbContext.Project.Where(p => p.OrganizationId == organizationId && p.DeletedDate == null);
|
||||
|
||||
query = accessType switch
|
||||
{
|
||||
AccessClientType.NoAccessCheck => query,
|
||||
AccessClientType.User => query.Where(UserHasReadAccessToProject(userId)),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(accessType), accessType, null),
|
||||
};
|
||||
|
||||
return await query.CountAsync();
|
||||
}
|
||||
|
||||
public async Task<ProjectCounts> GetProjectCountsByIdAsync(Guid projectId, Guid userId, AccessClientType accessType)
|
||||
{
|
||||
await using var scope = ServiceScopeFactory.CreateAsyncScope();
|
||||
var dbContext = GetDatabaseContext(scope);
|
||||
var query = dbContext.Project.Where(p => p.Id == projectId && p.DeletedDate == null);
|
||||
|
||||
var queryReadAccess = accessType switch
|
||||
{
|
||||
AccessClientType.NoAccessCheck => query,
|
||||
AccessClientType.User => query.Where(UserHasReadAccessToProject(userId)),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(accessType), accessType, null),
|
||||
};
|
||||
|
||||
var queryWriteAccess = accessType switch
|
||||
{
|
||||
AccessClientType.NoAccessCheck => query,
|
||||
AccessClientType.User => query.Where(UserHasWriteAccessToProject(userId)),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(accessType), accessType, null),
|
||||
};
|
||||
|
||||
var secretsQuery = queryReadAccess.Select(project => project.Secrets.Count(s => s.DeletedDate == null));
|
||||
|
||||
var projectCountsQuery = queryWriteAccess.Select(project => new ProjectCounts
|
||||
{
|
||||
People = project.UserAccessPolicies.Count + project.GroupAccessPolicies.Count,
|
||||
ServiceAccounts = project.ServiceAccountAccessPolicies.Count
|
||||
});
|
||||
|
||||
var secrets = await secretsQuery.FirstOrDefaultAsync();
|
||||
var projectCounts = await projectCountsQuery.FirstOrDefaultAsync() ?? new ProjectCounts { Secrets = 0, People = 0, ServiceAccounts = 0 };
|
||||
projectCounts.Secrets = secrets;
|
||||
|
||||
return projectCounts;
|
||||
}
|
||||
|
||||
private record ProjectAccess(Guid Id, bool Read, bool Write);
|
||||
|
||||
private static IQueryable<ProjectAccess> BuildProjectAccessQuery(IQueryable<Project> projectQuery, Guid userId,
|
||||
|
@ -299,6 +299,22 @@ public class SecretRepository : Repository<Core.SecretsManager.Entities.Secret,
|
||||
return policy == null ? (false, false) : (policy.Read, policy.Write);
|
||||
}
|
||||
|
||||
public async Task<Dictionary<Guid, (bool Read, bool Write)>> AccessToSecretsAsync(
|
||||
IEnumerable<Guid> ids,
|
||||
Guid userId,
|
||||
AccessClientType accessType)
|
||||
{
|
||||
await using var scope = ServiceScopeFactory.CreateAsyncScope();
|
||||
var dbContext = GetDatabaseContext(scope);
|
||||
|
||||
var secrets = dbContext.Secret
|
||||
.Where(s => ids.Contains(s.Id));
|
||||
|
||||
var accessQuery = BuildSecretAccessQuery(secrets, userId, accessType);
|
||||
|
||||
return await accessQuery.ToDictionaryAsync(sa => sa.Id, sa => (sa.Read, sa.Write));
|
||||
}
|
||||
|
||||
public async Task EmptyTrash(DateTime currentDate, uint deleteAfterThisNumberOfDays)
|
||||
{
|
||||
using var scope = ServiceScopeFactory.CreateScope();
|
||||
@ -309,6 +325,23 @@ public class SecretRepository : Repository<Core.SecretsManager.Entities.Secret,
|
||||
await dbContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task<int> GetSecretsCountByOrganizationIdAsync(Guid organizationId, Guid userId,
|
||||
AccessClientType accessType)
|
||||
{
|
||||
await using var scope = ServiceScopeFactory.CreateAsyncScope();
|
||||
var dbContext = GetDatabaseContext(scope);
|
||||
var query = dbContext.Secret.Where(s => s.OrganizationId == organizationId && s.DeletedDate == null);
|
||||
|
||||
query = accessType switch
|
||||
{
|
||||
AccessClientType.NoAccessCheck => query,
|
||||
AccessClientType.User => query.Where(UserHasReadAccessToSecret(userId)),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(accessType), accessType, null),
|
||||
};
|
||||
|
||||
return await query.CountAsync();
|
||||
}
|
||||
|
||||
private IQueryable<SecretPermissionDetails> SecretToPermissionDetails(IQueryable<Secret> query, Guid userId, AccessClientType accessType)
|
||||
{
|
||||
var secrets = accessType switch
|
||||
|
@ -125,6 +125,48 @@ public class ServiceAccountRepository : Repository<Core.SecretsManager.Entities.
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<int> GetServiceAccountCountByOrganizationIdAsync(Guid organizationId, Guid userId,
|
||||
AccessClientType accessType)
|
||||
{
|
||||
await using var scope = ServiceScopeFactory.CreateAsyncScope();
|
||||
var dbContext = GetDatabaseContext(scope);
|
||||
var query = dbContext.ServiceAccount.Where(sa => sa.OrganizationId == organizationId);
|
||||
|
||||
query = accessType switch
|
||||
{
|
||||
AccessClientType.NoAccessCheck => query,
|
||||
AccessClientType.User => query.Where(UserHasReadAccessToServiceAccount(userId)),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(accessType), accessType, null),
|
||||
};
|
||||
|
||||
return await query.CountAsync();
|
||||
}
|
||||
|
||||
public async Task<ServiceAccountCounts> GetServiceAccountCountsByIdAsync(Guid serviceAccountId, Guid userId,
|
||||
AccessClientType accessType)
|
||||
{
|
||||
await using var scope = ServiceScopeFactory.CreateAsyncScope();
|
||||
var dbContext = GetDatabaseContext(scope);
|
||||
var query = dbContext.ServiceAccount.Where(sa => sa.Id == serviceAccountId);
|
||||
|
||||
query = accessType switch
|
||||
{
|
||||
AccessClientType.NoAccessCheck => query,
|
||||
AccessClientType.User => query.Where(UserHasReadAccessToServiceAccount(userId)),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(accessType), accessType, null),
|
||||
};
|
||||
|
||||
var serviceAccountCountsQuery = query.Select(serviceAccount => new ServiceAccountCounts
|
||||
{
|
||||
Projects = serviceAccount.ProjectAccessPolicies.Count,
|
||||
People = serviceAccount.UserAccessPolicies.Count + serviceAccount.GroupAccessPolicies.Count,
|
||||
AccessTokens = serviceAccount.ApiKeys.Count
|
||||
});
|
||||
|
||||
var serviceAccountCounts = await serviceAccountCountsQuery.FirstOrDefaultAsync();
|
||||
return serviceAccountCounts ?? new ServiceAccountCounts { Projects = 0, People = 0, AccessTokens = 0 };
|
||||
}
|
||||
|
||||
public async Task<bool> ServiceAccountsAreInOrganizationAsync(List<Guid> serviceAccountIds, Guid organizationId)
|
||||
{
|
||||
await using var scope = ServiceScopeFactory.CreateAsyncScope();
|
||||
|
@ -20,7 +20,6 @@ public class ScimUserRequestModel : BaseScimUserModel
|
||||
|
||||
// Permissions cannot be set via SCIM so we use default values
|
||||
Type = OrganizationUserType.User,
|
||||
AccessAll = false,
|
||||
Collections = new List<CollectionAccessSelection>(),
|
||||
Groups = new List<Guid>()
|
||||
};
|
||||
|
@ -7,15 +7,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>@ViewData["Title"] - SSO</title>
|
||||
|
||||
<link rel="stylesheet" href="~/css/webfonts.css" />
|
||||
<environment include="Development">
|
||||
<link rel="stylesheet" href="~/lib/font-awesome/css/font-awesome.css" />
|
||||
<link rel="stylesheet" href="~/css/site.css" />
|
||||
</environment>
|
||||
<environment exclude="Development">
|
||||
<link rel="stylesheet" href="~/lib/font-awesome/css/font-awesome.min.css" asp-append-version="true" />
|
||||
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
|
||||
</environment>
|
||||
<link rel="stylesheet" href="~/assets/site.css" asp-append-version="true" />
|
||||
@RenderSection("Head", required: false)
|
||||
</head>
|
||||
<body>
|
||||
@ -43,18 +35,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<environment include="Development">
|
||||
<script src="~/lib/jquery/jquery.slim.js"></script>
|
||||
<script src="~/lib/popper/popper.js"></script>
|
||||
<script src="~/lib/bootstrap/js/bootstrap.js"></script>
|
||||
</environment>
|
||||
<environment exclude="Development">
|
||||
<script src="~/lib/jquery/jquery.slim.min.js" asp-append-version="true"></script>
|
||||
<script src="~/lib/popper/popper.min.js" asp-append-version="true"></script>
|
||||
<script src="~/lib/bootstrap/js/bootstrap.min.js" asp-append-version="true"></script>
|
||||
</environment>
|
||||
|
||||
<script src="~/js/site.js" asp-append-version="true"></script>
|
||||
<script src="~/assets/site.js" asp-append-version="true"></script>
|
||||
@RenderSection("Scripts", required: false)
|
||||
</body>
|
||||
</html>
|
||||
|
@ -1,71 +0,0 @@
|
||||
/// <binding BeforeBuild='build' Clean='clean' ProjectOpened='build' />
|
||||
|
||||
const gulp = require('gulp');
|
||||
const merge = require('merge-stream');
|
||||
const sass = require('gulp-sass')(require("sass"));
|
||||
const del = require('del');
|
||||
|
||||
const paths = {};
|
||||
paths.webroot = './wwwroot/';
|
||||
paths.npmDir = './node_modules/';
|
||||
paths.sassDir = './Sass/';
|
||||
paths.libDir = paths.webroot + 'lib/';
|
||||
paths.cssDir = paths.webroot + 'css/';
|
||||
paths.jsDir = paths.webroot + 'js/';
|
||||
|
||||
paths.sass = paths.sassDir + '**/*.scss';
|
||||
paths.minCss = paths.cssDir + '**/*.min.css';
|
||||
paths.js = paths.jsDir + '**/*.js';
|
||||
paths.minJs = paths.jsDir + '**/*.min.js';
|
||||
paths.libJs = paths.libDir + '**/*.js';
|
||||
paths.libMinJs = paths.libDir + '**/*.min.js';
|
||||
|
||||
function clean() {
|
||||
return del([paths.minJs, paths.cssDir, paths.libDir]);
|
||||
}
|
||||
|
||||
function lib() {
|
||||
const libs = [
|
||||
{
|
||||
src: paths.npmDir + 'bootstrap/dist/js/*',
|
||||
dest: paths.libDir + 'bootstrap/js'
|
||||
},
|
||||
{
|
||||
src: paths.npmDir + 'popper.js/dist/umd/*',
|
||||
dest: paths.libDir + 'popper'
|
||||
},
|
||||
{
|
||||
src: paths.npmDir + 'font-awesome/css/*',
|
||||
dest: paths.libDir + 'font-awesome/css'
|
||||
},
|
||||
{
|
||||
src: paths.npmDir + 'font-awesome/fonts/*',
|
||||
dest: paths.libDir + 'font-awesome/fonts'
|
||||
},
|
||||
{
|
||||
src: paths.npmDir + 'jquery/dist/jquery.slim*',
|
||||
dest: paths.libDir + 'jquery'
|
||||
},
|
||||
];
|
||||
|
||||
const tasks = libs.map((lib) => {
|
||||
return gulp.src(lib.src).pipe(gulp.dest(lib.dest));
|
||||
});
|
||||
return merge(tasks);
|
||||
}
|
||||
|
||||
function runSass() {
|
||||
return gulp.src(paths.sass)
|
||||
.pipe(sass({ outputStyle: 'compressed' }).on('error', sass.logError))
|
||||
.pipe(gulp.dest(paths.cssDir));
|
||||
}
|
||||
|
||||
function sassWatch() {
|
||||
gulp.watch(paths.sass, runSass);
|
||||
}
|
||||
|
||||
exports.build = gulp.series(clean, gulp.parallel([lib, runSass]));
|
||||
exports['sass:watch'] = sassWatch;
|
||||
exports.sass = runSass;
|
||||
exports.lib = lib;
|
||||
exports.clean = clean;
|
6718
bitwarden_license/src/Sso/package-lock.json
generated
6718
bitwarden_license/src/Sso/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -5,17 +5,21 @@
|
||||
"repository": "https://github.com/bitwarden/enterprise",
|
||||
"license": "-",
|
||||
"scripts": {
|
||||
"build": "gulp build"
|
||||
"build": "webpack"
|
||||
},
|
||||
"dependencies": {
|
||||
"bootstrap": "4.6.2",
|
||||
"font-awesome": "4.7.0",
|
||||
"jquery": "3.7.1",
|
||||
"popper.js": "1.16.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"bootstrap": "4.6.2",
|
||||
"del": "6.1.1",
|
||||
"font-awesome": "4.7.0",
|
||||
"gulp": "4.0.2",
|
||||
"gulp-sass": "5.1.0",
|
||||
"jquery": "3.7.1",
|
||||
"merge-stream": "2.0.0",
|
||||
"popper.js": "1.16.1",
|
||||
"sass": "1.75.0"
|
||||
"css-loader": "7.1.2",
|
||||
"expose-loader": "5.0.0",
|
||||
"mini-css-extract-plugin": "2.9.0",
|
||||
"sass": "1.75.0",
|
||||
"sass-loader": "16.0.0",
|
||||
"webpack": "5.93.0",
|
||||
"webpack-cli": "5.1.4"
|
||||
}
|
||||
}
|
||||
|
57
bitwarden_license/src/Sso/webpack.config.js
Normal file
57
bitwarden_license/src/Sso/webpack.config.js
Normal file
@ -0,0 +1,57 @@
|
||||
const path = require("path");
|
||||
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
|
||||
|
||||
const paths = {
|
||||
assets: "./wwwroot/assets/",
|
||||
sassDir: "./Sass/",
|
||||
};
|
||||
|
||||
/** @type {import("webpack").Configuration} */
|
||||
module.exports = {
|
||||
mode: "production",
|
||||
devtool: "source-map",
|
||||
entry: {
|
||||
site: [
|
||||
path.resolve(__dirname, paths.sassDir, "site.scss"),
|
||||
|
||||
"popper.js",
|
||||
"bootstrap",
|
||||
"jquery",
|
||||
"font-awesome/css/font-awesome.css",
|
||||
],
|
||||
},
|
||||
output: {
|
||||
clean: true,
|
||||
path: path.resolve(__dirname, paths.assets),
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.(sa|sc|c)ss$/,
|
||||
use: [MiniCssExtractPlugin.loader, "css-loader", "sass-loader"],
|
||||
},
|
||||
{
|
||||
test: /.(ttf|otf|eot|svg|woff(2)?)(\?[a-z0-9]+)?$/,
|
||||
exclude: /loading(|-white).svg/,
|
||||
generator: {
|
||||
filename: "fonts/[name].[contenthash][ext]",
|
||||
},
|
||||
type: "asset/resource",
|
||||
},
|
||||
|
||||
// Expose jquery globally so they can be used directly in asp.net
|
||||
{
|
||||
test: require.resolve("jquery"),
|
||||
loader: "expose-loader",
|
||||
options: {
|
||||
exposes: ["$", "jQuery"],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
plugins: [
|
||||
new MiniCssExtractPlugin({
|
||||
filename: "[name].css",
|
||||
}),
|
||||
],
|
||||
};
|
@ -1,4 +0,0 @@
|
||||
// Please see documentation at https://docs.microsoft.com/aspnet/core/client-side/bundling-and-minification
|
||||
// for details on configuring this project to bundle and minify static web assets.
|
||||
|
||||
// Write your JavaScript code.
|
5
bitwarden_license/test/Bitwarden.License.Tests.proj
Normal file
5
bitwarden_license/test/Bitwarden.License.Tests.proj
Normal file
@ -0,0 +1,5 @@
|
||||
<Project Sdk="Microsoft.Build.Traversal">
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="**\*.*proj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
@ -8,6 +8,7 @@ using Bit.Core.AdminConsole.Models.Business.Provider;
|
||||
using Bit.Core.AdminConsole.Models.Business.Tokenables;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
@ -81,6 +82,51 @@ public class ProviderServiceTests
|
||||
.ReplaceAsync(Arg.Is<ProviderUser>(pu => pu.UserId == user.Id && pu.ProviderId == provider.Id && pu.Key == key));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task CompleteSetupAsync_ConsolidatedBilling_Success(User user, Provider provider, string key, TaxInfo taxInfo,
|
||||
[ProviderUser(ProviderUserStatusType.Confirmed, ProviderUserType.ProviderAdmin)] ProviderUser providerUser,
|
||||
SutProvider<ProviderService> sutProvider)
|
||||
{
|
||||
providerUser.ProviderId = provider.Id;
|
||||
providerUser.UserId = user.Id;
|
||||
var userService = sutProvider.GetDependency<IUserService>();
|
||||
userService.GetUserByIdAsync(user.Id).Returns(user);
|
||||
|
||||
var providerUserRepository = sutProvider.GetDependency<IProviderUserRepository>();
|
||||
providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id).Returns(providerUser);
|
||||
|
||||
var dataProtectionProvider = DataProtectionProvider.Create("ApplicationName");
|
||||
var protector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector");
|
||||
sutProvider.GetDependency<IDataProtectionProvider>().CreateProtector("ProviderServiceDataProtector")
|
||||
.Returns(protector);
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling)
|
||||
.Returns(true);
|
||||
|
||||
var providerBillingService = sutProvider.GetDependency<IProviderBillingService>();
|
||||
|
||||
var customer = new Customer { Id = "customer_id" };
|
||||
providerBillingService.SetupCustomer(provider, taxInfo).Returns(customer);
|
||||
|
||||
var subscription = new Subscription { Id = "subscription_id" };
|
||||
providerBillingService.SetupSubscription(provider).Returns(subscription);
|
||||
|
||||
sutProvider.Create();
|
||||
|
||||
var token = protector.Protect($"ProviderSetupInvite {provider.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}");
|
||||
|
||||
await sutProvider.Sut.CompleteSetupAsync(provider, user.Id, token, key, taxInfo);
|
||||
|
||||
await sutProvider.GetDependency<IProviderRepository>().Received().UpsertAsync(Arg.Is<Provider>(
|
||||
p =>
|
||||
p.GatewayCustomerId == customer.Id &&
|
||||
p.GatewaySubscriptionId == subscription.Id &&
|
||||
p.Status == ProviderStatusType.Billable));
|
||||
|
||||
await sutProvider.GetDependency<IProviderUserRepository>().Received()
|
||||
.ReplaceAsync(Arg.Is<ProviderUser>(pu => pu.UserId == user.Id && pu.ProviderId == provider.Id && pu.Key == key));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateAsync_ProviderIdIsInvalid_Throws(Provider provider, SutProvider<ProviderService> sutProvider)
|
||||
{
|
||||
@ -613,7 +659,7 @@ public class ProviderServiceTests
|
||||
await sutProvider.GetDependency<IEventService>().DidNotReceiveWithAnyArgs().LogProviderOrganizationEventsAsync(default);
|
||||
}
|
||||
|
||||
[Theory, OrganizationCustomize(FlexibleCollections = false), BitAutoData]
|
||||
[Theory, OrganizationCustomize, BitAutoData]
|
||||
public async Task CreateOrganizationAsync_Success(Provider provider, OrganizationSignup organizationSignup,
|
||||
Organization organization, string clientOwnerEmail, User user, SutProvider<ProviderService> sutProvider)
|
||||
{
|
||||
@ -637,12 +683,11 @@ public class ProviderServiceTests
|
||||
t.First().Item1.Emails.Count() == 1 &&
|
||||
t.First().Item1.Emails.First() == clientOwnerEmail &&
|
||||
t.First().Item1.Type == OrganizationUserType.Owner &&
|
||||
t.First().Item1.AccessAll &&
|
||||
!t.First().Item1.Collections.Any() &&
|
||||
t.First().Item1.Collections.Count() == 1 &&
|
||||
t.First().Item2 == null));
|
||||
}
|
||||
|
||||
[Theory, OrganizationCustomize(FlexibleCollections = false), BitAutoData]
|
||||
[Theory, OrganizationCustomize, BitAutoData]
|
||||
public async Task CreateOrganizationAsync_ConsolidatedBillingEnabled_InvalidPlanType_ThrowsBadRequestException(
|
||||
Provider provider,
|
||||
OrganizationSignup organizationSignup,
|
||||
@ -671,7 +716,7 @@ public class ProviderServiceTests
|
||||
await providerOrganizationRepository.DidNotReceiveWithAnyArgs().CreateAsync(default);
|
||||
}
|
||||
|
||||
[Theory, OrganizationCustomize(FlexibleCollections = false), BitAutoData]
|
||||
[Theory, OrganizationCustomize, BitAutoData]
|
||||
public async Task CreateOrganizationAsync_ConsolidatedBillingEnabled_InvokeSignupClientAsync(
|
||||
Provider provider,
|
||||
OrganizationSignup organizationSignup,
|
||||
@ -717,13 +762,12 @@ public class ProviderServiceTests
|
||||
t.First().Item1.Emails.Count() == 1 &&
|
||||
t.First().Item1.Emails.First() == clientOwnerEmail &&
|
||||
t.First().Item1.Type == OrganizationUserType.Owner &&
|
||||
t.First().Item1.AccessAll &&
|
||||
!t.First().Item1.Collections.Any() &&
|
||||
t.First().Item1.Collections.Count() == 1 &&
|
||||
t.First().Item2 == null));
|
||||
}
|
||||
|
||||
[Theory, OrganizationCustomize(FlexibleCollections = true), BitAutoData]
|
||||
public async Task CreateOrganizationAsync_WithFlexibleCollections_SetsAccessAllToFalse
|
||||
[Theory, OrganizationCustomize, BitAutoData]
|
||||
public async Task CreateOrganizationAsync_SetsAccessAllToFalse
|
||||
(Provider provider, OrganizationSignup organizationSignup, Organization organization, string clientOwnerEmail,
|
||||
User user, SutProvider<ProviderService> sutProvider, Collection defaultCollection)
|
||||
{
|
||||
@ -747,7 +791,6 @@ public class ProviderServiceTests
|
||||
t.First().Item1.Emails.Count() == 1 &&
|
||||
t.First().Item1.Emails.First() == clientOwnerEmail &&
|
||||
t.First().Item1.Type == OrganizationUserType.Owner &&
|
||||
t.First().Item1.AccessAll == false &&
|
||||
t.First().Item1.Collections.Single().Id == defaultCollection.Id &&
|
||||
!t.First().Item1.Collections.Single().HidePasswords &&
|
||||
!t.First().Item1.Collections.Single().ReadOnly &&
|
||||
|
@ -11,12 +11,12 @@ using Bit.Core.Billing;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Entities;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Billing.Repositories;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Business;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
@ -87,7 +87,7 @@ public class ProviderBillingServiceTests
|
||||
{
|
||||
organization.PlanType = PlanType.FamiliesAnnually;
|
||||
|
||||
await ThrowsContactSupportAsync(() =>
|
||||
await ThrowsBillingExceptionAsync(() =>
|
||||
sutProvider.Sut.AssignSeatsToClientOrganization(provider, organization, seats));
|
||||
}
|
||||
|
||||
@ -105,7 +105,7 @@ public class ProviderBillingServiceTests
|
||||
new() { Id = Guid.NewGuid(), PlanType = PlanType.TeamsMonthly, ProviderId = provider.Id }
|
||||
});
|
||||
|
||||
await ThrowsContactSupportAsync(() =>
|
||||
await ThrowsBillingExceptionAsync(() =>
|
||||
sutProvider.Sut.AssignSeatsToClientOrganization(provider, organization, seats));
|
||||
}
|
||||
|
||||
@ -247,7 +247,7 @@ public class ProviderBillingServiceTests
|
||||
|
||||
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(false);
|
||||
|
||||
await ThrowsContactSupportAsync(() =>
|
||||
await ThrowsBillingExceptionAsync(() =>
|
||||
sutProvider.Sut.AssignSeatsToClientOrganization(provider, organization, seats));
|
||||
}
|
||||
|
||||
@ -493,105 +493,6 @@ public class ProviderBillingServiceTests
|
||||
|
||||
#endregion
|
||||
|
||||
#region CreateCustomer
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task CreateCustomer_NullProvider_ThrowsArgumentNullException(
|
||||
SutProvider<ProviderBillingService> sutProvider,
|
||||
TaxInfo taxInfo) =>
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => sutProvider.Sut.CreateCustomer(null, taxInfo));
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task CreateCustomer_NullTaxInfo_ThrowsArgumentNullException(
|
||||
SutProvider<ProviderBillingService> sutProvider,
|
||||
Provider provider) =>
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => sutProvider.Sut.CreateCustomer(provider, null));
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task CreateCustomer_MissingCountry_ContactSupport(
|
||||
SutProvider<ProviderBillingService> sutProvider,
|
||||
Provider provider,
|
||||
TaxInfo taxInfo)
|
||||
{
|
||||
taxInfo.BillingAddressCountry = null;
|
||||
|
||||
await ThrowsContactSupportAsync(() => sutProvider.Sut.CreateCustomer(provider, taxInfo));
|
||||
|
||||
await sutProvider.GetDependency<IStripeAdapter>()
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
.CustomerGetAsync(Arg.Any<string>(), Arg.Any<CustomerGetOptions>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task CreateCustomer_MissingPostalCode_ContactSupport(
|
||||
SutProvider<ProviderBillingService> sutProvider,
|
||||
Provider provider,
|
||||
TaxInfo taxInfo)
|
||||
{
|
||||
taxInfo.BillingAddressCountry = null;
|
||||
|
||||
await ThrowsContactSupportAsync(() => sutProvider.Sut.CreateCustomer(provider, taxInfo));
|
||||
|
||||
await sutProvider.GetDependency<IStripeAdapter>()
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
.CustomerGetAsync(Arg.Any<string>(), Arg.Any<CustomerGetOptions>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task CreateCustomer_Success(
|
||||
SutProvider<ProviderBillingService> sutProvider,
|
||||
Provider provider,
|
||||
TaxInfo taxInfo)
|
||||
{
|
||||
provider.Name = "MSP";
|
||||
|
||||
taxInfo.BillingAddressCountry = "AD";
|
||||
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
|
||||
stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o =>
|
||||
o.Address.Country == taxInfo.BillingAddressCountry &&
|
||||
o.Address.PostalCode == taxInfo.BillingAddressPostalCode &&
|
||||
o.Address.Line1 == taxInfo.BillingAddressLine1 &&
|
||||
o.Address.Line2 == taxInfo.BillingAddressLine2 &&
|
||||
o.Address.City == taxInfo.BillingAddressCity &&
|
||||
o.Address.State == taxInfo.BillingAddressState &&
|
||||
o.Description == WebUtility.HtmlDecode(provider.BusinessName) &&
|
||||
o.Email == provider.BillingEmail &&
|
||||
o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" &&
|
||||
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" &&
|
||||
o.Metadata["region"] == "" &&
|
||||
o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType &&
|
||||
o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber))
|
||||
.Returns(new Customer
|
||||
{
|
||||
Id = "customer_id",
|
||||
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
|
||||
});
|
||||
|
||||
await sutProvider.Sut.CreateCustomer(provider, taxInfo);
|
||||
|
||||
await stripeAdapter.Received(1).CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o =>
|
||||
o.Address.Country == taxInfo.BillingAddressCountry &&
|
||||
o.Address.PostalCode == taxInfo.BillingAddressPostalCode &&
|
||||
o.Address.Line1 == taxInfo.BillingAddressLine1 &&
|
||||
o.Address.Line2 == taxInfo.BillingAddressLine2 &&
|
||||
o.Address.City == taxInfo.BillingAddressCity &&
|
||||
o.Address.State == taxInfo.BillingAddressState &&
|
||||
o.Description == WebUtility.HtmlDecode(provider.BusinessName) &&
|
||||
o.Email == provider.BillingEmail &&
|
||||
o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" &&
|
||||
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" &&
|
||||
o.Metadata["region"] == "" &&
|
||||
o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType &&
|
||||
o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber));
|
||||
|
||||
await sutProvider.GetDependency<IProviderRepository>()
|
||||
.ReplaceAsync(Arg.Is<Provider>(p => p.GatewayCustomerId == "customer_id"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CreateCustomerForClientOrganization
|
||||
|
||||
[Theory, BitAutoData]
|
||||
@ -777,7 +678,7 @@ public class ProviderBillingServiceTests
|
||||
public async Task GetAssignedSeatTotalForPlanOrThrow_NullProvider_ContactSupport(
|
||||
Guid providerId,
|
||||
SutProvider<ProviderBillingService> sutProvider)
|
||||
=> await ThrowsContactSupportAsync(() =>
|
||||
=> await ThrowsBillingExceptionAsync(() =>
|
||||
sutProvider.Sut.GetAssignedSeatTotalForPlanOrThrow(providerId, PlanType.TeamsMonthly));
|
||||
|
||||
[Theory, BitAutoData]
|
||||
@ -790,9 +691,8 @@ public class ProviderBillingServiceTests
|
||||
|
||||
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(providerId).Returns(provider);
|
||||
|
||||
await ThrowsContactSupportAsync(
|
||||
() => sutProvider.Sut.GetAssignedSeatTotalForPlanOrThrow(providerId, PlanType.TeamsMonthly),
|
||||
internalMessage: "Consolidated billing does not support reseller-type providers");
|
||||
await ThrowsBillingExceptionAsync(
|
||||
() => sutProvider.Sut.GetAssignedSeatTotalForPlanOrThrow(providerId, PlanType.TeamsMonthly));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
@ -836,197 +736,100 @@ public class ProviderBillingServiceTests
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetConsolidatedBillingSubscription
|
||||
#region SetupCustomer
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetConsolidatedBillingSubscription_NullProvider_ThrowsArgumentNullException(
|
||||
SutProvider<ProviderBillingService> sutProvider) =>
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => sutProvider.Sut.GetConsolidatedBillingSubscription(null));
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetConsolidatedBillingSubscription_NullSubscription_ReturnsNull(
|
||||
public async Task SetupCustomer_NullProvider_ThrowsArgumentNullException(
|
||||
SutProvider<ProviderBillingService> sutProvider,
|
||||
Provider provider)
|
||||
TaxInfo taxInfo) =>
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => sutProvider.Sut.SetupCustomer(null, taxInfo));
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task SetupCustomer_NullTaxInfo_ThrowsArgumentNullException(
|
||||
SutProvider<ProviderBillingService> sutProvider,
|
||||
Provider provider) =>
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => sutProvider.Sut.SetupCustomer(provider, null));
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task SetupCustomer_MissingCountry_ContactSupport(
|
||||
SutProvider<ProviderBillingService> sutProvider,
|
||||
Provider provider,
|
||||
TaxInfo taxInfo)
|
||||
{
|
||||
var consolidatedBillingSubscription = await sutProvider.Sut.GetConsolidatedBillingSubscription(provider);
|
||||
taxInfo.BillingAddressCountry = null;
|
||||
|
||||
Assert.Null(consolidatedBillingSubscription);
|
||||
await ThrowsBillingExceptionAsync(() => sutProvider.Sut.SetupCustomer(provider, taxInfo));
|
||||
|
||||
await sutProvider.GetDependency<ISubscriberService>().Received(1).GetSubscription(
|
||||
provider,
|
||||
Arg.Is<SubscriptionGetOptions>(
|
||||
options => options.Expand.Count == 2 && options.Expand.First() == "customer" && options.Expand.Last() == "test_clock"));
|
||||
await sutProvider.GetDependency<IStripeAdapter>()
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
.CustomerGetAsync(Arg.Any<string>(), Arg.Any<CustomerGetOptions>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetConsolidatedBillingSubscription_Active_NoSuspension_Success(
|
||||
public async Task SetupCustomer_MissingPostalCode_ContactSupport(
|
||||
SutProvider<ProviderBillingService> sutProvider,
|
||||
Provider provider)
|
||||
Provider provider,
|
||||
TaxInfo taxInfo)
|
||||
{
|
||||
var subscriberService = sutProvider.GetDependency<ISubscriberService>();
|
||||
taxInfo.BillingAddressCountry = null;
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Status = "active"
|
||||
};
|
||||
await ThrowsBillingExceptionAsync(() => sutProvider.Sut.SetupCustomer(provider, taxInfo));
|
||||
|
||||
subscriberService.GetSubscription(provider, Arg.Is<SubscriptionGetOptions>(
|
||||
options => options.Expand.Count == 2 && options.Expand.First() == "customer" && options.Expand.Last() == "test_clock")).Returns(subscription);
|
||||
|
||||
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
|
||||
|
||||
var enterprisePlan = new ProviderPlan
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ProviderId = provider.Id,
|
||||
PlanType = PlanType.EnterpriseMonthly,
|
||||
SeatMinimum = 100,
|
||||
PurchasedSeats = 0,
|
||||
AllocatedSeats = 0
|
||||
};
|
||||
|
||||
var teamsPlan = new ProviderPlan
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ProviderId = provider.Id,
|
||||
PlanType = PlanType.TeamsMonthly,
|
||||
SeatMinimum = 50,
|
||||
PurchasedSeats = 10,
|
||||
AllocatedSeats = 60
|
||||
};
|
||||
|
||||
var providerPlans = new List<ProviderPlan> { enterprisePlan, teamsPlan, };
|
||||
|
||||
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
|
||||
|
||||
var taxInformation =
|
||||
new TaxInformationDTO("US", "12345", "123456789", "123 Example St.", null, "Example Town", "NY");
|
||||
|
||||
subscriberService.GetTaxInformation(provider).Returns(taxInformation);
|
||||
|
||||
var (gotProviderPlans, gotSubscription, gotTaxInformation, gotSuspension) = await sutProvider.Sut.GetConsolidatedBillingSubscription(provider);
|
||||
|
||||
Assert.Equal(2, gotProviderPlans.Count);
|
||||
|
||||
var configuredEnterprisePlan =
|
||||
gotProviderPlans.FirstOrDefault(configuredPlan =>
|
||||
configuredPlan.PlanType == PlanType.EnterpriseMonthly);
|
||||
|
||||
var configuredTeamsPlan =
|
||||
gotProviderPlans.FirstOrDefault(configuredPlan =>
|
||||
configuredPlan.PlanType == PlanType.TeamsMonthly);
|
||||
|
||||
Compare(enterprisePlan, configuredEnterprisePlan);
|
||||
|
||||
Compare(teamsPlan, configuredTeamsPlan);
|
||||
|
||||
Assert.Equivalent(subscription, gotSubscription);
|
||||
|
||||
Assert.Equivalent(taxInformation, gotTaxInformation);
|
||||
|
||||
Assert.Null(gotSuspension);
|
||||
|
||||
return;
|
||||
|
||||
void Compare(ProviderPlan providerPlan, ConfiguredProviderPlanDTO configuredProviderPlan)
|
||||
{
|
||||
Assert.NotNull(configuredProviderPlan);
|
||||
Assert.Equal(providerPlan.Id, configuredProviderPlan.Id);
|
||||
Assert.Equal(providerPlan.ProviderId, configuredProviderPlan.ProviderId);
|
||||
Assert.Equal(providerPlan.SeatMinimum!.Value, configuredProviderPlan.SeatMinimum);
|
||||
Assert.Equal(providerPlan.PurchasedSeats!.Value, configuredProviderPlan.PurchasedSeats);
|
||||
Assert.Equal(providerPlan.AllocatedSeats!.Value, configuredProviderPlan.AssignedSeats);
|
||||
}
|
||||
await sutProvider.GetDependency<IStripeAdapter>()
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
.CustomerGetAsync(Arg.Any<string>(), Arg.Any<CustomerGetOptions>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetConsolidatedBillingSubscription_PastDue_HasSuspension_Success(
|
||||
public async Task SetupCustomer_Success(
|
||||
SutProvider<ProviderBillingService> sutProvider,
|
||||
Provider provider)
|
||||
Provider provider,
|
||||
TaxInfo taxInfo)
|
||||
{
|
||||
var subscriberService = sutProvider.GetDependency<ISubscriberService>();
|
||||
provider.Name = "MSP";
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Id = "subscription_id",
|
||||
Status = "past_due",
|
||||
CollectionMethod = "send_invoice"
|
||||
};
|
||||
|
||||
subscriberService.GetSubscription(provider, Arg.Is<SubscriptionGetOptions>(
|
||||
options => options.Expand.Count == 2 && options.Expand.First() == "customer" && options.Expand.Last() == "test_clock")).Returns(subscription);
|
||||
|
||||
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
|
||||
|
||||
var enterprisePlan = new ProviderPlan
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ProviderId = provider.Id,
|
||||
PlanType = PlanType.EnterpriseMonthly,
|
||||
SeatMinimum = 100,
|
||||
PurchasedSeats = 0,
|
||||
AllocatedSeats = 0
|
||||
};
|
||||
|
||||
var teamsPlan = new ProviderPlan
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ProviderId = provider.Id,
|
||||
PlanType = PlanType.TeamsMonthly,
|
||||
SeatMinimum = 50,
|
||||
PurchasedSeats = 10,
|
||||
AllocatedSeats = 60
|
||||
};
|
||||
|
||||
var providerPlans = new List<ProviderPlan> { enterprisePlan, teamsPlan, };
|
||||
|
||||
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
|
||||
|
||||
var taxInformation =
|
||||
new TaxInformationDTO("US", "12345", "123456789", "123 Example St.", null, "Example Town", "NY");
|
||||
|
||||
subscriberService.GetTaxInformation(provider).Returns(taxInformation);
|
||||
taxInfo.BillingAddressCountry = "AD";
|
||||
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
|
||||
var openInvoice = new Invoice
|
||||
var expected = new Customer
|
||||
{
|
||||
Id = "invoice_id",
|
||||
Status = "open",
|
||||
DueDate = new DateTime(2024, 6, 1),
|
||||
Created = new DateTime(2024, 5, 1),
|
||||
PeriodEnd = new DateTime(2024, 6, 1)
|
||||
Id = "customer_id",
|
||||
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
|
||||
};
|
||||
|
||||
stripeAdapter.InvoiceSearchAsync(Arg.Is<InvoiceSearchOptions>(options =>
|
||||
options.Query == $"subscription:'{subscription.Id}' status:'open'"))
|
||||
.Returns([openInvoice]);
|
||||
stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o =>
|
||||
o.Address.Country == taxInfo.BillingAddressCountry &&
|
||||
o.Address.PostalCode == taxInfo.BillingAddressPostalCode &&
|
||||
o.Address.Line1 == taxInfo.BillingAddressLine1 &&
|
||||
o.Address.Line2 == taxInfo.BillingAddressLine2 &&
|
||||
o.Address.City == taxInfo.BillingAddressCity &&
|
||||
o.Address.State == taxInfo.BillingAddressState &&
|
||||
o.Description == WebUtility.HtmlDecode(provider.BusinessName) &&
|
||||
o.Email == provider.BillingEmail &&
|
||||
o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" &&
|
||||
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" &&
|
||||
o.Metadata["region"] == "" &&
|
||||
o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType &&
|
||||
o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber))
|
||||
.Returns(expected);
|
||||
|
||||
var (gotProviderPlans, gotSubscription, gotTaxInformation, gotSuspension) = await sutProvider.Sut.GetConsolidatedBillingSubscription(provider);
|
||||
var actual = await sutProvider.Sut.SetupCustomer(provider, taxInfo);
|
||||
|
||||
Assert.Equal(2, gotProviderPlans.Count);
|
||||
|
||||
Assert.Equivalent(subscription, gotSubscription);
|
||||
|
||||
Assert.Equivalent(taxInformation, gotTaxInformation);
|
||||
|
||||
Assert.NotNull(gotSuspension);
|
||||
Assert.Equal(openInvoice.DueDate.Value.AddDays(30), gotSuspension.SuspensionDate);
|
||||
Assert.Equal(openInvoice.PeriodEnd, gotSuspension.UnpaidPeriodEndDate);
|
||||
Assert.Equal(30, gotSuspension.GracePeriod);
|
||||
Assert.Equivalent(expected, actual);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region StartSubscription
|
||||
#region SetupSubscription
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task StartSubscription_NullProvider_ThrowsArgumentNullException(
|
||||
public async Task SetupSubscription_NullProvider_ThrowsArgumentNullException(
|
||||
SutProvider<ProviderBillingService> sutProvider) =>
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => sutProvider.Sut.StartSubscription(null));
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => sutProvider.Sut.SetupSubscription(null));
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task StartSubscription_NoProviderPlans_ContactSupport(
|
||||
public async Task SetupSubscription_NoProviderPlans_ContactSupport(
|
||||
SutProvider<ProviderBillingService> sutProvider,
|
||||
Provider provider)
|
||||
{
|
||||
@ -1041,7 +844,7 @@ public class ProviderBillingServiceTests
|
||||
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
|
||||
.Returns(new List<ProviderPlan>());
|
||||
|
||||
await ThrowsContactSupportAsync(() => sutProvider.Sut.StartSubscription(provider));
|
||||
await ThrowsBillingExceptionAsync(() => sutProvider.Sut.SetupSubscription(provider));
|
||||
|
||||
await sutProvider.GetDependency<IStripeAdapter>()
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
@ -1049,7 +852,7 @@ public class ProviderBillingServiceTests
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task StartSubscription_NoProviderTeamsPlan_ContactSupport(
|
||||
public async Task SetupSubscription_NoProviderTeamsPlan_ContactSupport(
|
||||
SutProvider<ProviderBillingService> sutProvider,
|
||||
Provider provider)
|
||||
{
|
||||
@ -1066,7 +869,7 @@ public class ProviderBillingServiceTests
|
||||
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
|
||||
.Returns(providerPlans);
|
||||
|
||||
await ThrowsContactSupportAsync(() => sutProvider.Sut.StartSubscription(provider));
|
||||
await ThrowsBillingExceptionAsync(() => sutProvider.Sut.SetupSubscription(provider));
|
||||
|
||||
await sutProvider.GetDependency<IStripeAdapter>()
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
@ -1074,7 +877,7 @@ public class ProviderBillingServiceTests
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task StartSubscription_NoProviderEnterprisePlan_ContactSupport(
|
||||
public async Task SetupSubscription_NoProviderEnterprisePlan_ContactSupport(
|
||||
SutProvider<ProviderBillingService> sutProvider,
|
||||
Provider provider)
|
||||
{
|
||||
@ -1091,7 +894,7 @@ public class ProviderBillingServiceTests
|
||||
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
|
||||
.Returns(providerPlans);
|
||||
|
||||
await ThrowsContactSupportAsync(() => sutProvider.Sut.StartSubscription(provider));
|
||||
await ThrowsBillingExceptionAsync(() => sutProvider.Sut.SetupSubscription(provider));
|
||||
|
||||
await sutProvider.GetDependency<IStripeAdapter>()
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
@ -1099,7 +902,7 @@ public class ProviderBillingServiceTests
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task StartSubscription_SubscriptionIncomplete_ThrowsBillingException(
|
||||
public async Task SetupSubscription_SubscriptionIncomplete_ThrowsBillingException(
|
||||
SutProvider<ProviderBillingService> sutProvider,
|
||||
Provider provider)
|
||||
{
|
||||
@ -1140,14 +943,11 @@ public class ProviderBillingServiceTests
|
||||
.Returns(
|
||||
new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Incomplete });
|
||||
|
||||
await ThrowsContactSupportAsync(() => sutProvider.Sut.StartSubscription(provider));
|
||||
|
||||
await sutProvider.GetDependency<IProviderRepository>().Received(1)
|
||||
.ReplaceAsync(Arg.Is<Provider>(p => p.GatewaySubscriptionId == "subscription_id"));
|
||||
await ThrowsBillingExceptionAsync(() => sutProvider.Sut.SetupSubscription(provider));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task StartSubscription_Succeeds(
|
||||
public async Task SetupSubscription_Succeeds(
|
||||
SutProvider<ProviderBillingService> sutProvider,
|
||||
Provider provider)
|
||||
{
|
||||
@ -1187,6 +987,8 @@ public class ProviderBillingServiceTests
|
||||
var teamsPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
|
||||
var enterprisePlan = StaticStore.GetPlan(PlanType.EnterpriseMonthly);
|
||||
|
||||
var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active };
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>().SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(
|
||||
sub =>
|
||||
sub.AutomaticTax.Enabled == true &&
|
||||
@ -1200,16 +1002,266 @@ public class ProviderBillingServiceTests
|
||||
sub.Items.ElementAt(1).Quantity == 100 &&
|
||||
sub.Metadata["providerId"] == provider.Id.ToString() &&
|
||||
sub.OffSession == true &&
|
||||
sub.ProrationBehavior == StripeConstants.ProrationBehavior.CreateProrations)).Returns(new Subscription
|
||||
{
|
||||
Id = "subscription_id",
|
||||
Status = StripeConstants.SubscriptionStatus.Active
|
||||
});
|
||||
sub.ProrationBehavior == StripeConstants.ProrationBehavior.CreateProrations)).Returns(expected);
|
||||
|
||||
await sutProvider.Sut.StartSubscription(provider);
|
||||
var actual = await sutProvider.Sut.SetupSubscription(provider);
|
||||
|
||||
await sutProvider.GetDependency<IProviderRepository>().Received(1)
|
||||
.ReplaceAsync(Arg.Is<Provider>(p => p.GatewaySubscriptionId == "subscription_id"));
|
||||
Assert.Equivalent(expected, actual);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region UpdateSeatMinimums
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateSeatMinimums_NullProvider_ThrowsArgumentNullException(
|
||||
SutProvider<ProviderBillingService> sutProvider) =>
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => sutProvider.Sut.UpdateSeatMinimums(null, 0, 0));
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateSeatMinimums_NegativeSeatMinimum_ThrowsBadRequestException(
|
||||
Provider provider,
|
||||
SutProvider<ProviderBillingService> sutProvider) =>
|
||||
await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.UpdateSeatMinimums(provider, -10, 100));
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateSeatMinimums_NoPurchasedSeats_SyncsStripeWithNewSeatMinimum(
|
||||
Provider provider,
|
||||
SutProvider<ProviderBillingService> sutProvider)
|
||||
{
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
|
||||
|
||||
const string enterpriseLineItemId = "enterprise_line_item_id";
|
||||
const string teamsLineItemId = "teams_line_item_id";
|
||||
|
||||
var enterprisePriceId = StaticStore.GetPlan(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
|
||||
var teamsPriceId = StaticStore.GetPlan(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem
|
||||
{
|
||||
Id = enterpriseLineItemId,
|
||||
Price = new Price { Id = enterprisePriceId }
|
||||
},
|
||||
new SubscriptionItem
|
||||
{
|
||||
Id = teamsLineItemId,
|
||||
Price = new Price { Id = teamsPriceId }
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
stripeAdapter.SubscriptionGetAsync(provider.GatewaySubscriptionId).Returns(subscription);
|
||||
|
||||
var providerPlans = new List<ProviderPlan>
|
||||
{
|
||||
new() { PlanType = PlanType.EnterpriseMonthly, SeatMinimum = 50, PurchasedSeats = 0 },
|
||||
new() { PlanType = PlanType.TeamsMonthly, SeatMinimum = 30, PurchasedSeats = 0 }
|
||||
};
|
||||
|
||||
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
|
||||
|
||||
await sutProvider.Sut.UpdateSeatMinimums(provider, 70, 50);
|
||||
|
||||
await providerPlanRepository.Received(1).ReplaceAsync(Arg.Is<ProviderPlan>(
|
||||
providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly && providerPlan.SeatMinimum == 70));
|
||||
|
||||
await providerPlanRepository.Received(1).ReplaceAsync(Arg.Is<ProviderPlan>(
|
||||
providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly && providerPlan.SeatMinimum == 50));
|
||||
|
||||
await stripeAdapter.Received(1).SubscriptionUpdateAsync(provider.GatewaySubscriptionId,
|
||||
Arg.Is<SubscriptionUpdateOptions>(
|
||||
options =>
|
||||
options.Items.Count == 2 &&
|
||||
options.Items.ElementAt(0).Id == enterpriseLineItemId &&
|
||||
options.Items.ElementAt(0).Quantity == 70 &&
|
||||
options.Items.ElementAt(1).Id == teamsLineItemId &&
|
||||
options.Items.ElementAt(1).Quantity == 50));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateSeatMinimums_PurchasedSeats_NewMinimumLessThanTotal_UpdatesPurchasedSeats(
|
||||
Provider provider,
|
||||
SutProvider<ProviderBillingService> sutProvider)
|
||||
{
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
|
||||
|
||||
const string enterpriseLineItemId = "enterprise_line_item_id";
|
||||
const string teamsLineItemId = "teams_line_item_id";
|
||||
|
||||
var enterprisePriceId = StaticStore.GetPlan(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
|
||||
var teamsPriceId = StaticStore.GetPlan(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem
|
||||
{
|
||||
Id = enterpriseLineItemId,
|
||||
Price = new Price { Id = enterprisePriceId }
|
||||
},
|
||||
new SubscriptionItem
|
||||
{
|
||||
Id = teamsLineItemId,
|
||||
Price = new Price { Id = teamsPriceId }
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
stripeAdapter.SubscriptionGetAsync(provider.GatewaySubscriptionId).Returns(subscription);
|
||||
|
||||
var providerPlans = new List<ProviderPlan>
|
||||
{
|
||||
new() { PlanType = PlanType.EnterpriseMonthly, SeatMinimum = 50, PurchasedSeats = 20 },
|
||||
new() { PlanType = PlanType.TeamsMonthly, SeatMinimum = 50, PurchasedSeats = 20 }
|
||||
};
|
||||
|
||||
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
|
||||
|
||||
await sutProvider.Sut.UpdateSeatMinimums(provider, 60, 60);
|
||||
|
||||
await providerPlanRepository.Received(1).ReplaceAsync(Arg.Is<ProviderPlan>(
|
||||
providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly && providerPlan.SeatMinimum == 60 && providerPlan.PurchasedSeats == 10));
|
||||
|
||||
await providerPlanRepository.Received(1).ReplaceAsync(Arg.Is<ProviderPlan>(
|
||||
providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly && providerPlan.SeatMinimum == 60 && providerPlan.PurchasedSeats == 10));
|
||||
|
||||
await stripeAdapter.DidNotReceiveWithAnyArgs()
|
||||
.SubscriptionUpdateAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateSeatMinimums_PurchasedSeats_NewMinimumGreaterThanTotal_ClearsPurchasedSeats_SyncsStripeWithNewSeatMinimum(
|
||||
Provider provider,
|
||||
SutProvider<ProviderBillingService> sutProvider)
|
||||
{
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
|
||||
|
||||
const string enterpriseLineItemId = "enterprise_line_item_id";
|
||||
const string teamsLineItemId = "teams_line_item_id";
|
||||
|
||||
var enterprisePriceId = StaticStore.GetPlan(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
|
||||
var teamsPriceId = StaticStore.GetPlan(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem
|
||||
{
|
||||
Id = enterpriseLineItemId,
|
||||
Price = new Price { Id = enterprisePriceId }
|
||||
},
|
||||
new SubscriptionItem
|
||||
{
|
||||
Id = teamsLineItemId,
|
||||
Price = new Price { Id = teamsPriceId }
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
stripeAdapter.SubscriptionGetAsync(provider.GatewaySubscriptionId).Returns(subscription);
|
||||
|
||||
var providerPlans = new List<ProviderPlan>
|
||||
{
|
||||
new() { PlanType = PlanType.EnterpriseMonthly, SeatMinimum = 50, PurchasedSeats = 20 },
|
||||
new() { PlanType = PlanType.TeamsMonthly, SeatMinimum = 50, PurchasedSeats = 20 }
|
||||
};
|
||||
|
||||
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
|
||||
|
||||
await sutProvider.Sut.UpdateSeatMinimums(provider, 80, 80);
|
||||
|
||||
await providerPlanRepository.Received(1).ReplaceAsync(Arg.Is<ProviderPlan>(
|
||||
providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly && providerPlan.SeatMinimum == 80 && providerPlan.PurchasedSeats == 0));
|
||||
|
||||
await providerPlanRepository.Received(1).ReplaceAsync(Arg.Is<ProviderPlan>(
|
||||
providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly && providerPlan.SeatMinimum == 80 && providerPlan.PurchasedSeats == 0));
|
||||
|
||||
await stripeAdapter.Received(1).SubscriptionUpdateAsync(provider.GatewaySubscriptionId,
|
||||
Arg.Is<SubscriptionUpdateOptions>(
|
||||
options =>
|
||||
options.Items.Count == 2 &&
|
||||
options.Items.ElementAt(0).Id == enterpriseLineItemId &&
|
||||
options.Items.ElementAt(0).Quantity == 80 &&
|
||||
options.Items.ElementAt(1).Id == teamsLineItemId &&
|
||||
options.Items.ElementAt(1).Quantity == 80));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateSeatMinimums_SinglePlanTypeUpdate_Succeeds(
|
||||
Provider provider,
|
||||
SutProvider<ProviderBillingService> sutProvider)
|
||||
{
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
|
||||
|
||||
const string enterpriseLineItemId = "enterprise_line_item_id";
|
||||
const string teamsLineItemId = "teams_line_item_id";
|
||||
|
||||
var enterprisePriceId = StaticStore.GetPlan(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
|
||||
var teamsPriceId = StaticStore.GetPlan(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem
|
||||
{
|
||||
Id = enterpriseLineItemId,
|
||||
Price = new Price { Id = enterprisePriceId }
|
||||
},
|
||||
new SubscriptionItem
|
||||
{
|
||||
Id = teamsLineItemId,
|
||||
Price = new Price { Id = teamsPriceId }
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
stripeAdapter.SubscriptionGetAsync(provider.GatewaySubscriptionId).Returns(subscription);
|
||||
|
||||
var providerPlans = new List<ProviderPlan>
|
||||
{
|
||||
new() { PlanType = PlanType.EnterpriseMonthly, SeatMinimum = 50, PurchasedSeats = 0 },
|
||||
new() { PlanType = PlanType.TeamsMonthly, SeatMinimum = 30, PurchasedSeats = 0 }
|
||||
};
|
||||
|
||||
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
|
||||
|
||||
await sutProvider.Sut.UpdateSeatMinimums(provider, 70, 30);
|
||||
|
||||
await providerPlanRepository.Received(1).ReplaceAsync(Arg.Is<ProviderPlan>(
|
||||
providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly && providerPlan.SeatMinimum == 70));
|
||||
|
||||
await providerPlanRepository.DidNotReceive().ReplaceAsync(Arg.Is<ProviderPlan>(
|
||||
providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly));
|
||||
|
||||
await stripeAdapter.Received(1).SubscriptionUpdateAsync(provider.GatewaySubscriptionId,
|
||||
Arg.Is<SubscriptionUpdateOptions>(
|
||||
options =>
|
||||
options.Items.Count == 1 &&
|
||||
options.Items.ElementAt(0).Id == enterpriseLineItemId &&
|
||||
options.Items.ElementAt(0).Quantity == 70));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
@ -0,0 +1,224 @@
|
||||
#nullable enable
|
||||
using System.Reflection;
|
||||
using System.Security.Claims;
|
||||
using Bit.Commercial.Core.SecretsManager.AuthorizationHandlers.Secrets;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.SecretsManager.AuthorizationRequirements;
|
||||
using Bit.Core.SecretsManager.Entities;
|
||||
using Bit.Core.SecretsManager.Queries.Interfaces;
|
||||
using Bit.Core.SecretsManager.Repositories;
|
||||
using Bit.Core.Test.SecretsManager.AutoFixture.ProjectsFixture;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Commercial.Core.Test.SecretsManager.AuthorizationHandlers.Secrets;
|
||||
|
||||
[SutProviderCustomize]
|
||||
[ProjectCustomize]
|
||||
public class BulkSecretAuthorizationHandlerTests
|
||||
{
|
||||
[Fact]
|
||||
public void BulkSecretOperations_OnlyPublicStatic()
|
||||
{
|
||||
var publicStaticFields = typeof(BulkSecretOperations).GetFields(BindingFlags.Public | BindingFlags.Static);
|
||||
var allFields = typeof(BulkSecretOperations).GetFields();
|
||||
Assert.Equal(publicStaticFields.Length, allFields.Length);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task Handler_MisMatchedOrganizations_DoesNotSucceed(
|
||||
SutProvider<BulkSecretAuthorizationHandler> sutProvider, List<Secret> resources,
|
||||
ClaimsPrincipal claimsPrincipal)
|
||||
{
|
||||
var requirement = BulkSecretOperations.ReadAll;
|
||||
resources[0].OrganizationId = Guid.NewGuid();
|
||||
sutProvider.GetDependency<ICurrentContext>().AccessSecretsManager(Arg.Any<Guid>())
|
||||
.ReturnsForAnyArgs(true);
|
||||
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
|
||||
claimsPrincipal, resources);
|
||||
|
||||
await sutProvider.Sut.HandleAsync(authzContext);
|
||||
|
||||
Assert.False(authzContext.HasSucceeded);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task Handler_NoAccessToSecretsManager_DoesNotSucceed(
|
||||
SutProvider<BulkSecretAuthorizationHandler> sutProvider, List<Secret> resources,
|
||||
ClaimsPrincipal claimsPrincipal)
|
||||
{
|
||||
var requirement = BulkSecretOperations.ReadAll;
|
||||
resources = SetSameOrganization(resources);
|
||||
sutProvider.GetDependency<ICurrentContext>().AccessSecretsManager(Arg.Any<Guid>())
|
||||
.ReturnsForAnyArgs(false);
|
||||
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
|
||||
claimsPrincipal, resources);
|
||||
|
||||
await sutProvider.Sut.HandleAsync(authzContext);
|
||||
|
||||
Assert.False(authzContext.HasSucceeded);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task Handler_UnsupportedSecretOperationRequirement_Throws(
|
||||
SutProvider<BulkSecretAuthorizationHandler> sutProvider, List<Secret> resources,
|
||||
ClaimsPrincipal claimsPrincipal)
|
||||
{
|
||||
var requirement = new BulkSecretOperationRequirement();
|
||||
resources = SetSameOrganization(resources);
|
||||
SetupUserSubstitutes(sutProvider, AccessClientType.User, resources.First().OrganizationId);
|
||||
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
|
||||
claimsPrincipal, resources);
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => sutProvider.Sut.HandleAsync(authzContext));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData(AccessClientType.User)]
|
||||
[BitAutoData(AccessClientType.NoAccessCheck)]
|
||||
[BitAutoData(AccessClientType.ServiceAccount)]
|
||||
public async Task Handler_NoAccessToSecrets_DoesNotSucceed(
|
||||
AccessClientType accessClientType,
|
||||
SutProvider<BulkSecretAuthorizationHandler> sutProvider, List<Secret> resources,
|
||||
ClaimsPrincipal claimsPrincipal)
|
||||
{
|
||||
var requirement = BulkSecretOperations.ReadAll;
|
||||
resources = SetSameOrganization(resources);
|
||||
var secretIds =
|
||||
SetupSecretAccessRequest(sutProvider, resources, accessClientType, resources.First().OrganizationId);
|
||||
sutProvider.GetDependency<ISecretRepository>()
|
||||
.AccessToSecretsAsync(Arg.Any<IEnumerable<Guid>>(), Arg.Any<Guid>(), Arg.Any<AccessClientType>())
|
||||
.Returns(secretIds.ToDictionary(id => id, _ => (false, false)));
|
||||
|
||||
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
|
||||
claimsPrincipal, resources);
|
||||
|
||||
await sutProvider.Sut.HandleAsync(authzContext);
|
||||
|
||||
Assert.False(authzContext.HasSucceeded);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData(AccessClientType.User)]
|
||||
[BitAutoData(AccessClientType.NoAccessCheck)]
|
||||
[BitAutoData(AccessClientType.ServiceAccount)]
|
||||
public async Task Handler_HasAccessToSomeSecrets_DoesNotSucceed(
|
||||
AccessClientType accessClientType,
|
||||
SutProvider<BulkSecretAuthorizationHandler> sutProvider, List<Secret> resources,
|
||||
ClaimsPrincipal claimsPrincipal)
|
||||
{
|
||||
var requirement = BulkSecretOperations.ReadAll;
|
||||
resources = SetSameOrganization(resources);
|
||||
var secretIds =
|
||||
SetupSecretAccessRequest(sutProvider, resources, accessClientType, resources.First().OrganizationId);
|
||||
|
||||
var accessResult = secretIds.ToDictionary(secretId => secretId, _ => (false, false));
|
||||
accessResult[secretIds.First()] = (true, true);
|
||||
sutProvider.GetDependency<ISecretRepository>()
|
||||
.AccessToSecretsAsync(Arg.Any<IEnumerable<Guid>>(), Arg.Any<Guid>(), Arg.Any<AccessClientType>())
|
||||
.Returns(accessResult);
|
||||
|
||||
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
|
||||
claimsPrincipal, resources);
|
||||
|
||||
await sutProvider.Sut.HandleAsync(authzContext);
|
||||
|
||||
Assert.False(authzContext.HasSucceeded);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData(AccessClientType.User)]
|
||||
[BitAutoData(AccessClientType.NoAccessCheck)]
|
||||
[BitAutoData(AccessClientType.ServiceAccount)]
|
||||
public async Task Handler_PartialAccessReturn_DoesNotSucceed(
|
||||
AccessClientType accessClientType,
|
||||
SutProvider<BulkSecretAuthorizationHandler> sutProvider, List<Secret> resources,
|
||||
ClaimsPrincipal claimsPrincipal)
|
||||
{
|
||||
var requirement = BulkSecretOperations.ReadAll;
|
||||
resources = SetSameOrganization(resources);
|
||||
var secretIds =
|
||||
SetupSecretAccessRequest(sutProvider, resources, accessClientType, resources.First().OrganizationId);
|
||||
|
||||
var accessResult = secretIds.ToDictionary(secretId => secretId, _ => (false, false));
|
||||
accessResult.Remove(secretIds.First());
|
||||
sutProvider.GetDependency<ISecretRepository>()
|
||||
.AccessToSecretsAsync(Arg.Any<IEnumerable<Guid>>(), Arg.Any<Guid>(), Arg.Any<AccessClientType>())
|
||||
.Returns(accessResult);
|
||||
|
||||
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
|
||||
claimsPrincipal, resources);
|
||||
|
||||
await sutProvider.Sut.HandleAsync(authzContext);
|
||||
|
||||
Assert.False(authzContext.HasSucceeded);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData(AccessClientType.User)]
|
||||
[BitAutoData(AccessClientType.NoAccessCheck)]
|
||||
[BitAutoData(AccessClientType.ServiceAccount)]
|
||||
public async Task Handler_HasAccessToAllSecrets_Success(
|
||||
AccessClientType accessClientType,
|
||||
SutProvider<BulkSecretAuthorizationHandler> sutProvider, List<Secret> resources,
|
||||
ClaimsPrincipal claimsPrincipal)
|
||||
{
|
||||
var requirement = BulkSecretOperations.ReadAll;
|
||||
resources = SetSameOrganization(resources);
|
||||
var secretIds =
|
||||
SetupSecretAccessRequest(sutProvider, resources, accessClientType, resources.First().OrganizationId);
|
||||
|
||||
var accessResult = secretIds.ToDictionary(secretId => secretId, _ => (true, true));
|
||||
sutProvider.GetDependency<ISecretRepository>()
|
||||
.AccessToSecretsAsync(Arg.Any<IEnumerable<Guid>>(), Arg.Any<Guid>(), Arg.Any<AccessClientType>())
|
||||
.Returns(accessResult);
|
||||
|
||||
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
|
||||
claimsPrincipal, resources);
|
||||
|
||||
await sutProvider.Sut.HandleAsync(authzContext);
|
||||
|
||||
Assert.True(authzContext.HasSucceeded);
|
||||
}
|
||||
|
||||
private static List<Secret> SetSameOrganization(List<Secret> secrets)
|
||||
{
|
||||
var organizationId = secrets.First().OrganizationId;
|
||||
foreach (var secret in secrets)
|
||||
{
|
||||
secret.OrganizationId = organizationId;
|
||||
}
|
||||
|
||||
return secrets;
|
||||
}
|
||||
|
||||
private static void SetupUserSubstitutes(
|
||||
SutProvider<BulkSecretAuthorizationHandler> sutProvider,
|
||||
AccessClientType accessClientType,
|
||||
Guid organizationId,
|
||||
Guid userId = new())
|
||||
{
|
||||
sutProvider.GetDependency<ICurrentContext>().AccessSecretsManager(organizationId)
|
||||
.Returns(true);
|
||||
sutProvider.GetDependency<IAccessClientQuery>().GetAccessClientAsync(default, organizationId)
|
||||
.ReturnsForAnyArgs((accessClientType, userId));
|
||||
}
|
||||
|
||||
private static List<Guid> SetupSecretAccessRequest(
|
||||
SutProvider<BulkSecretAuthorizationHandler> sutProvider,
|
||||
IEnumerable<Secret> resources,
|
||||
AccessClientType accessClientType,
|
||||
Guid organizationId,
|
||||
Guid userId = new())
|
||||
{
|
||||
SetupUserSubstitutes(sutProvider, accessClientType, organizationId, userId);
|
||||
return resources.Select(s => s.Id).ToList();
|
||||
}
|
||||
}
|
@ -0,0 +1,96 @@
|
||||
using Bit.Commercial.Core.SecretsManager.Commands.Requests;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Bit.Test.Common.Helpers;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Commercial.Core.Test.SecretsManager.Commands.Requests;
|
||||
|
||||
[SutProviderCustomize]
|
||||
public class RequestSMAccessCommandTests
|
||||
{
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task SendRequestAccessToSM_Success(
|
||||
User user,
|
||||
Organization organization,
|
||||
ICollection<OrganizationUserUserDetails> orgUsers,
|
||||
string emailContent,
|
||||
SutProvider<RequestSMAccessCommand> sutProvider)
|
||||
{
|
||||
foreach (var userDetails in orgUsers)
|
||||
{
|
||||
userDetails.Type = OrganizationUserType.Admin;
|
||||
}
|
||||
|
||||
orgUsers.First().Type = OrganizationUserType.Owner;
|
||||
|
||||
await sutProvider.Sut.SendRequestAccessToSM(organization, orgUsers, user, emailContent);
|
||||
|
||||
var adminEmailList = orgUsers
|
||||
.Where(o => o.Type <= OrganizationUserType.Admin)
|
||||
.Select(a => a.Email)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
await sutProvider.GetDependency<IMailService>()
|
||||
.Received(1)
|
||||
.SendRequestSMAccessToAdminEmailAsync(Arg.Is(AssertHelper.AssertPropertyEqual(adminEmailList)), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task SendRequestAccessToSM_NoAdmins_ThrowsBadRequestException(
|
||||
User user,
|
||||
Organization organization,
|
||||
ICollection<OrganizationUserUserDetails> orgUsers,
|
||||
string emailContent,
|
||||
SutProvider<RequestSMAccessCommand> sutProvider)
|
||||
{
|
||||
// Set OrgUsers so they are only users, no admins or owners
|
||||
foreach (OrganizationUserUserDetails userDetails in orgUsers)
|
||||
{
|
||||
userDetails.Type = OrganizationUserType.User;
|
||||
}
|
||||
|
||||
await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.SendRequestAccessToSM(organization, orgUsers, user, emailContent));
|
||||
}
|
||||
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task SendRequestAccessToSM_SomeAdmins_EmailListIsAsExpected(
|
||||
User user,
|
||||
Organization organization,
|
||||
ICollection<OrganizationUserUserDetails> orgUsers,
|
||||
string emailContent,
|
||||
SutProvider<RequestSMAccessCommand> sutProvider)
|
||||
{
|
||||
foreach (OrganizationUserUserDetails userDetails in orgUsers)
|
||||
{
|
||||
userDetails.Type = OrganizationUserType.User;
|
||||
}
|
||||
|
||||
// Make the first orgUser an admin so it's a mix of Admin + Users
|
||||
orgUsers.First().Type = OrganizationUserType.Admin;
|
||||
|
||||
var adminEmailList = orgUsers
|
||||
.Where(o => o.Type == OrganizationUserType.Admin) // Filter by Admin type
|
||||
.Select(a => a.Email)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
await sutProvider.Sut.SendRequestAccessToSM(organization, orgUsers, user, emailContent);
|
||||
|
||||
await sutProvider.GetDependency<IMailService>()
|
||||
.Received(1)
|
||||
.SendRequestSMAccessToAdminEmailAsync(Arg.Is(AssertHelper.AssertPropertyEqual(adminEmailList)), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>());
|
||||
}
|
||||
}
|
@ -43,7 +43,6 @@ public class PostUserCommandTests
|
||||
Arg.Is<OrganizationUserInvite>(i =>
|
||||
i.Emails.Single().Equals(scimUserRequestModel.PrimaryEmail.ToLowerInvariant()) &&
|
||||
i.Type == OrganizationUserType.User &&
|
||||
!i.AccessAll &&
|
||||
!i.Collections.Any() &&
|
||||
!i.Groups.Any() &&
|
||||
i.AccessSecretsManager), externalId)
|
||||
@ -56,7 +55,6 @@ public class PostUserCommandTests
|
||||
Arg.Is<OrganizationUserInvite>(i =>
|
||||
i.Emails.Single().Equals(scimUserRequestModel.PrimaryEmail.ToLowerInvariant()) &&
|
||||
i.Type == OrganizationUserType.User &&
|
||||
!i.AccessAll &&
|
||||
!i.Collections.Any() &&
|
||||
!i.Groups.Any() &&
|
||||
i.AccessSecretsManager), externalId);
|
||||
|
@ -1,37 +0,0 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 16
|
||||
VisualStudioVersion = 25.0.1703.8
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Commercial.Core.Test", "Commercial.Core.Test\Commercial.Core.Test.csproj", "{70F03E72-2F38-4497-BF31-EA19B13B2161}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Scim.Test", "Scim.Test\Scim.Test.csproj", "{9BC8E2C9-400D-4FA7-86CA-3E1794E7CA4C}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Scim.IntegrationTest", "Scim.IntegrationTest\Scim.IntegrationTest.csproj", "{45CD3F1B-127E-44B7-B22B-28D677B621D9}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{70F03E72-2F38-4497-BF31-EA19B13B2161}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{70F03E72-2F38-4497-BF31-EA19B13B2161}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{70F03E72-2F38-4497-BF31-EA19B13B2161}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{70F03E72-2F38-4497-BF31-EA19B13B2161}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{9BC8E2C9-400D-4FA7-86CA-3E1794E7CA4C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{9BC8E2C9-400D-4FA7-86CA-3E1794E7CA4C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{9BC8E2C9-400D-4FA7-86CA-3E1794E7CA4C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{9BC8E2C9-400D-4FA7-86CA-3E1794E7CA4C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{45CD3F1B-127E-44B7-B22B-28D677B621D9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{45CD3F1B-127E-44B7-B22B-28D677B621D9}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{45CD3F1B-127E-44B7-B22B-28D677B621D9}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{45CD3F1B-127E-44B7-B22B-28D677B621D9}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {BAD5FA17-2653-401A-A1E5-A31C420B9DE8}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
@ -9,7 +9,11 @@ $service = "mysql"
|
||||
|
||||
Write-Output "--- Attempting to start $service service ---"
|
||||
|
||||
docker-compose --profile $service up -d --no-recreate
|
||||
# Attempt to start mysql but if docker-compose doesn't
|
||||
# exist just trust that the user has it running some other way
|
||||
if (command -v docker-compose) {
|
||||
docker-compose --profile $service up -d --no-recreate
|
||||
}
|
||||
|
||||
dotnet tool restore
|
||||
|
||||
|
@ -27,7 +27,9 @@ if ($all -or $postgres -or $mysql -or $sqlite) {
|
||||
|
||||
if ($all -or $mssql) {
|
||||
function Get-UserSecrets {
|
||||
return dotnet user-secrets list --json --project ../src/Api | ConvertFrom-Json
|
||||
# The dotnet cli command sometimes adds //BEGIN and //END comments to the output, Where-Object removes comments
|
||||
# to ensure a valid json
|
||||
return dotnet user-secrets list --json --project ../src/Api | Where-Object { $_ -notmatch "^//" } | ConvertFrom-Json
|
||||
}
|
||||
|
||||
if ($selfhost) {
|
||||
|
@ -2,5 +2,8 @@
|
||||
"sdk": {
|
||||
"version": "8.0.100",
|
||||
"rollForward": "latestFeature"
|
||||
},
|
||||
"msbuild-sdks": {
|
||||
"Microsoft.Build.Traversal": "4.1.0"
|
||||
}
|
||||
}
|
||||
|
@ -13,6 +13,7 @@ using Bit.Core.Billing.Entities;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Repositories;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
@ -40,6 +41,7 @@ public class ProvidersController : Controller
|
||||
private readonly ICreateProviderCommand _createProviderCommand;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IProviderPlanRepository _providerPlanRepository;
|
||||
private readonly IProviderBillingService _providerBillingService;
|
||||
private readonly string _stripeUrl;
|
||||
private readonly string _braintreeMerchantUrl;
|
||||
private readonly string _braintreeMerchantId;
|
||||
@ -57,6 +59,7 @@ public class ProvidersController : Controller
|
||||
ICreateProviderCommand createProviderCommand,
|
||||
IFeatureService featureService,
|
||||
IProviderPlanRepository providerPlanRepository,
|
||||
IProviderBillingService providerBillingService,
|
||||
IWebHostEnvironment webHostEnvironment)
|
||||
{
|
||||
_organizationRepository = organizationRepository;
|
||||
@ -71,6 +74,7 @@ public class ProvidersController : Controller
|
||||
_createProviderCommand = createProviderCommand;
|
||||
_featureService = featureService;
|
||||
_providerPlanRepository = providerPlanRepository;
|
||||
_providerBillingService = providerBillingService;
|
||||
_stripeUrl = webHostEnvironment.GetStripeUrl();
|
||||
_braintreeMerchantUrl = webHostEnvironment.GetBraintreeMerchantUrl();
|
||||
_braintreeMerchantId = globalSettings.Braintree.MerchantId;
|
||||
@ -223,19 +227,10 @@ public class ProvidersController : Controller
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var providerPlan in providerPlans)
|
||||
{
|
||||
if (providerPlan.PlanType == PlanType.EnterpriseMonthly)
|
||||
{
|
||||
providerPlan.SeatMinimum = model.EnterpriseMonthlySeatMinimum;
|
||||
}
|
||||
else if (providerPlan.PlanType == PlanType.TeamsMonthly)
|
||||
{
|
||||
providerPlan.SeatMinimum = model.TeamsMonthlySeatMinimum;
|
||||
}
|
||||
|
||||
await _providerPlanRepository.ReplaceAsync(providerPlan);
|
||||
}
|
||||
await _providerBillingService.UpdateSeatMinimums(
|
||||
provider,
|
||||
model.EnterpriseMonthlySeatMinimum,
|
||||
model.TeamsMonthlySeatMinimum);
|
||||
}
|
||||
|
||||
return RedirectToAction("Edit", new { id });
|
||||
@ -315,8 +310,7 @@ public class ProvidersController : Controller
|
||||
return RedirectToAction("Index");
|
||||
}
|
||||
|
||||
var flexibleCollectionsV1Enabled = _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1);
|
||||
var organization = model.CreateOrganization(provider, flexibleCollectionsV1Enabled);
|
||||
var organization = model.CreateOrganization(provider);
|
||||
await _organizationService.CreatePendingOrganization(organization, model.Owners, User, _userService, model.SalesAssistedTrialStarted);
|
||||
await _providerService.AddOrganization(providerId, organization.Id, null);
|
||||
|
||||
|
@ -179,30 +179,91 @@ public class OrganizationEditModel : OrganizationViewModel
|
||||
* This is mapped manually below to provide some type safety in case the plan objects change
|
||||
* Add mappings for individual properties as you need them
|
||||
*/
|
||||
public IEnumerable<Dictionary<string, object>> GetPlansHelper() =>
|
||||
public object GetPlansHelper() =>
|
||||
StaticStore.Plans
|
||||
.Where(p => p.SupportsSecretsManager)
|
||||
.Select(p => new Dictionary<string, object>
|
||||
.Select(p =>
|
||||
{
|
||||
{ "type", p.Type },
|
||||
{ "baseServiceAccount", p.SecretsManager.BaseServiceAccount }
|
||||
var plan = new
|
||||
{
|
||||
Type = p.Type,
|
||||
ProductTier = p.ProductTier,
|
||||
Name = p.Name,
|
||||
IsAnnual = p.IsAnnual,
|
||||
NameLocalizationKey = p.NameLocalizationKey,
|
||||
DescriptionLocalizationKey = p.DescriptionLocalizationKey,
|
||||
CanBeUsedByBusiness = p.CanBeUsedByBusiness,
|
||||
TrialPeriodDays = p.TrialPeriodDays,
|
||||
HasSelfHost = p.HasSelfHost,
|
||||
HasPolicies = p.HasPolicies,
|
||||
HasGroups = p.HasGroups,
|
||||
HasDirectory = p.HasDirectory,
|
||||
HasEvents = p.HasEvents,
|
||||
HasTotp = p.HasTotp,
|
||||
Has2fa = p.Has2fa,
|
||||
HasApi = p.HasApi,
|
||||
HasSso = p.HasSso,
|
||||
HasKeyConnector = p.HasKeyConnector,
|
||||
HasScim = p.HasScim,
|
||||
HasResetPassword = p.HasResetPassword,
|
||||
UsersGetPremium = p.UsersGetPremium,
|
||||
HasCustomPermissions = p.HasCustomPermissions,
|
||||
UpgradeSortOrder = p.UpgradeSortOrder,
|
||||
DisplaySortOrder = p.DisplaySortOrder,
|
||||
LegacyYear = p.LegacyYear,
|
||||
Disabled = p.Disabled,
|
||||
SupportsSecretsManager = p.SupportsSecretsManager,
|
||||
PasswordManager =
|
||||
new
|
||||
{
|
||||
StripePlanId = p.PasswordManager?.StripePlanId,
|
||||
StripeSeatPlanId = p.PasswordManager?.StripeSeatPlanId,
|
||||
StripeProviderPortalSeatPlanId = p.PasswordManager?.StripeProviderPortalSeatPlanId,
|
||||
BasePrice = p.PasswordManager?.BasePrice,
|
||||
SeatPrice = p.PasswordManager?.SeatPrice,
|
||||
ProviderPortalSeatPrice = p.PasswordManager?.ProviderPortalSeatPrice,
|
||||
AllowSeatAutoscale = p.PasswordManager?.AllowSeatAutoscale,
|
||||
HasAdditionalSeatsOption = p.PasswordManager?.HasAdditionalSeatsOption,
|
||||
MaxAdditionalSeats = p.PasswordManager?.MaxAdditionalSeats,
|
||||
BaseSeats = p.PasswordManager?.BaseSeats,
|
||||
HasPremiumAccessOption = p.PasswordManager?.HasPremiumAccessOption,
|
||||
StripePremiumAccessPlanId = p.PasswordManager?.StripePremiumAccessPlanId,
|
||||
PremiumAccessOptionPrice = p.PasswordManager?.PremiumAccessOptionPrice,
|
||||
MaxSeats = p.PasswordManager?.MaxSeats,
|
||||
BaseStorageGb = p.PasswordManager?.BaseStorageGb,
|
||||
HasAdditionalStorageOption = p.PasswordManager?.HasAdditionalStorageOption,
|
||||
AdditionalStoragePricePerGb = p.PasswordManager?.AdditionalStoragePricePerGb,
|
||||
StripeStoragePlanId = p.PasswordManager?.StripeStoragePlanId,
|
||||
MaxAdditionalStorage = p.PasswordManager?.MaxAdditionalStorage,
|
||||
MaxCollections = p.PasswordManager?.MaxCollections
|
||||
},
|
||||
SecretsManager = new
|
||||
{
|
||||
MaxServiceAccounts = p.SecretsManager?.MaxServiceAccounts,
|
||||
AllowServiceAccountsAutoscale = p.SecretsManager?.AllowServiceAccountsAutoscale,
|
||||
StripeServiceAccountPlanId = p.SecretsManager?.StripeServiceAccountPlanId,
|
||||
AdditionalPricePerServiceAccount = p.SecretsManager?.AdditionalPricePerServiceAccount,
|
||||
BaseServiceAccount = p.SecretsManager?.BaseServiceAccount,
|
||||
MaxAdditionalServiceAccount = p.SecretsManager?.MaxAdditionalServiceAccount,
|
||||
HasAdditionalServiceAccountOption = p.SecretsManager?.HasAdditionalServiceAccountOption,
|
||||
StripeSeatPlanId = p.SecretsManager?.StripeSeatPlanId,
|
||||
HasAdditionalSeatsOption = p.SecretsManager?.HasAdditionalSeatsOption,
|
||||
BasePrice = p.SecretsManager?.BasePrice,
|
||||
SeatPrice = p.SecretsManager?.SeatPrice,
|
||||
BaseSeats = p.SecretsManager?.BaseSeats,
|
||||
MaxSeats = p.SecretsManager?.MaxSeats,
|
||||
MaxAdditionalSeats = p.SecretsManager?.MaxAdditionalSeats,
|
||||
AllowSeatAutoscale = p.SecretsManager?.AllowSeatAutoscale,
|
||||
MaxProjects = p.SecretsManager?.MaxProjects
|
||||
}
|
||||
};
|
||||
return plan;
|
||||
});
|
||||
|
||||
public Organization CreateOrganization(Provider provider, bool flexibleCollectionsV1Enabled)
|
||||
public Organization CreateOrganization(Provider provider)
|
||||
{
|
||||
BillingEmail = provider.BillingEmail;
|
||||
|
||||
var newOrg = new Organization
|
||||
{
|
||||
// Flexible Collections MVP is fully released and all organizations must always have this setting enabled.
|
||||
// AC-1714 will remove this flag after all old code has been removed.
|
||||
FlexibleCollections = true,
|
||||
|
||||
// This is a transitional setting that defaults to ON until Flexible Collections v1 is released
|
||||
// (to preserve existing behavior) and defaults to OFF after release (enabling new behavior)
|
||||
AllowAdminAccessToAllCollectionItems = !flexibleCollectionsV1Enabled
|
||||
};
|
||||
return ToOrganization(newOrg);
|
||||
return ToOrganization(new Organization());
|
||||
}
|
||||
|
||||
public Organization ToOrganization(Organization existingOrganization)
|
||||
|
@ -69,14 +69,4 @@ public class OrganizationViewModel
|
||||
public int ServiceAccountsCount { get; set; }
|
||||
public int OccupiedSmSeatsCount { get; set; }
|
||||
public bool UseSecretsManager => Organization.UseSecretsManager;
|
||||
|
||||
public string GetCollectionManagementSetting(bool collectionManagementSetting)
|
||||
{
|
||||
if (!Organization.FlexibleCollections)
|
||||
{
|
||||
return "N/A";
|
||||
}
|
||||
|
||||
return collectionManagementSetting ? "On" : "Off";
|
||||
}
|
||||
}
|
||||
|
@ -50,14 +50,11 @@
|
||||
<dt class="col-sm-4 col-lg-3">Collections</dt>
|
||||
<dd class="col-sm-8 col-lg-9">@Model.CollectionCount</dd>
|
||||
|
||||
<dt class="col-sm-4 col-lg-3">Collection management enhancements</dt>
|
||||
<dd class="col-sm-8 col-lg-9">@(Model.Organization.FlexibleCollections ? "On" : "Off")</dd>
|
||||
|
||||
<dt class="col-sm-4 col-lg-3">Administrators manage all collections</dt>
|
||||
<dd class="col-sm-8 col-lg-9">@(Model.GetCollectionManagementSetting(Model.Organization.AllowAdminAccessToAllCollectionItems))</dd>
|
||||
<dd class="col-sm-8 col-lg-9">@(Model.Organization.AllowAdminAccessToAllCollectionItems ? "On" : "Off")</dd>
|
||||
|
||||
<dt class="col-sm-4 col-lg-3">Limit collection creation to administrators</dt>
|
||||
<dd class="col-sm-8 col-lg-9">@(Model.GetCollectionManagementSetting(Model.Organization.LimitCollectionCreationDeletion))</dd>
|
||||
<dd class="col-sm-8 col-lg-9">@(Model.Organization.LimitCollectionCreationDeletion ? "On" : "Off")</dd>
|
||||
</dl>
|
||||
|
||||
<h2>Secrets Manager</h2>
|
||||
|
@ -2,6 +2,7 @@
|
||||
@using Bit.Admin.Utilities
|
||||
@using Bit.Core.Billing.Enums
|
||||
@using Bit.Core.Enums
|
||||
@using Bit.Core.Utilities
|
||||
@model OrganizationEditModel
|
||||
|
||||
<script>
|
||||
@ -53,55 +54,38 @@
|
||||
})();
|
||||
|
||||
function togglePlanFeatures(planType) {
|
||||
switch(planType) {
|
||||
case '@((byte)PlanType.TeamsMonthly2019)':
|
||||
case '@((byte)PlanType.TeamsAnnually2019)':
|
||||
case '@((byte)PlanType.TeamsMonthly2020)':
|
||||
case '@((byte)PlanType.TeamsAnnually2020)':
|
||||
case '@((byte)PlanType.TeamsMonthly2023)':
|
||||
case '@((byte)PlanType.TeamsAnnually2023)':
|
||||
case '@((byte)PlanType.TeamsMonthly)':
|
||||
case '@((byte)PlanType.TeamsAnnually)':
|
||||
case '@((byte)PlanType.TeamsStarter2023)':
|
||||
case '@((byte)PlanType.TeamsStarter)':
|
||||
document.getElementById('@(nameof(Model.UsePolicies))').checked = false;
|
||||
document.getElementById('@(nameof(Model.UseSso))').checked = false;
|
||||
document.getElementById('@(nameof(Model.UseGroups))').checked = true;
|
||||
document.getElementById('@(nameof(Model.UseDirectory))').checked = true;
|
||||
document.getElementById('@(nameof(Model.UseEvents))').checked = true;
|
||||
document.getElementById('@(nameof(Model.UsersGetPremium))').checked = true;
|
||||
document.getElementById('@(nameof(Model.UseCustomPermissions))').checked = false;
|
||||
document.getElementById('@(nameof(Model.UseTotp))').checked = true;
|
||||
document.getElementById('@(nameof(Model.Use2fa))').checked = true;
|
||||
document.getElementById('@(nameof(Model.UseApi))').checked = true;
|
||||
document.getElementById('@(nameof(Model.SelfHost))').checked = false;
|
||||
document.getElementById('@(nameof(Model.UseResetPassword))').checked = false;
|
||||
document.getElementById('@(nameof(Model.UseScim))').checked = false;
|
||||
break;
|
||||
const plan = getPlan(planType);
|
||||
|
||||
case '@((byte)PlanType.EnterpriseMonthly2019)':
|
||||
case '@((byte)PlanType.EnterpriseAnnually2019)':
|
||||
case '@((byte)PlanType.EnterpriseMonthly2020)':
|
||||
case '@((byte)PlanType.EnterpriseAnnually2020)':
|
||||
case '@((byte)PlanType.EnterpriseMonthly2023)':
|
||||
case '@((byte)PlanType.EnterpriseAnnually2023)':
|
||||
case '@((byte)PlanType.EnterpriseMonthly)':
|
||||
case '@((byte)PlanType.EnterpriseAnnually)':
|
||||
document.getElementById('@(nameof(Model.UsePolicies))').checked = true;
|
||||
document.getElementById('@(nameof(Model.UseSso))').checked = true;
|
||||
document.getElementById('@(nameof(Model.UseGroups))').checked = true;
|
||||
document.getElementById('@(nameof(Model.UseDirectory))').checked = true;
|
||||
document.getElementById('@(nameof(Model.UseEvents))').checked = true;
|
||||
document.getElementById('@(nameof(Model.UsersGetPremium))').checked = true;
|
||||
document.getElementById('@(nameof(Model.UseCustomPermissions))').checked = true;
|
||||
document.getElementById('@(nameof(Model.UseTotp))').checked = true;
|
||||
document.getElementById('@(nameof(Model.Use2fa))').checked = true;
|
||||
document.getElementById('@(nameof(Model.UseApi))').checked = true;
|
||||
document.getElementById('@(nameof(Model.SelfHost))').checked = true;
|
||||
document.getElementById('@(nameof(Model.UseResetPassword))').checked = true;
|
||||
document.getElementById('@(nameof(Model.UseScim))').checked = true;
|
||||
break;
|
||||
if (!plan) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(plan);
|
||||
|
||||
document.getElementById('@(nameof(Model.SelfHost))').checked = plan.hasSelfHost;
|
||||
|
||||
document.getElementById('@(nameof(Model.Use2fa))').checked = plan.has2fa;
|
||||
document.getElementById('@(nameof(Model.UseApi))').checked = plan.hasApi;
|
||||
document.getElementById('@(nameof(Model.UseGroups))').checked = plan.hasGroups;
|
||||
document.getElementById('@(nameof(Model.UsePolicies))').checked = plan.hasPolicies;
|
||||
document.getElementById('@(nameof(Model.UseSso))').checked = plan.hasSso;
|
||||
document.getElementById('@(nameof(Model.UseScim))').checked = plan.hasScim;
|
||||
document.getElementById('@(nameof(Model.UseDirectory))').checked = plan.hasDirectory;
|
||||
document.getElementById('@(nameof(Model.UseEvents))').checked = plan.hasEvents;
|
||||
document.getElementById('@(nameof(Model.UseResetPassword))').checked = plan.hasResetPassword;
|
||||
document.getElementById('@(nameof(Model.UseCustomPermissions))').checked = plan.hasCustomPermissions;
|
||||
// use key connector is intentionally omitted
|
||||
|
||||
document.getElementById('@(nameof(Model.UseTotp))').checked = plan.hasTotp;
|
||||
document.getElementById('@(nameof(Model.UsersGetPremium))').checked = plan.usersGetPremium;
|
||||
|
||||
document.getElementById('@(nameof(Model.MaxStorageGb))').value =
|
||||
document.getElementById('@(nameof(Model.MaxStorageGb))').value ||
|
||||
plan.passwordManager.baseStorageGb ||
|
||||
1;
|
||||
document.getElementById('@(nameof(Model.Seats))').value = document.getElementById('@(nameof(Model.Seats))').value ||
|
||||
plan.passwordManager.baseSeats ||
|
||||
1;
|
||||
}
|
||||
|
||||
function unlinkProvider(id) {
|
||||
@ -134,7 +118,7 @@
|
||||
document.getElementById('@(nameof(Model.SmSeats))').value = Math.max(@Model.OccupiedSmSeatsCount, 1);
|
||||
|
||||
// Service accounts
|
||||
const baseServiceAccounts = getPlan(planType)?.baseServiceAccount ?? 0;
|
||||
const baseServiceAccounts = getPlan(planType)?.secretsManager?.baseServiceAccount ?? 0;
|
||||
if (planType !== '@((byte)PlanType.Free)' && @Model.ServiceAccountsCount > baseServiceAccounts) {
|
||||
document.getElementById('@(nameof(Model.SmServiceAccounts))').value = @Model.ServiceAccountsCount;
|
||||
} else {
|
||||
|
@ -3,6 +3,7 @@ using Bit.Admin.Models;
|
||||
using Bit.Admin.Services;
|
||||
using Bit.Admin.Utilities;
|
||||
using Bit.Core;
|
||||
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Repositories;
|
||||
@ -25,9 +26,7 @@ public class UsersController : Controller
|
||||
private readonly IAccessControlService _accessControlService;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
private readonly IFeatureService _featureService;
|
||||
|
||||
private bool UseFlexibleCollections =>
|
||||
_featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections);
|
||||
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
|
||||
|
||||
public UsersController(
|
||||
IUserRepository userRepository,
|
||||
@ -36,7 +35,8 @@ public class UsersController : Controller
|
||||
GlobalSettings globalSettings,
|
||||
IAccessControlService accessControlService,
|
||||
ICurrentContext currentContext,
|
||||
IFeatureService featureService)
|
||||
IFeatureService featureService,
|
||||
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
_cipherRepository = cipherRepository;
|
||||
@ -45,6 +45,7 @@ public class UsersController : Controller
|
||||
_accessControlService = accessControlService;
|
||||
_currentContext = currentContext;
|
||||
_featureService = featureService;
|
||||
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
|
||||
}
|
||||
|
||||
[RequirePermission(Permission.User_List_View)]
|
||||
@ -62,6 +63,12 @@ public class UsersController : Controller
|
||||
|
||||
var skip = (page - 1) * count;
|
||||
var users = await _userRepository.SearchAsync(email, skip, count);
|
||||
|
||||
if (_featureService.IsEnabled(FeatureFlagKeys.MembersTwoFAQueryOptimization))
|
||||
{
|
||||
TempData["UsersTwoFactorIsEnabled"] = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(users.Select(u => u.Id));
|
||||
}
|
||||
|
||||
return View(new UsersModel
|
||||
{
|
||||
Items = users as List<User>,
|
||||
@ -80,7 +87,7 @@ public class UsersController : Controller
|
||||
return RedirectToAction("Index");
|
||||
}
|
||||
|
||||
var ciphers = await _cipherRepository.GetManyByUserIdAsync(id, useFlexibleCollections: UseFlexibleCollections);
|
||||
var ciphers = await _cipherRepository.GetManyByUserIdAsync(id);
|
||||
return View(new UserViewModel(user, ciphers));
|
||||
}
|
||||
|
||||
@ -93,7 +100,7 @@ public class UsersController : Controller
|
||||
return RedirectToAction("Index");
|
||||
}
|
||||
|
||||
var ciphers = await _cipherRepository.GetManyByUserIdAsync(id, useFlexibleCollections: UseFlexibleCollections);
|
||||
var ciphers = await _cipherRepository.GetManyByUserIdAsync(id);
|
||||
var billingInfo = await _paymentService.GetBillingAsync(user);
|
||||
var billingHistoryInfo = await _paymentService.GetBillingHistoryAsync(user);
|
||||
return View(new UserEditModel(user, ciphers, billingInfo, billingHistoryInfo, _globalSettings));
|
||||
|
@ -1,4 +1,4 @@
|
||||
@import "webfonts.css";
|
||||
@import "webfonts.scss";
|
||||
|
||||
$primary: #175DDC;
|
||||
$primary-accent: #1252A3;
|
||||
@ -17,7 +17,7 @@ $h4-font-size: 1rem;
|
||||
$h5-font-size: 1rem;
|
||||
$h6-font-size: 1rem;
|
||||
|
||||
@import "../node_modules/bootstrap/scss/bootstrap.scss";
|
||||
@import "bootstrap/scss/bootstrap.scss";
|
||||
|
||||
h1 {
|
||||
border-bottom: 1px solid $border-color;
|
||||
|
@ -27,17 +27,7 @@
|
||||
<meta name="robots" content="noindex,nofollow" />
|
||||
<title>@ViewData["Title"] - Bitwarden Admin Portal</title>
|
||||
|
||||
<link rel="stylesheet" href="~/css/webfonts.css" />
|
||||
<environment include="Development">
|
||||
<link rel="stylesheet" href="~/lib/font-awesome/css/font-awesome.css" />
|
||||
<link rel="stylesheet" href="~/css/site.css" />
|
||||
<link rel="stylesheet" href="~/lib/toastr/toastr.css" />
|
||||
</environment>
|
||||
<environment exclude="Development">
|
||||
<link rel="stylesheet" href="~/lib/font-awesome/css/font-awesome.min.css" asp-append-version="true" />
|
||||
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
|
||||
<link rel="stylesheet" href="~/lib/toastr/toastr.min.css" />
|
||||
</environment>
|
||||
<link rel="stylesheet" href="~/assets/site.css" asp-append-version="true" />
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-md navbar-dark bg-dark mb-4">
|
||||
@ -153,18 +143,7 @@
|
||||
© @DateTime.Now.Year, Bitwarden Inc.
|
||||
</footer>
|
||||
|
||||
<environment include="Development">
|
||||
<script src="~/lib/jquery/jquery.js"></script>
|
||||
<script src="~/lib/popper/popper.js"></script>
|
||||
<script src="~/lib/bootstrap/js/bootstrap.js"></script>
|
||||
<script src="~/lib/toastr/toastr.min.js"></script>
|
||||
</environment>
|
||||
<environment exclude="Development">
|
||||
<script src="~/lib/jquery/jquery.min.js" asp-append-version="true"></script>
|
||||
<script src="~/lib/popper/popper.min.js" asp-append-version="true"></script>
|
||||
<script src="~/lib/bootstrap/js/bootstrap.min.js" asp-append-version="true"></script>
|
||||
<script src="~/lib/toastr/toastr.min.js" asp-append-version="true"></script>
|
||||
</environment>
|
||||
<script src="~/assets/site.js" asp-append-version="true"></script>
|
||||
|
||||
@if (TempData["Error"] != null)
|
||||
{
|
||||
|
@ -1,5 +1,6 @@
|
||||
@model UsersModel
|
||||
@inject Bit.Core.Services.IUserService userService
|
||||
@inject Bit.Core.Services.IFeatureService featureService
|
||||
@{
|
||||
ViewData["Title"] = "Users";
|
||||
}
|
||||
@ -69,13 +70,28 @@
|
||||
{
|
||||
<i class="fa fa-times-circle-o fa-lg fa-fw text-muted" title="Email Not Verified"></i>
|
||||
}
|
||||
@if(await userService.TwoFactorIsEnabledAsync(user))
|
||||
@if (featureService.IsEnabled(Bit.Core.FeatureFlagKeys.MembersTwoFAQueryOptimization))
|
||||
{
|
||||
<i class="fa fa-lock fa-lg fa-fw" title="2FA Enabled"></i>
|
||||
var usersTwoFactorIsEnabled = TempData["UsersTwoFactorIsEnabled"] as IEnumerable<(Guid userId, bool twoFactorIsEnabled)>;
|
||||
@if(usersTwoFactorIsEnabled.FirstOrDefault(tuple => tuple.userId == user.Id).twoFactorIsEnabled)
|
||||
{
|
||||
<i class="fa fa-lock fa-lg fa-fw" title="2FA Enabled"></i>
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="fa fa-unlock fa-lg fa-fw text-muted" title="2FA Not Enabled"></i>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="fa fa-unlock fa-lg fa-fw text-muted" title="2FA Not Enabled"></i>
|
||||
@if(await userService.TwoFactorIsEnabledAsync(user))
|
||||
{
|
||||
<i class="fa fa-lock fa-lg fa-fw" title="2FA Enabled"></i>
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="fa fa-unlock fa-lg fa-fw text-muted" title="2FA Not Enabled"></i>
|
||||
}
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
|
@ -29,15 +29,15 @@
|
||||
<dd class="col-sm-8 col-lg-9">@Model.User.RevisionDate.ToString()</dd>
|
||||
|
||||
<dt class="col-sm-4 col-lg-3">Last Email Address Change</dt>
|
||||
<dd class="col-sm-8 col-lg-9">@(Model.User.LastEmailChangeDate.ToString() ?? "-")</dd>
|
||||
<dd class="col-sm-8 col-lg-9">@(Model.User.LastEmailChangeDate?.ToString() ?? "-")</dd>
|
||||
|
||||
<dt class="col-sm-4 col-lg-3">Last KDF Change</dt>
|
||||
<dd class="col-sm-8 col-lg-9">@(Model.User.LastKdfChangeDate.ToString() ?? "-")</dd>
|
||||
<dd class="col-sm-8 col-lg-9">@(Model.User.LastKdfChangeDate?.ToString() ?? "-")</dd>
|
||||
|
||||
<dt class="col-sm-4 col-lg-3">Last Key Rotation</dt>
|
||||
<dd class="col-sm-8 col-lg-9">@(Model.User.LastKeyRotationDate.ToString() ?? "-")</dd>
|
||||
<dd class="col-sm-8 col-lg-9">@(Model.User.LastKeyRotationDate?.ToString() ?? "-")</dd>
|
||||
|
||||
<dt class="col-sm-4 col-lg-3">Last Password Change</dt>
|
||||
<dd class="col-sm-8 col-lg-9">@(Model.User.LastPasswordChangeDate.ToString() ?? "-")</dd>
|
||||
<dd class="col-sm-8 col-lg-9">@(Model.User.LastPasswordChangeDate?.ToString() ?? "-")</dd>
|
||||
|
||||
</dl>
|
||||
|
@ -1,79 +0,0 @@
|
||||
/// <binding BeforeBuild='build' Clean='clean' ProjectOpened='build' />
|
||||
|
||||
const gulp = require('gulp');
|
||||
const merge = require('merge-stream');
|
||||
const sass = require('gulp-sass')(require("sass"));
|
||||
const del = require('del');
|
||||
|
||||
const paths = {};
|
||||
paths.webroot = './wwwroot/';
|
||||
paths.npmDir = './node_modules/';
|
||||
paths.sassDir = './Sass/';
|
||||
paths.libDir = paths.webroot + 'lib/';
|
||||
paths.cssDir = paths.webroot + 'css/';
|
||||
paths.jsDir = paths.webroot + 'js/';
|
||||
|
||||
paths.sass = paths.sassDir + '**/*.scss';
|
||||
paths.minCss = paths.cssDir + '**/*.min.css';
|
||||
paths.js = paths.jsDir + '**/*.js';
|
||||
paths.minJs = paths.jsDir + '**/*.min.js';
|
||||
paths.libJs = paths.libDir + '**/*.js';
|
||||
paths.libMinJs = paths.libDir + '**/*.min.js';
|
||||
|
||||
function clean() {
|
||||
return del([paths.minJs, paths.cssDir, paths.libDir]);
|
||||
}
|
||||
|
||||
function lib() {
|
||||
const libs = [
|
||||
{
|
||||
src: paths.npmDir + 'bootstrap/dist/js/*',
|
||||
dest: paths.libDir + 'bootstrap/js'
|
||||
},
|
||||
{
|
||||
src: paths.npmDir + 'popper.js/dist/umd/*',
|
||||
dest: paths.libDir + 'popper'
|
||||
},
|
||||
{
|
||||
src: paths.npmDir + 'font-awesome/css/*',
|
||||
dest: paths.libDir + 'font-awesome/css'
|
||||
},
|
||||
{
|
||||
src: paths.npmDir + 'font-awesome/fonts/*',
|
||||
dest: paths.libDir + 'font-awesome/fonts'
|
||||
},
|
||||
{
|
||||
src: paths.npmDir + 'jquery/dist/jquery.*',
|
||||
dest: paths.libDir + 'jquery'
|
||||
},
|
||||
{
|
||||
src: paths.npmDir + 'toastr/build/*',
|
||||
dest: paths.libDir + 'toastr'
|
||||
},
|
||||
{
|
||||
src: paths.sassDir + 'webfonts/*',
|
||||
dest: paths.cssDir + 'webfonts'
|
||||
}
|
||||
];
|
||||
|
||||
const tasks = libs.map((lib) => {
|
||||
return gulp.src(lib.src).pipe(gulp.dest(lib.dest));
|
||||
});
|
||||
return merge(tasks);
|
||||
}
|
||||
|
||||
function runSass() {
|
||||
return gulp.src(paths.sass)
|
||||
.pipe(sass({ outputStyle: 'compressed' }).on('error', sass.logError))
|
||||
.pipe(gulp.dest(paths.cssDir));
|
||||
}
|
||||
|
||||
function sassWatch() {
|
||||
gulp.watch(paths.sass, runSass);
|
||||
}
|
||||
|
||||
exports.build = gulp.series(clean, gulp.parallel([lib, runSass]));
|
||||
exports['sass:watch'] = sassWatch;
|
||||
exports.sass = runSass;
|
||||
exports.lib = lib;
|
||||
exports.clean = clean;
|
6715
src/Admin/package-lock.json
generated
6715
src/Admin/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -5,18 +5,22 @@
|
||||
"repository": "https://github.com/bitwarden/server",
|
||||
"license": "GPL-3.0",
|
||||
"scripts": {
|
||||
"build": "gulp build"
|
||||
"build": "webpack"
|
||||
},
|
||||
"dependencies": {
|
||||
"bootstrap": "4.6.2",
|
||||
"font-awesome": "4.7.0",
|
||||
"jquery": "3.7.1",
|
||||
"popper.js": "1.16.1",
|
||||
"toastr": "2.1.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"bootstrap": "4.6.2",
|
||||
"del": "6.1.1",
|
||||
"font-awesome": "4.7.0",
|
||||
"gulp": "4.0.2",
|
||||
"gulp-sass": "5.1.0",
|
||||
"jquery": "3.7.1",
|
||||
"merge-stream": "2.0.0",
|
||||
"popper.js": "1.16.1",
|
||||
"css-loader": "7.1.2",
|
||||
"expose-loader": "5.0.0",
|
||||
"mini-css-extract-plugin": "2.9.0",
|
||||
"sass": "1.75.0",
|
||||
"toastr": "2.1.4"
|
||||
"sass-loader": "16.0.0",
|
||||
"webpack": "5.93.0",
|
||||
"webpack-cli": "5.1.4"
|
||||
}
|
||||
}
|
||||
|
66
src/Admin/webpack.config.js
Normal file
66
src/Admin/webpack.config.js
Normal file
@ -0,0 +1,66 @@
|
||||
const path = require("path");
|
||||
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
|
||||
|
||||
const paths = {
|
||||
assets: "./wwwroot/assets/",
|
||||
sassDir: "./Sass/",
|
||||
};
|
||||
|
||||
/** @type {import("webpack").Configuration} */
|
||||
module.exports = {
|
||||
mode: "production",
|
||||
devtool: "source-map",
|
||||
entry: {
|
||||
site: [
|
||||
path.resolve(__dirname, paths.sassDir, "site.scss"),
|
||||
|
||||
"popper.js",
|
||||
"bootstrap",
|
||||
"jquery",
|
||||
"font-awesome/css/font-awesome.css",
|
||||
"toastr",
|
||||
"toastr/build/toastr.css",
|
||||
],
|
||||
},
|
||||
output: {
|
||||
clean: true,
|
||||
path: path.resolve(__dirname, paths.assets),
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.(sa|sc|c)ss$/,
|
||||
use: [MiniCssExtractPlugin.loader, "css-loader", "sass-loader"],
|
||||
},
|
||||
{
|
||||
test: /.(ttf|otf|eot|svg|woff(2)?)(\?[a-z0-9]+)?$/,
|
||||
exclude: /loading(|-white).svg/,
|
||||
generator: {
|
||||
filename: "fonts/[name].[contenthash][ext]",
|
||||
},
|
||||
type: "asset/resource",
|
||||
},
|
||||
|
||||
// Expose jquery and toastr globally so they can be used directly in asp.net
|
||||
{
|
||||
test: require.resolve("jquery"),
|
||||
loader: "expose-loader",
|
||||
options: {
|
||||
exposes: ["$", "jQuery"],
|
||||
},
|
||||
},
|
||||
{
|
||||
test: require.resolve("toastr"),
|
||||
loader: "expose-loader",
|
||||
options: {
|
||||
exposes: ["toastr"],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
plugins: [
|
||||
new MiniCssExtractPlugin({
|
||||
filename: "[name].css",
|
||||
}),
|
||||
],
|
||||
};
|
@ -4,7 +4,6 @@ using Bit.Api.Models.Response;
|
||||
using Bit.Api.Utilities;
|
||||
using Bit.Api.Vault.AuthorizationHandlers.Collections;
|
||||
using Bit.Api.Vault.AuthorizationHandlers.Groups;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
@ -126,8 +125,8 @@ public class GroupsController : Controller
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
// Flexible Collections - check the user has permission to grant access to the collections for the new group
|
||||
if (_featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1) && model.Collections?.Any() == true)
|
||||
// Check the user has permission to grant access to the collections for the new group
|
||||
if (model.Collections?.Any() == true)
|
||||
{
|
||||
var collections = await _collectionRepository.GetManyByManyIdsAsync(model.Collections.Select(a => a.Id));
|
||||
var authorized =
|
||||
@ -135,7 +134,7 @@ public class GroupsController : Controller
|
||||
.Succeeded;
|
||||
if (!authorized)
|
||||
{
|
||||
throw new NotFoundException("You are not authorized to grant access to these collections.");
|
||||
throw new NotFoundException();
|
||||
}
|
||||
}
|
||||
|
||||
@ -150,38 +149,20 @@ public class GroupsController : Controller
|
||||
[HttpPost("{id}")]
|
||||
public async Task<GroupResponseModel> Put(Guid orgId, Guid id, [FromBody] GroupRequestModel model)
|
||||
{
|
||||
if (_featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1))
|
||||
{
|
||||
// Use new Flexible Collections v1 logic
|
||||
return await Put_vNext(orgId, id, model);
|
||||
}
|
||||
|
||||
// Pre-Flexible Collections v1 logic follows
|
||||
var group = await _groupRepository.GetByIdAsync(id);
|
||||
if (group == null || !await _currentContext.ManageGroups(group.OrganizationId))
|
||||
if (!await _currentContext.ManageGroups(orgId))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var organization = await _organizationRepository.GetByIdAsync(orgId);
|
||||
|
||||
await _updateGroupCommand.UpdateGroupAsync(model.ToGroup(group), organization,
|
||||
model.Collections.Select(c => c.ToSelectionReadOnly()).ToList(), model.Users);
|
||||
return new GroupResponseModel(group);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Put logic for Flexible Collections v1
|
||||
/// </summary>
|
||||
private async Task<GroupResponseModel> Put_vNext(Guid orgId, Guid id, [FromBody] GroupRequestModel model)
|
||||
{
|
||||
var (group, currentAccess) = await _groupRepository.GetByIdWithCollectionsAsync(id);
|
||||
if (group == null || !await _currentContext.ManageGroups(group.OrganizationId))
|
||||
if (group == null || group.OrganizationId != orgId)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
// Check whether the user is permitted to add themselves to the group
|
||||
// Authorization check:
|
||||
// If admins are not allowed access to all collections, you cannot add yourself to a group.
|
||||
// No error is thrown for this, we just don't update groups.
|
||||
var orgAbility = await _applicationCacheService.GetOrganizationAbilityAsync(orgId);
|
||||
if (!orgAbility.AllowAdminAccessToAllCollectionItems)
|
||||
{
|
||||
@ -195,9 +176,23 @@ public class GroupsController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
// Authorization check:
|
||||
// You must have authorization to ModifyUserAccess for all collections being saved
|
||||
var postedCollections = await _collectionRepository
|
||||
.GetManyByManyIdsAsync(model.Collections.Select(c => c.Id));
|
||||
foreach (var collection in postedCollections)
|
||||
{
|
||||
if (!(await _authorizationService.AuthorizeAsync(User, collection,
|
||||
BulkCollectionOperations.ModifyGroupAccess))
|
||||
.Succeeded)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
}
|
||||
|
||||
// The client only sends collections that the saving user has permissions to edit.
|
||||
// On the server side, we need to (1) confirm this and (2) concat these with the collections that the user
|
||||
// can't edit before saving to the database.
|
||||
// We need to combine these with collections that the user doesn't have permissions for, so that we don't
|
||||
// accidentally overwrite those
|
||||
var currentCollections = await _collectionRepository
|
||||
.GetManyByManyIdsAsync(currentAccess.Select(cas => cas.Id));
|
||||
|
||||
@ -211,11 +206,6 @@ public class GroupsController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
if (model.Collections.Any(c => readonlyCollectionIds.Contains(c.Id)))
|
||||
{
|
||||
throw new BadRequestException("You must have Can Manage permissions to edit a collection's membership");
|
||||
}
|
||||
|
||||
var editedCollectionAccess = model.Collections
|
||||
.Select(c => c.ToSelectionReadOnly());
|
||||
var readonlyCollectionAccess = currentAccess
|
||||
|
@ -1,14 +1,12 @@
|
||||
using Bit.Api.AdminConsole.Models.Request;
|
||||
using Bit.Api.AdminConsole.Models.Response;
|
||||
using Bit.Api.Models.Response;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.OrganizationAuth.Interfaces;
|
||||
using Bit.Core.Auth.Models.Api.Request.AuthRequest;
|
||||
using Bit.Core.Auth.Services;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
@ -75,7 +73,6 @@ public class OrganizationAuthRequestsController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
[RequireFeature(FeatureFlagKeys.BulkDeviceApproval)]
|
||||
[HttpPost("")]
|
||||
public async Task UpdateManyAuthRequests(Guid orgId, [FromBody] IEnumerable<OrganizationAuthRequestUpdateManyRequestModel> model)
|
||||
{
|
||||
|
@ -12,6 +12,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Repositories;
|
||||
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
@ -22,7 +23,6 @@ using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
|
||||
using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
@ -49,6 +49,7 @@ public class OrganizationUsersController : Controller
|
||||
private readonly IApplicationCacheService _applicationCacheService;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly ISsoConfigRepository _ssoConfigRepository;
|
||||
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
|
||||
|
||||
public OrganizationUsersController(
|
||||
IOrganizationRepository organizationRepository,
|
||||
@ -67,7 +68,8 @@ public class OrganizationUsersController : Controller
|
||||
IAuthorizationService authorizationService,
|
||||
IApplicationCacheService applicationCacheService,
|
||||
IFeatureService featureService,
|
||||
ISsoConfigRepository ssoConfigRepository)
|
||||
ISsoConfigRepository ssoConfigRepository,
|
||||
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery)
|
||||
{
|
||||
_organizationRepository = organizationRepository;
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
@ -86,6 +88,7 @@ public class OrganizationUsersController : Controller
|
||||
_applicationCacheService = applicationCacheService;
|
||||
_featureService = featureService;
|
||||
_ssoConfigRepository = ssoConfigRepository;
|
||||
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
@ -127,8 +130,12 @@ public class OrganizationUsersController : Controller
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var organizationUsers = await _organizationUserRepository
|
||||
.GetManyDetailsByOrganizationAsync(orgId, includeGroups, includeCollections);
|
||||
if (_featureService.IsEnabled(FeatureFlagKeys.MembersTwoFAQueryOptimization))
|
||||
{
|
||||
return await Get_vNext(orgId, includeGroups, includeCollections);
|
||||
}
|
||||
|
||||
var organizationUsers = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(orgId, includeGroups, includeCollections);
|
||||
var responseTasks = organizationUsers
|
||||
.Select(async o =>
|
||||
{
|
||||
@ -201,7 +208,6 @@ public class OrganizationUsersController : Controller
|
||||
return new OrganizationUserResetPasswordDetailsResponseModel(new OrganizationUserResetPasswordDetails(organizationUser, user, org));
|
||||
}
|
||||
|
||||
[RequireFeature(FeatureFlagKeys.BulkDeviceApproval)]
|
||||
[HttpPost("account-recovery-details")]
|
||||
public async Task<ListResponseModel<OrganizationUserResetPasswordDetailsResponseModel>> GetAccountRecoveryDetails(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model)
|
||||
{
|
||||
@ -223,8 +229,8 @@ public class OrganizationUsersController : Controller
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
// Flexible Collections - check the user has permission to grant access to the collections for the new user
|
||||
if (_featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1) && model.Collections?.Any() == true)
|
||||
// Check the user has permission to grant access to the collections for the new user
|
||||
if (model.Collections?.Any() == true)
|
||||
{
|
||||
var collections = await _collectionRepository.GetManyByManyIdsAsync(model.Collections.Select(a => a.Id));
|
||||
var authorized =
|
||||
@ -232,7 +238,7 @@ public class OrganizationUsersController : Controller
|
||||
.Succeeded;
|
||||
if (!authorized)
|
||||
{
|
||||
throw new NotFoundException("You are not authorized to grant access to these collections.");
|
||||
throw new NotFoundException();
|
||||
}
|
||||
}
|
||||
|
||||
@ -334,7 +340,9 @@ public class OrganizationUsersController : Controller
|
||||
}
|
||||
|
||||
var userId = _userService.GetProperUserId(User);
|
||||
var results = await _organizationService.ConfirmUsersAsync(orgGuidId, model.ToDictionary(), userId.Value,
|
||||
var results = _featureService.IsEnabled(FeatureFlagKeys.MembersTwoFAQueryOptimization)
|
||||
? await _organizationService.ConfirmUsersAsync_vNext(orgGuidId, model.ToDictionary(), userId.Value)
|
||||
: await _organizationService.ConfirmUsersAsync(orgGuidId, model.ToDictionary(), userId.Value,
|
||||
_userService);
|
||||
|
||||
return new ListResponseModel<OrganizationUserBulkResponseModel>(results.Select(r =>
|
||||
@ -358,35 +366,6 @@ public class OrganizationUsersController : Controller
|
||||
[HttpPut("{id}")]
|
||||
[HttpPost("{id}")]
|
||||
public async Task Put(Guid orgId, Guid id, [FromBody] OrganizationUserUpdateRequestModel model)
|
||||
{
|
||||
if (_featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1))
|
||||
{
|
||||
// Use new Flexible Collections v1 logic
|
||||
await Put_vNext(orgId, id, model);
|
||||
return;
|
||||
}
|
||||
|
||||
// Pre-Flexible Collections v1 code follows
|
||||
if (!await _currentContext.ManageUsers(orgId))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var organizationUser = await _organizationUserRepository.GetByIdAsync(id);
|
||||
if (organizationUser == null || organizationUser.OrganizationId != orgId)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var userId = _userService.GetProperUserId(User);
|
||||
await _updateOrganizationUserCommand.UpdateUserAsync(model.ToOrganizationUser(organizationUser), userId.Value,
|
||||
model.Collections.Select(c => c.ToSelectionReadOnly()).ToList(), model.Groups);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Put logic for Flexible Collections v1
|
||||
/// </summary>
|
||||
private async Task Put_vNext(Guid orgId, Guid id, [FromBody] OrganizationUserUpdateRequestModel model)
|
||||
{
|
||||
if (!await _currentContext.ManageUsers(orgId))
|
||||
{
|
||||
@ -402,13 +381,15 @@ public class OrganizationUsersController : Controller
|
||||
var userId = _userService.GetProperUserId(User).Value;
|
||||
var editingSelf = userId == organizationUser.UserId;
|
||||
|
||||
// Authorization check:
|
||||
// If admins are not allowed access to all collections, you cannot add yourself to a group.
|
||||
// In this case we just don't update groups.
|
||||
// No error is thrown for this, we just don't update groups.
|
||||
var organizationAbility = await _applicationCacheService.GetOrganizationAbilityAsync(orgId);
|
||||
var groupsToSave = editingSelf && !organizationAbility.AllowAdminAccessToAllCollectionItems
|
||||
? null
|
||||
: model.Groups;
|
||||
|
||||
// Authorization check:
|
||||
// If admins are not allowed access to all collections, you cannot add yourself to collections.
|
||||
// This is not caught by the requirement below that you can ModifyUserAccess and must be checked separately
|
||||
var currentAccessIds = currentAccess.Select(c => c.Id).ToHashSet();
|
||||
@ -419,9 +400,23 @@ public class OrganizationUsersController : Controller
|
||||
throw new BadRequestException("You cannot add yourself to a collection.");
|
||||
}
|
||||
|
||||
// Authorization check:
|
||||
// You must have authorization to ModifyUserAccess for all collections being saved
|
||||
var postedCollections = await _collectionRepository
|
||||
.GetManyByManyIdsAsync(model.Collections.Select(c => c.Id));
|
||||
foreach (var collection in postedCollections)
|
||||
{
|
||||
if (!(await _authorizationService.AuthorizeAsync(User, collection,
|
||||
BulkCollectionOperations.ModifyUserAccess))
|
||||
.Succeeded)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
}
|
||||
|
||||
// The client only sends collections that the saving user has permissions to edit.
|
||||
// On the server side, we need to (1) make sure the user has permissions for these collections, and
|
||||
// (2) concat these with the collections that the user can't edit before saving to the database.
|
||||
// We need to combine these with collections that the user doesn't have permissions for, so that we don't
|
||||
// accidentally overwrite those
|
||||
var currentCollections = await _collectionRepository
|
||||
.GetManyByManyIdsAsync(currentAccess.Select(cas => cas.Id));
|
||||
|
||||
@ -435,11 +430,6 @@ public class OrganizationUsersController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
if (model.Collections.Any(c => readonlyCollectionIds.Contains(c.Id)))
|
||||
{
|
||||
throw new BadRequestException("You must have Can Manage permissions to edit a collection's membership");
|
||||
}
|
||||
|
||||
var editedCollectionAccess = model.Collections
|
||||
.Select(c => c.ToSelectionReadOnly());
|
||||
var readonlyCollectionAccess = currentAccess
|
||||
@ -672,4 +662,32 @@ public class OrganizationUsersController : Controller
|
||||
|
||||
return type;
|
||||
}
|
||||
|
||||
private async Task<ListResponseModel<OrganizationUserUserDetailsResponseModel>> Get_vNext(Guid orgId,
|
||||
bool includeGroups = false, bool includeCollections = false)
|
||||
{
|
||||
var organizationUsers = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(orgId, includeGroups, includeCollections);
|
||||
var organizationUsersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(organizationUsers);
|
||||
var responseTasks = organizationUsers
|
||||
.Select(async o =>
|
||||
{
|
||||
var userTwoFactorEnabled = organizationUsersTwoFactorEnabled.FirstOrDefault(u => u.user.Id == o.Id).twoFactorIsEnabled;
|
||||
var orgUser = new OrganizationUserUserDetailsResponseModel(o, userTwoFactorEnabled);
|
||||
|
||||
// Downgrade Custom users with no other permissions than 'Edit/Delete Assigned Collections' to User
|
||||
orgUser.Type = GetFlexibleCollectionsUserType(orgUser.Type, orgUser.Permissions);
|
||||
|
||||
// Set 'Edit/Delete Assigned Collections' custom permissions to false
|
||||
if (orgUser.Permissions is not null)
|
||||
{
|
||||
orgUser.Permissions.EditAssignedCollections = false;
|
||||
orgUser.Permissions.DeleteAssignedCollections = false;
|
||||
}
|
||||
|
||||
return orgUser;
|
||||
});
|
||||
var responses = await Task.WhenAll(responseTasks);
|
||||
|
||||
return new ListResponseModel<OrganizationUserUserDetailsResponseModel>(responses);
|
||||
}
|
||||
}
|
||||
|
@ -539,14 +539,6 @@ public class OrganizationsController : Controller
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var v1Enabled = _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1);
|
||||
|
||||
if (!v1Enabled)
|
||||
{
|
||||
// V1 is disabled, ensure V1 setting doesn't change
|
||||
model.AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems;
|
||||
}
|
||||
|
||||
await _organizationService.UpdateAsync(model.ToOrganization(organization), eventType: EventType.Organization_CollectionManagement_Updated);
|
||||
return new OrganizationResponseModel(organization);
|
||||
}
|
||||
|
@ -1,9 +1,7 @@
|
||||
using Bit.Api.AdminConsole.Models.Request.Providers;
|
||||
using Bit.Api.AdminConsole.Models.Response.Providers;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Business;
|
||||
@ -23,23 +21,15 @@ public class ProvidersController : Controller
|
||||
private readonly IProviderService _providerService;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly ILogger<ProvidersController> _logger;
|
||||
private readonly IProviderBillingService _providerBillingService;
|
||||
|
||||
public ProvidersController(IUserService userService, IProviderRepository providerRepository,
|
||||
IProviderService providerService, ICurrentContext currentContext, GlobalSettings globalSettings,
|
||||
IFeatureService featureService, ILogger<ProvidersController> logger,
|
||||
IProviderBillingService providerBillingService)
|
||||
IProviderService providerService, ICurrentContext currentContext, GlobalSettings globalSettings)
|
||||
{
|
||||
_userService = userService;
|
||||
_providerRepository = providerRepository;
|
||||
_providerService = providerService;
|
||||
_currentContext = currentContext;
|
||||
_globalSettings = globalSettings;
|
||||
_featureService = featureService;
|
||||
_logger = logger;
|
||||
_providerBillingService = providerBillingService;
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
@ -94,12 +84,8 @@ public class ProvidersController : Controller
|
||||
|
||||
var userId = _userService.GetProperUserId(User).Value;
|
||||
|
||||
var response =
|
||||
await _providerService.CompleteSetupAsync(model.ToProvider(provider), userId, model.Token, model.Key);
|
||||
|
||||
if (_featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling))
|
||||
{
|
||||
var taxInfo = new TaxInfo
|
||||
var taxInfo = model.TaxInfo != null
|
||||
? new TaxInfo
|
||||
{
|
||||
BillingAddressCountry = model.TaxInfo.Country,
|
||||
BillingAddressPostalCode = model.TaxInfo.PostalCode,
|
||||
@ -108,20 +94,12 @@ public class ProvidersController : Controller
|
||||
BillingAddressLine2 = model.TaxInfo.Line2,
|
||||
BillingAddressCity = model.TaxInfo.City,
|
||||
BillingAddressState = model.TaxInfo.State
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await _providerBillingService.CreateCustomer(provider, taxInfo);
|
||||
|
||||
await _providerBillingService.StartSubscription(provider);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// We don't want to trap the user on the setup page, so we'll let this go through but the provider will be in an un-billable state.
|
||||
_logger.LogError("Failed to create subscription for provider with ID {ID} during setup", provider.Id);
|
||||
}
|
||||
}
|
||||
: null;
|
||||
|
||||
var response =
|
||||
await _providerService.CompleteSetupAsync(model.ToProvider(provider), userId, model.Token, model.Key,
|
||||
taxInfo);
|
||||
|
||||
return new ProviderResponseModel(response);
|
||||
}
|
||||
|
@ -14,6 +14,7 @@ public class OrganizationUserInviteRequestModel
|
||||
[StrictEmailAddressList]
|
||||
public IEnumerable<string> Emails { get; set; }
|
||||
[Required]
|
||||
[EnumDataType(typeof(OrganizationUserType))]
|
||||
public OrganizationUserType? Type { get; set; }
|
||||
public bool AccessSecretsManager { get; set; }
|
||||
public Permissions Permissions { get; set; }
|
||||
@ -83,6 +84,7 @@ public class OrganizationUserBulkConfirmRequestModel
|
||||
public class OrganizationUserUpdateRequestModel
|
||||
{
|
||||
[Required]
|
||||
[EnumDataType(typeof(OrganizationUserType))]
|
||||
public OrganizationUserType? Type { get; set; }
|
||||
public bool AccessSecretsManager { get; set; }
|
||||
public Permissions Permissions { get; set; }
|
||||
|
@ -57,7 +57,6 @@ public class OrganizationResponseModel : ResponseModel
|
||||
MaxAutoscaleSmServiceAccounts = organization.MaxAutoscaleSmServiceAccounts;
|
||||
LimitCollectionCreationDeletion = organization.LimitCollectionCreationDeletion;
|
||||
AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems;
|
||||
FlexibleCollections = organization.FlexibleCollections;
|
||||
}
|
||||
|
||||
public Guid Id { get; set; }
|
||||
@ -101,7 +100,6 @@ public class OrganizationResponseModel : ResponseModel
|
||||
public int? MaxAutoscaleSmServiceAccounts { get; set; }
|
||||
public bool LimitCollectionCreationDeletion { get; set; }
|
||||
public bool AllowAdminAccessToAllCollectionItems { get; set; }
|
||||
public bool FlexibleCollections { get; set; }
|
||||
}
|
||||
|
||||
public class OrganizationSubscriptionResponseModel : OrganizationResponseModel
|
||||
|
@ -64,7 +64,6 @@ public class ProfileOrganizationResponseModel : ResponseModel
|
||||
AccessSecretsManager = organization.AccessSecretsManager;
|
||||
LimitCollectionCreationDeletion = organization.LimitCollectionCreationDeletion;
|
||||
AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems;
|
||||
FlexibleCollections = organization.FlexibleCollections;
|
||||
|
||||
if (organization.SsoConfig != null)
|
||||
{
|
||||
@ -73,39 +72,36 @@ public class ProfileOrganizationResponseModel : ResponseModel
|
||||
KeyConnectorUrl = ssoConfigData.KeyConnectorUrl;
|
||||
}
|
||||
|
||||
if (FlexibleCollections)
|
||||
// Downgrade Custom users with no other permissions than 'Edit/Delete Assigned Collections' to User
|
||||
if (Type == OrganizationUserType.Custom && Permissions is not null)
|
||||
{
|
||||
// Downgrade Custom users with no other permissions than 'Edit/Delete Assigned Collections' to User
|
||||
if (Type == OrganizationUserType.Custom && Permissions is not null)
|
||||
{
|
||||
if ((Permissions.EditAssignedCollections || Permissions.DeleteAssignedCollections) &&
|
||||
Permissions is
|
||||
{
|
||||
AccessEventLogs: false,
|
||||
AccessImportExport: false,
|
||||
AccessReports: false,
|
||||
CreateNewCollections: false,
|
||||
EditAnyCollection: false,
|
||||
DeleteAnyCollection: false,
|
||||
ManageGroups: false,
|
||||
ManagePolicies: false,
|
||||
ManageSso: false,
|
||||
ManageUsers: false,
|
||||
ManageResetPassword: false,
|
||||
ManageScim: false
|
||||
})
|
||||
if ((Permissions.EditAssignedCollections || Permissions.DeleteAssignedCollections) &&
|
||||
Permissions is
|
||||
{
|
||||
organization.Type = OrganizationUserType.User;
|
||||
}
|
||||
}
|
||||
|
||||
// Set 'Edit/Delete Assigned Collections' custom permissions to false
|
||||
if (Permissions is not null)
|
||||
AccessEventLogs: false,
|
||||
AccessImportExport: false,
|
||||
AccessReports: false,
|
||||
CreateNewCollections: false,
|
||||
EditAnyCollection: false,
|
||||
DeleteAnyCollection: false,
|
||||
ManageGroups: false,
|
||||
ManagePolicies: false,
|
||||
ManageSso: false,
|
||||
ManageUsers: false,
|
||||
ManageResetPassword: false,
|
||||
ManageScim: false
|
||||
})
|
||||
{
|
||||
Permissions.EditAssignedCollections = false;
|
||||
Permissions.DeleteAssignedCollections = false;
|
||||
organization.Type = OrganizationUserType.User;
|
||||
}
|
||||
}
|
||||
|
||||
// Set 'Edit/Delete Assigned Collections' custom permissions to false
|
||||
if (Permissions is not null)
|
||||
{
|
||||
Permissions.EditAssignedCollections = false;
|
||||
Permissions.DeleteAssignedCollections = false;
|
||||
}
|
||||
}
|
||||
|
||||
public Guid Id { get; set; }
|
||||
@ -157,5 +153,4 @@ public class ProfileOrganizationResponseModel : ResponseModel
|
||||
public bool AccessSecretsManager { get; set; }
|
||||
public bool LimitCollectionCreationDeletion { get; set; }
|
||||
public bool AllowAdminAccessToAllCollectionItems { get; set; }
|
||||
public bool FlexibleCollections { get; set; }
|
||||
}
|
||||
|
@ -46,6 +46,5 @@ public class ProfileProviderOrganizationResponseModel : ProfileOrganizationRespo
|
||||
ProductTierType = StaticStore.GetPlan(organization.PlanType).ProductTier;
|
||||
LimitCollectionCreationDeletion = organization.LimitCollectionCreationDeletion;
|
||||
AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems;
|
||||
FlexibleCollections = organization.FlexibleCollections;
|
||||
}
|
||||
}
|
||||
|
@ -2,9 +2,12 @@
|
||||
using Bit.Api.AdminConsole.Public.Models.Request;
|
||||
using Bit.Api.AdminConsole.Public.Models.Response;
|
||||
using Bit.Api.Models.Public.Response;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
@ -24,6 +27,10 @@ public class MembersController : Controller
|
||||
private readonly IUpdateOrganizationUserCommand _updateOrganizationUserCommand;
|
||||
private readonly IUpdateOrganizationUserGroupsCommand _updateOrganizationUserGroupsCommand;
|
||||
private readonly IApplicationCacheService _applicationCacheService;
|
||||
private readonly IPaymentService _paymentService;
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
|
||||
|
||||
public MembersController(
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
@ -33,7 +40,11 @@ public class MembersController : Controller
|
||||
ICurrentContext currentContext,
|
||||
IUpdateOrganizationUserCommand updateOrganizationUserCommand,
|
||||
IUpdateOrganizationUserGroupsCommand updateOrganizationUserGroupsCommand,
|
||||
IApplicationCacheService applicationCacheService)
|
||||
IApplicationCacheService applicationCacheService,
|
||||
IPaymentService paymentService,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IFeatureService featureService,
|
||||
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery)
|
||||
{
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
_groupRepository = groupRepository;
|
||||
@ -43,6 +54,10 @@ public class MembersController : Controller
|
||||
_updateOrganizationUserCommand = updateOrganizationUserCommand;
|
||||
_updateOrganizationUserGroupsCommand = updateOrganizationUserGroupsCommand;
|
||||
_applicationCacheService = applicationCacheService;
|
||||
_paymentService = paymentService;
|
||||
_organizationRepository = organizationRepository;
|
||||
_featureService = featureService;
|
||||
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -102,11 +117,18 @@ public class MembersController : Controller
|
||||
[ProducesResponseType(typeof(ListResponseModel<MemberResponseModel>), (int)HttpStatusCode.OK)]
|
||||
public async Task<IActionResult> List()
|
||||
{
|
||||
var users = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(
|
||||
_currentContext.OrganizationId.Value);
|
||||
var organizationUserUserDetails = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(_currentContext.OrganizationId.Value);
|
||||
// TODO: Get all CollectionUser associations for the organization and marry them up here for the response.
|
||||
var memberResponsesTasks = users.Select(async u => new MemberResponseModel(u,
|
||||
await _userService.TwoFactorIsEnabledAsync(u), null));
|
||||
|
||||
if (_featureService.IsEnabled(FeatureFlagKeys.MembersTwoFAQueryOptimization))
|
||||
{
|
||||
return await List_vNext(organizationUserUserDetails);
|
||||
}
|
||||
|
||||
var memberResponsesTasks = organizationUserUserDetails.Select(async u =>
|
||||
{
|
||||
return new MemberResponseModel(u, await _userService.TwoFactorIsEnabledAsync(u), null);
|
||||
});
|
||||
var memberResponses = await Task.WhenAll(memberResponsesTasks);
|
||||
var response = new ListResponseModel<MemberResponseModel>(memberResponses);
|
||||
return new JsonResult(response);
|
||||
@ -124,8 +146,19 @@ public class MembersController : Controller
|
||||
[ProducesResponseType(typeof(ErrorResponseModel), (int)HttpStatusCode.BadRequest)]
|
||||
public async Task<IActionResult> Post([FromBody] MemberCreateRequestModel model)
|
||||
{
|
||||
var hasStandaloneSecretsManager = false;
|
||||
|
||||
var organization = await _organizationRepository.GetByIdAsync(_currentContext.OrganizationId!.Value);
|
||||
|
||||
if (organization != null)
|
||||
{
|
||||
hasStandaloneSecretsManager = await _paymentService.HasSecretsManagerStandalone(organization);
|
||||
}
|
||||
|
||||
var invite = model.ToOrganizationUserInvite();
|
||||
|
||||
invite.AccessSecretsManager = hasStandaloneSecretsManager;
|
||||
|
||||
var user = await _organizationService.InviteUserAsync(_currentContext.OrganizationId.Value, null,
|
||||
systemUser: null, invite, model.ExternalId);
|
||||
var response = new MemberResponseModel(user, invite.Collections);
|
||||
@ -235,4 +268,15 @@ public class MembersController : Controller
|
||||
await _organizationService.ResendInviteAsync(_currentContext.OrganizationId.Value, null, id);
|
||||
return new OkResult();
|
||||
}
|
||||
|
||||
private async Task<JsonResult> List_vNext(ICollection<OrganizationUserUserDetails> organizationUserUserDetails)
|
||||
{
|
||||
var orgUsersTwoFactorIsEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(organizationUserUserDetails);
|
||||
var memberResponses = organizationUserUserDetails.Select(u =>
|
||||
{
|
||||
return new MemberResponseModel(u, orgUsersTwoFactorIsEnabled.FirstOrDefault(tuple => tuple.user == u).twoFactorIsEnabled, null);
|
||||
});
|
||||
var response = new ListResponseModel<MemberResponseModel>(memberResponses);
|
||||
return new JsonResult(response);
|
||||
}
|
||||
}
|
||||
|
@ -45,10 +45,10 @@ public abstract class MemberBaseModel
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The member's type (or role) within the organization. If your organization has is using the latest collection enhancements,
|
||||
/// you will not be allowed to assign the Manager role (OrganizationUserType = 3).
|
||||
/// The member's type (or role) within the organization.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[EnumDataType(typeof(OrganizationUserType))]
|
||||
public OrganizationUserType? Type { get; set; }
|
||||
/// <summary>
|
||||
/// External identifier for reference or linking this member to another system, such as a user directory.
|
||||
|
@ -34,7 +34,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AspNetCore.HealthChecks.SqlServer" Version="8.0.0" />
|
||||
<PackageReference Include="AspNetCore.HealthChecks.Uris" Version="8.0.0" />
|
||||
<PackageReference Include="Azure.Messaging.EventGrid" Version="4.24.0" />
|
||||
<PackageReference Include="Azure.Messaging.EventGrid" Version="4.24.1" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
|
||||
</ItemGroup>
|
||||
|
||||
|
@ -17,6 +17,7 @@ using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Auth.Entities;
|
||||
using Bit.Core.Auth.Models.Api.Request.Accounts;
|
||||
using Bit.Core.Auth.Models.Data;
|
||||
using Bit.Core.Auth.UserFeatures.TdeOffboardingPassword.Interfaces;
|
||||
using Bit.Core.Auth.UserFeatures.UserKey;
|
||||
using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces;
|
||||
using Bit.Core.Billing.Models;
|
||||
@ -53,15 +54,13 @@ public class AccountsController : Controller
|
||||
private readonly IUserService _userService;
|
||||
private readonly IPolicyService _policyService;
|
||||
private readonly ISetInitialMasterPasswordCommand _setInitialMasterPasswordCommand;
|
||||
private readonly ITdeOffboardingPasswordCommand _tdeOffboardingPasswordCommand;
|
||||
private readonly IRotateUserKeyCommand _rotateUserKeyCommand;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly ISubscriberService _subscriberService;
|
||||
private readonly IReferenceEventService _referenceEventService;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
|
||||
private bool UseFlexibleCollections =>
|
||||
_featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections);
|
||||
|
||||
private readonly IRotationValidator<IEnumerable<CipherWithIdRequestModel>, IEnumerable<Cipher>> _cipherValidator;
|
||||
private readonly IRotationValidator<IEnumerable<FolderWithIdRequestModel>, IEnumerable<Folder>> _folderValidator;
|
||||
private readonly IRotationValidator<IEnumerable<SendWithIdRequestModel>, IReadOnlyList<Send>> _sendValidator;
|
||||
@ -83,6 +82,7 @@ public class AccountsController : Controller
|
||||
IUserService userService,
|
||||
IPolicyService policyService,
|
||||
ISetInitialMasterPasswordCommand setInitialMasterPasswordCommand,
|
||||
ITdeOffboardingPasswordCommand tdeOffboardingPasswordCommand,
|
||||
IRotateUserKeyCommand rotateUserKeyCommand,
|
||||
IFeatureService featureService,
|
||||
ISubscriberService subscriberService,
|
||||
@ -106,6 +106,7 @@ public class AccountsController : Controller
|
||||
_userService = userService;
|
||||
_policyService = policyService;
|
||||
_setInitialMasterPasswordCommand = setInitialMasterPasswordCommand;
|
||||
_tdeOffboardingPasswordCommand = tdeOffboardingPasswordCommand;
|
||||
_rotateUserKeyCommand = rotateUserKeyCommand;
|
||||
_featureService = featureService;
|
||||
_subscriberService = subscriberService;
|
||||
@ -877,6 +878,29 @@ public class AccountsController : Controller
|
||||
throw new BadRequestException(ModelState);
|
||||
}
|
||||
|
||||
[HttpPut("update-tde-offboarding-password")]
|
||||
public async Task PutUpdateTdePasswordAsync([FromBody] UpdateTdeOffboardingPasswordRequestModel model)
|
||||
{
|
||||
var user = await _userService.GetUserByPrincipalAsync(User);
|
||||
if (user == null)
|
||||
{
|
||||
throw new UnauthorizedAccessException();
|
||||
}
|
||||
|
||||
var result = await _tdeOffboardingPasswordCommand.UpdateTdeOffboardingPasswordAsync(user, model.NewMasterPasswordHash, model.Key, model.MasterPasswordHint);
|
||||
if (result.Succeeded)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var error in result.Errors)
|
||||
{
|
||||
ModelState.AddModelError(string.Empty, error.Description);
|
||||
}
|
||||
|
||||
throw new BadRequestException(ModelState);
|
||||
}
|
||||
|
||||
[HttpPost("request-otp")]
|
||||
public async Task PostRequestOTP()
|
||||
{
|
||||
|
@ -3,6 +3,7 @@ using Bit.Api.Auth.Models.Request.Accounts;
|
||||
using Bit.Api.Auth.Models.Response.TwoFactor;
|
||||
using Bit.Api.Models.Request;
|
||||
using Bit.Api.Models.Response;
|
||||
using Bit.Core;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.LoginFeatures.PasswordlessLogin.Interfaces;
|
||||
using Bit.Core.Auth.Models.Business.Tokenables;
|
||||
@ -33,7 +34,10 @@ public class TwoFactorController : Controller
|
||||
private readonly UserManager<User> _userManager;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
private readonly IVerifyAuthRequestCommand _verifyAuthRequestCommand;
|
||||
private readonly IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> _tokenDataFactory;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IDataProtectorTokenFactory<TwoFactorAuthenticatorUserVerificationTokenable> _twoFactorAuthenticatorDataProtector;
|
||||
private readonly IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> _ssoEmailTwoFactorSessionDataProtector;
|
||||
private readonly bool _TwoFactorAuthenticatorTokenFeatureFlagEnabled;
|
||||
|
||||
public TwoFactorController(
|
||||
IUserService userService,
|
||||
@ -43,7 +47,9 @@ public class TwoFactorController : Controller
|
||||
UserManager<User> userManager,
|
||||
ICurrentContext currentContext,
|
||||
IVerifyAuthRequestCommand verifyAuthRequestCommand,
|
||||
IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> tokenDataFactory)
|
||||
IFeatureService featureService,
|
||||
IDataProtectorTokenFactory<TwoFactorAuthenticatorUserVerificationTokenable> twoFactorAuthenticatorDataProtector,
|
||||
IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> ssoEmailTwoFactorSessionDataProtector)
|
||||
{
|
||||
_userService = userService;
|
||||
_organizationRepository = organizationRepository;
|
||||
@ -52,7 +58,10 @@ public class TwoFactorController : Controller
|
||||
_userManager = userManager;
|
||||
_currentContext = currentContext;
|
||||
_verifyAuthRequestCommand = verifyAuthRequestCommand;
|
||||
_tokenDataFactory = tokenDataFactory;
|
||||
_featureService = featureService;
|
||||
_twoFactorAuthenticatorDataProtector = twoFactorAuthenticatorDataProtector;
|
||||
_ssoEmailTwoFactorSessionDataProtector = ssoEmailTwoFactorSessionDataProtector;
|
||||
_TwoFactorAuthenticatorTokenFeatureFlagEnabled = _featureService.IsEnabled(FeatureFlagKeys.AuthenticatorTwoFactorToken);
|
||||
}
|
||||
|
||||
[HttpGet("")]
|
||||
@ -93,8 +102,13 @@ public class TwoFactorController : Controller
|
||||
public async Task<TwoFactorAuthenticatorResponseModel> GetAuthenticator(
|
||||
[FromBody] SecretVerificationRequestModel model)
|
||||
{
|
||||
var user = await CheckAsync(model, false);
|
||||
var user = _TwoFactorAuthenticatorTokenFeatureFlagEnabled ? await CheckAsync(model, false) : await CheckAsync(model, false, true);
|
||||
var response = new TwoFactorAuthenticatorResponseModel(user);
|
||||
if (_TwoFactorAuthenticatorTokenFeatureFlagEnabled)
|
||||
{
|
||||
var tokenable = new TwoFactorAuthenticatorUserVerificationTokenable(user, response.Key);
|
||||
response.UserVerificationToken = _twoFactorAuthenticatorDataProtector.Protect(tokenable);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
@ -103,8 +117,21 @@ public class TwoFactorController : Controller
|
||||
public async Task<TwoFactorAuthenticatorResponseModel> PutAuthenticator(
|
||||
[FromBody] UpdateTwoFactorAuthenticatorRequestModel model)
|
||||
{
|
||||
var user = await CheckAsync(model, false);
|
||||
model.ToUser(user);
|
||||
User user;
|
||||
if (_TwoFactorAuthenticatorTokenFeatureFlagEnabled)
|
||||
{
|
||||
user = model.ToUser(await _userService.GetUserByPrincipalAsync(User));
|
||||
_twoFactorAuthenticatorDataProtector.TryUnprotect(model.UserVerificationToken, out var decryptedToken);
|
||||
if (!decryptedToken.TokenIsValid(user, model.Key))
|
||||
{
|
||||
throw new BadRequestException("UserVerificationToken", "User verification failed.");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
user = await CheckAsync(model, false);
|
||||
model.ToUser(user); // populates user obj with proper metadata for VerifyTwoFactorTokenAsync
|
||||
}
|
||||
|
||||
if (!await _userManager.VerifyTwoFactorTokenAsync(user,
|
||||
CoreHelpers.CustomProviderName(TwoFactorProviderType.Authenticator), model.Token))
|
||||
@ -118,10 +145,26 @@ public class TwoFactorController : Controller
|
||||
return response;
|
||||
}
|
||||
|
||||
[RequireFeature(FeatureFlagKeys.AuthenticatorTwoFactorToken)]
|
||||
[HttpDelete("authenticator")]
|
||||
public async Task<TwoFactorProviderResponseModel> DisableAuthenticator(
|
||||
[FromBody] TwoFactorAuthenticatorDisableRequestModel model)
|
||||
{
|
||||
var user = await _userService.GetUserByPrincipalAsync(User);
|
||||
_twoFactorAuthenticatorDataProtector.TryUnprotect(model.UserVerificationToken, out var decryptedToken);
|
||||
if (!decryptedToken.TokenIsValid(user, model.Key))
|
||||
{
|
||||
throw new BadRequestException("UserVerificationToken", "User verification failed.");
|
||||
}
|
||||
|
||||
await _userService.DisableTwoFactorProviderAsync(user, model.Type.Value, _organizationService);
|
||||
return new TwoFactorProviderResponseModel(model.Type.Value, user);
|
||||
}
|
||||
|
||||
[HttpPost("get-yubikey")]
|
||||
public async Task<TwoFactorYubiKeyResponseModel> GetYubiKey([FromBody] SecretVerificationRequestModel model)
|
||||
{
|
||||
var user = await CheckAsync(model, true);
|
||||
var user = await CheckAsync(model, true, true);
|
||||
var response = new TwoFactorYubiKeyResponseModel(user);
|
||||
return response;
|
||||
}
|
||||
@ -147,7 +190,7 @@ public class TwoFactorController : Controller
|
||||
[HttpPost("get-duo")]
|
||||
public async Task<TwoFactorDuoResponseModel> GetDuo([FromBody] SecretVerificationRequestModel model)
|
||||
{
|
||||
var user = await CheckAsync(model, true);
|
||||
var user = await CheckAsync(model, true, true);
|
||||
var response = new TwoFactorDuoResponseModel(user);
|
||||
return response;
|
||||
}
|
||||
@ -187,7 +230,7 @@ public class TwoFactorController : Controller
|
||||
public async Task<TwoFactorDuoResponseModel> GetOrganizationDuo(string id,
|
||||
[FromBody] SecretVerificationRequestModel model)
|
||||
{
|
||||
await CheckAsync(model, false);
|
||||
await CheckAsync(model, false, true);
|
||||
|
||||
var orgIdGuid = new Guid(id);
|
||||
if (!await _currentContext.ManagePolicies(orgIdGuid))
|
||||
@ -244,7 +287,7 @@ public class TwoFactorController : Controller
|
||||
[HttpPost("get-webauthn")]
|
||||
public async Task<TwoFactorWebAuthnResponseModel> GetWebAuthn([FromBody] SecretVerificationRequestModel model)
|
||||
{
|
||||
var user = await CheckAsync(model, false);
|
||||
var user = await CheckAsync(model, false, true);
|
||||
var response = new TwoFactorWebAuthnResponseModel(user);
|
||||
return response;
|
||||
}
|
||||
@ -253,7 +296,7 @@ public class TwoFactorController : Controller
|
||||
[ApiExplorerSettings(IgnoreApi = true)] // Disable Swagger due to CredentialCreateOptions not converting properly
|
||||
public async Task<CredentialCreateOptions> GetWebAuthnChallenge([FromBody] SecretVerificationRequestModel model)
|
||||
{
|
||||
var user = await CheckAsync(model, false);
|
||||
var user = await CheckAsync(model, false, true);
|
||||
var reg = await _userService.StartWebAuthnRegistrationAsync(user);
|
||||
return reg;
|
||||
}
|
||||
@ -288,7 +331,7 @@ public class TwoFactorController : Controller
|
||||
[HttpPost("get-email")]
|
||||
public async Task<TwoFactorEmailResponseModel> GetEmail([FromBody] SecretVerificationRequestModel model)
|
||||
{
|
||||
var user = await CheckAsync(model, false);
|
||||
var user = await CheckAsync(model, false, true);
|
||||
var response = new TwoFactorEmailResponseModel(user);
|
||||
return response;
|
||||
}
|
||||
@ -296,7 +339,7 @@ public class TwoFactorController : Controller
|
||||
[HttpPost("send-email")]
|
||||
public async Task SendEmail([FromBody] TwoFactorEmailRequestModel model)
|
||||
{
|
||||
var user = await CheckAsync(model, false);
|
||||
var user = await CheckAsync(model, false, true);
|
||||
model.ToUser(user);
|
||||
await _userService.SendTwoFactorEmailAsync(user);
|
||||
}
|
||||
@ -433,7 +476,8 @@ public class TwoFactorController : Controller
|
||||
return Task.FromResult(new DeviceVerificationResponseModel(false, false));
|
||||
}
|
||||
|
||||
private async Task<User> CheckAsync(SecretVerificationRequestModel model, bool premium)
|
||||
private async Task<User> CheckAsync(SecretVerificationRequestModel model, bool premium,
|
||||
bool skipVerification = false)
|
||||
{
|
||||
var user = await _userService.GetUserByPrincipalAsync(User);
|
||||
if (user == null)
|
||||
@ -441,7 +485,7 @@ public class TwoFactorController : Controller
|
||||
throw new UnauthorizedAccessException();
|
||||
}
|
||||
|
||||
if (!await _userService.VerifySecretAsync(user, model.Secret))
|
||||
if (!await _userService.VerifySecretAsync(user, model.Secret, skipVerification))
|
||||
{
|
||||
await Task.Delay(2000);
|
||||
throw new BadRequestException(string.Empty, "User verification failed.");
|
||||
@ -476,7 +520,7 @@ public class TwoFactorController : Controller
|
||||
|
||||
private bool ValidateSsoEmail2FaToken(string ssoEmail2FaSessionToken, User user)
|
||||
{
|
||||
return _tokenDataFactory.TryUnprotect(ssoEmail2FaSessionToken, out var decryptedToken) &&
|
||||
return _ssoEmailTwoFactorSessionDataProtector.TryUnprotect(ssoEmail2FaSessionToken, out var decryptedToken) &&
|
||||
decryptedToken.Valid && decryptedToken.TokenIsValid(user);
|
||||
}
|
||||
|
||||
|
@ -0,0 +1,14 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Bit.Api.Auth.Models.Request.Accounts;
|
||||
|
||||
public class UpdateTdeOffboardingPasswordRequestModel
|
||||
{
|
||||
[Required]
|
||||
[StringLength(300)]
|
||||
public string NewMasterPasswordHash { get; set; }
|
||||
[Required]
|
||||
public string Key { get; set; }
|
||||
[StringLength(50)]
|
||||
public string MasterPasswordHint { get; set; }
|
||||
}
|
@ -17,7 +17,7 @@ public class UpdateTwoFactorAuthenticatorRequestModel : SecretVerificationReques
|
||||
[Required]
|
||||
[StringLength(50)]
|
||||
public string Key { get; set; }
|
||||
|
||||
public string UserVerificationToken { get; set; }
|
||||
public User ToUser(User existingUser)
|
||||
{
|
||||
var providers = existingUser.GetTwoFactorProviders();
|
||||
@ -323,3 +323,11 @@ public class TwoFactorRecoveryRequestModel : TwoFactorEmailRequestModel
|
||||
[StringLength(32)]
|
||||
public string RecoveryCode { get; set; }
|
||||
}
|
||||
|
||||
public class TwoFactorAuthenticatorDisableRequestModel : TwoFactorProviderRequestModel
|
||||
{
|
||||
[Required]
|
||||
public string UserVerificationToken { get; set; }
|
||||
[Required]
|
||||
public string Key { get; set; }
|
||||
}
|
||||
|
@ -10,10 +10,7 @@ public class TwoFactorAuthenticatorResponseModel : ResponseModel
|
||||
public TwoFactorAuthenticatorResponseModel(User user)
|
||||
: base("twoFactorAuthenticator")
|
||||
{
|
||||
if (user == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(user));
|
||||
}
|
||||
ArgumentNullException.ThrowIfNull(user);
|
||||
|
||||
var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Authenticator);
|
||||
if (provider?.MetaData?.ContainsKey("Key") ?? false)
|
||||
@ -31,4 +28,5 @@ public class TwoFactorAuthenticatorResponseModel : ResponseModel
|
||||
|
||||
public bool Enabled { get; set; }
|
||||
public string Key { get; set; }
|
||||
public string UserVerificationToken { get; set; }
|
||||
}
|
||||
|
@ -59,8 +59,8 @@ public class TwoFactorDuoResponseModel : ResponseModel
|
||||
// check Skey and IKey first if they exist
|
||||
if (provider.MetaData.TryGetValue("SKey", out var sKey))
|
||||
{
|
||||
ClientSecret = (string)sKey;
|
||||
SecretKey = (string)sKey;
|
||||
ClientSecret = MaskKey((string)sKey);
|
||||
SecretKey = MaskKey((string)sKey);
|
||||
}
|
||||
if (provider.MetaData.TryGetValue("IKey", out var iKey))
|
||||
{
|
||||
@ -73,8 +73,8 @@ public class TwoFactorDuoResponseModel : ResponseModel
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace((string)clientSecret))
|
||||
{
|
||||
ClientSecret = (string)clientSecret;
|
||||
SecretKey = (string)clientSecret;
|
||||
ClientSecret = MaskKey((string)clientSecret);
|
||||
SecretKey = MaskKey((string)clientSecret);
|
||||
}
|
||||
}
|
||||
if (provider.MetaData.TryGetValue("ClientId", out var clientId))
|
||||
@ -114,4 +114,15 @@ public class TwoFactorDuoResponseModel : ResponseModel
|
||||
throw new InvalidDataException("Invalid Duo parameters.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string MaskKey(string key)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key) || key.Length <= 6)
|
||||
{
|
||||
return key;
|
||||
}
|
||||
|
||||
// Mask all but the first 6 characters.
|
||||
return string.Concat(key.AsSpan(0, 6), new string('*', key.Length - 6));
|
||||
}
|
||||
}
|
||||
|
@ -3,7 +3,9 @@ using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Models.Api;
|
||||
using Bit.Core.Services;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Bit.Api.Billing.Controllers;
|
||||
@ -11,8 +13,25 @@ namespace Bit.Api.Billing.Controllers;
|
||||
public abstract class BaseProviderController(
|
||||
ICurrentContext currentContext,
|
||||
IFeatureService featureService,
|
||||
IProviderRepository providerRepository) : Controller
|
||||
ILogger<BaseProviderController> logger,
|
||||
IProviderRepository providerRepository,
|
||||
IUserService userService) : Controller
|
||||
{
|
||||
protected readonly IUserService UserService = userService;
|
||||
|
||||
protected static NotFound<ErrorResponseModel> NotFoundResponse() =>
|
||||
TypedResults.NotFound(new ErrorResponseModel("Resource not found."));
|
||||
|
||||
protected static JsonHttpResult<ErrorResponseModel> ServerErrorResponse(string errorMessage) =>
|
||||
TypedResults.Json(
|
||||
new ErrorResponseModel(errorMessage),
|
||||
statusCode: StatusCodes.Status500InternalServerError);
|
||||
|
||||
protected static JsonHttpResult<ErrorResponseModel> UnauthorizedResponse() =>
|
||||
TypedResults.Json(
|
||||
new ErrorResponseModel("Unauthorized."),
|
||||
statusCode: StatusCodes.Status401Unauthorized);
|
||||
|
||||
protected Task<(Provider, IResult)> TryGetBillableProviderForAdminOperation(
|
||||
Guid providerId) => TryGetBillableProviderAsync(providerId, currentContext.ProviderProviderAdmin);
|
||||
|
||||
@ -25,26 +44,53 @@ public abstract class BaseProviderController(
|
||||
{
|
||||
if (!featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling))
|
||||
{
|
||||
return (null, TypedResults.NotFound());
|
||||
logger.LogError(
|
||||
"Cannot run Consolidated Billing operation for provider ({ProviderID}) while feature flag is disabled",
|
||||
providerId);
|
||||
|
||||
return (null, NotFoundResponse());
|
||||
}
|
||||
|
||||
var provider = await providerRepository.GetByIdAsync(providerId);
|
||||
|
||||
if (provider == null)
|
||||
{
|
||||
return (null, TypedResults.NotFound());
|
||||
logger.LogError(
|
||||
"Cannot find provider ({ProviderID}) for Consolidated Billing operation",
|
||||
providerId);
|
||||
|
||||
return (null, NotFoundResponse());
|
||||
}
|
||||
|
||||
if (!checkAuthorization(providerId))
|
||||
{
|
||||
return (null, TypedResults.Unauthorized());
|
||||
var user = await UserService.GetUserByPrincipalAsync(User);
|
||||
|
||||
logger.LogError(
|
||||
"User ({UserID}) is not authorized to perform Consolidated Billing operation for provider ({ProviderID})",
|
||||
user?.Id, providerId);
|
||||
|
||||
return (null, UnauthorizedResponse());
|
||||
}
|
||||
|
||||
if (!provider.IsBillable())
|
||||
{
|
||||
return (null, TypedResults.Unauthorized());
|
||||
logger.LogError(
|
||||
"Cannot run Consolidated Billing operation for provider ({ProviderID}) that is not billable",
|
||||
providerId);
|
||||
|
||||
return (null, UnauthorizedResponse());
|
||||
}
|
||||
|
||||
return (provider, null);
|
||||
if (provider.IsStripeEnabled())
|
||||
{
|
||||
return (provider, null);
|
||||
}
|
||||
|
||||
logger.LogError(
|
||||
"Cannot run Consolidated Billing operation for provider ({ProviderID}) that is missing Stripe configuration",
|
||||
providerId);
|
||||
|
||||
return (null, ServerErrorResponse("Something went wrong with your request. Please contact support."));
|
||||
}
|
||||
}
|
||||
|
@ -20,6 +20,11 @@ public class OrganizationBillingController(
|
||||
[HttpGet("metadata")]
|
||||
public async Task<IResult> GetMetadataAsync([FromRoute] Guid organizationId)
|
||||
{
|
||||
if (!await currentContext.AccessMembersTab(organizationId))
|
||||
{
|
||||
return TypedResults.Unauthorized();
|
||||
}
|
||||
|
||||
var metadata = await organizationBillingService.GetMetadata(organizationId);
|
||||
|
||||
if (metadata == null)
|
||||
@ -35,6 +40,11 @@ public class OrganizationBillingController(
|
||||
[HttpGet("history")]
|
||||
public async Task<IResult> GetHistoryAsync([FromRoute] Guid organizationId)
|
||||
{
|
||||
if (!await currentContext.ViewBillingHistory(organizationId))
|
||||
{
|
||||
return TypedResults.Unauthorized();
|
||||
}
|
||||
|
||||
var organization = await organizationRepository.GetByIdAsync(organizationId);
|
||||
|
||||
if (organization == null)
|
||||
|
@ -162,13 +162,13 @@ public class OrganizationsController(
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task PostSmSubscription(Guid id, [FromBody] SecretsManagerSubscriptionUpdateRequestModel model)
|
||||
{
|
||||
var organization = await organizationRepository.GetByIdAsync(id);
|
||||
if (organization == null)
|
||||
if (!await currentContext.EditSubscription(id))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
if (!await currentContext.EditSubscription(id))
|
||||
var organization = await organizationRepository.GetByIdAsync(id);
|
||||
if (organization == null)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
@ -195,13 +195,13 @@ public class OrganizationsController(
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task<ProfileOrganizationResponseModel> PostSubscribeSecretsManagerAsync(Guid id, [FromBody] SecretsManagerSubscribeRequestModel model)
|
||||
{
|
||||
var organization = await organizationRepository.GetByIdAsync(id);
|
||||
if (organization == null)
|
||||
if (!await currentContext.EditSubscription(id))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
if (!await currentContext.EditSubscription(id))
|
||||
var organization = await organizationRepository.GetByIdAsync(id);
|
||||
if (organization == null)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
@ -1,15 +1,19 @@
|
||||
using Bit.Api.Billing.Models.Requests;
|
||||
using Bit.Api.Billing.Models.Responses;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Billing.Repositories;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Models.Api;
|
||||
using Bit.Core.Models.BitStripe;
|
||||
using Bit.Core.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Stripe;
|
||||
|
||||
using static Bit.Core.Billing.Utilities;
|
||||
|
||||
namespace Bit.Api.Billing.Controllers;
|
||||
|
||||
[Route("providers/{providerId:guid}/billing")]
|
||||
@ -17,10 +21,13 @@ namespace Bit.Api.Billing.Controllers;
|
||||
public class ProviderBillingController(
|
||||
ICurrentContext currentContext,
|
||||
IFeatureService featureService,
|
||||
ILogger<BaseProviderController> logger,
|
||||
IProviderBillingService providerBillingService,
|
||||
IProviderPlanRepository providerPlanRepository,
|
||||
IProviderRepository providerRepository,
|
||||
ISubscriberService subscriberService,
|
||||
IStripeAdapter stripeAdapter,
|
||||
ISubscriberService subscriberService) : BaseProviderController(currentContext, featureService, providerRepository)
|
||||
IUserService userService) : BaseProviderController(currentContext, featureService, logger, providerRepository, userService)
|
||||
{
|
||||
[HttpGet("invoices")]
|
||||
public async Task<IResult> GetInvoicesAsync([FromRoute] Guid providerId)
|
||||
@ -32,7 +39,10 @@ public class ProviderBillingController(
|
||||
return result;
|
||||
}
|
||||
|
||||
var invoices = await subscriberService.GetInvoices(provider);
|
||||
var invoices = await stripeAdapter.InvoiceListAsync(new StripeInvoiceListOptions
|
||||
{
|
||||
Customer = provider.GatewayCustomerId
|
||||
});
|
||||
|
||||
var response = InvoicesResponse.From(invoices);
|
||||
|
||||
@ -53,7 +63,7 @@ public class ProviderBillingController(
|
||||
|
||||
if (reportContent == null)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
return ServerErrorResponse("We had a problem generating your invoice CSV. Please contact support.");
|
||||
}
|
||||
|
||||
return TypedResults.File(
|
||||
@ -61,95 +71,6 @@ public class ProviderBillingController(
|
||||
"text/csv");
|
||||
}
|
||||
|
||||
[HttpGet("payment-information")]
|
||||
public async Task<IResult> GetPaymentInformationAsync([FromRoute] Guid providerId)
|
||||
{
|
||||
var (provider, result) = await TryGetBillableProviderForAdminOperation(providerId);
|
||||
|
||||
if (provider == null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
var paymentInformation = await subscriberService.GetPaymentInformation(provider);
|
||||
|
||||
if (paymentInformation == null)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
var response = PaymentInformationResponse.From(paymentInformation);
|
||||
|
||||
return TypedResults.Ok(response);
|
||||
}
|
||||
|
||||
[HttpGet("payment-method")]
|
||||
public async Task<IResult> GetPaymentMethodAsync([FromRoute] Guid providerId)
|
||||
{
|
||||
var (provider, result) = await TryGetBillableProviderForAdminOperation(providerId);
|
||||
|
||||
if (provider == null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
var maskedPaymentMethod = await subscriberService.GetPaymentMethod(provider);
|
||||
|
||||
if (maskedPaymentMethod == null)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
var response = MaskedPaymentMethodResponse.From(maskedPaymentMethod);
|
||||
|
||||
return TypedResults.Ok(response);
|
||||
}
|
||||
|
||||
[HttpPut("payment-method")]
|
||||
public async Task<IResult> UpdatePaymentMethodAsync(
|
||||
[FromRoute] Guid providerId,
|
||||
[FromBody] TokenizedPaymentMethodRequestBody requestBody)
|
||||
{
|
||||
var (provider, result) = await TryGetBillableProviderForAdminOperation(providerId);
|
||||
|
||||
if (provider == null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
var tokenizedPaymentMethod = new TokenizedPaymentMethodDTO(
|
||||
requestBody.Type,
|
||||
requestBody.Token);
|
||||
|
||||
await subscriberService.UpdatePaymentMethod(provider, tokenizedPaymentMethod);
|
||||
|
||||
await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId,
|
||||
new SubscriptionUpdateOptions
|
||||
{
|
||||
CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically
|
||||
});
|
||||
|
||||
return TypedResults.Ok();
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Route("payment-method/verify-bank-account")]
|
||||
public async Task<IResult> VerifyBankAccountAsync(
|
||||
[FromRoute] Guid providerId,
|
||||
[FromBody] VerifyBankAccountRequestBody requestBody)
|
||||
{
|
||||
var (provider, result) = await TryGetBillableProviderForAdminOperation(providerId);
|
||||
|
||||
if (provider == null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
await subscriberService.VerifyBankAccount(provider, (requestBody.Amount1, requestBody.Amount2));
|
||||
|
||||
return TypedResults.Ok();
|
||||
}
|
||||
|
||||
[HttpGet("subscription")]
|
||||
public async Task<IResult> GetSubscriptionAsync([FromRoute] Guid providerId)
|
||||
{
|
||||
@ -160,36 +81,20 @@ public class ProviderBillingController(
|
||||
return result;
|
||||
}
|
||||
|
||||
var consolidatedBillingSubscription = await providerBillingService.GetConsolidatedBillingSubscription(provider);
|
||||
var subscription = await stripeAdapter.SubscriptionGetAsync(provider.GatewaySubscriptionId,
|
||||
new SubscriptionGetOptions { Expand = ["customer.tax_ids", "test_clock"] });
|
||||
|
||||
if (consolidatedBillingSubscription == null)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
|
||||
|
||||
var response = ConsolidatedBillingSubscriptionResponse.From(consolidatedBillingSubscription);
|
||||
var taxInformation = GetTaxInformation(subscription.Customer);
|
||||
|
||||
return TypedResults.Ok(response);
|
||||
}
|
||||
var subscriptionSuspension = await GetSubscriptionSuspensionAsync(stripeAdapter, subscription);
|
||||
|
||||
[HttpGet("tax-information")]
|
||||
public async Task<IResult> GetTaxInformationAsync([FromRoute] Guid providerId)
|
||||
{
|
||||
var (provider, result) = await TryGetBillableProviderForAdminOperation(providerId);
|
||||
|
||||
if (provider == null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
var taxInformation = await subscriberService.GetTaxInformation(provider);
|
||||
|
||||
if (taxInformation == null)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
var response = TaxInformationResponse.From(taxInformation);
|
||||
var response = ProviderSubscriptionResponse.From(
|
||||
subscription,
|
||||
providerPlans,
|
||||
taxInformation,
|
||||
subscriptionSuspension);
|
||||
|
||||
return TypedResults.Ok(response);
|
||||
}
|
||||
@ -206,7 +111,13 @@ public class ProviderBillingController(
|
||||
return result;
|
||||
}
|
||||
|
||||
var taxInformation = new TaxInformationDTO(
|
||||
if (requestBody is not { Country: not null, PostalCode: not null })
|
||||
{
|
||||
return TypedResults.BadRequest(
|
||||
new ErrorResponseModel("Country and postal code are required to update your tax information."));
|
||||
}
|
||||
|
||||
var taxInformation = new TaxInformation(
|
||||
requestBody.Country,
|
||||
requestBody.PostalCode,
|
||||
requestBody.TaxId,
|
||||
|
@ -15,13 +15,13 @@ namespace Bit.Api.Billing.Controllers;
|
||||
public class ProviderClientsController(
|
||||
ICurrentContext currentContext,
|
||||
IFeatureService featureService,
|
||||
ILogger<ProviderClientsController> logger,
|
||||
ILogger<BaseProviderController> logger,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IProviderBillingService providerBillingService,
|
||||
IProviderOrganizationRepository providerOrganizationRepository,
|
||||
IProviderRepository providerRepository,
|
||||
IProviderService providerService,
|
||||
IUserService userService) : BaseProviderController(currentContext, featureService, providerRepository)
|
||||
IUserService userService) : BaseProviderController(currentContext, featureService, logger, providerRepository, userService)
|
||||
{
|
||||
[HttpPost]
|
||||
public async Task<IResult> CreateAsync(
|
||||
@ -35,11 +35,11 @@ public class ProviderClientsController(
|
||||
return result;
|
||||
}
|
||||
|
||||
var user = await userService.GetUserByPrincipalAsync(User);
|
||||
var user = await UserService.GetUserByPrincipalAsync(User);
|
||||
|
||||
if (user == null)
|
||||
{
|
||||
return TypedResults.Unauthorized();
|
||||
return UnauthorizedResponse();
|
||||
}
|
||||
|
||||
var organizationSignup = new OrganizationSignup
|
||||
@ -63,13 +63,6 @@ public class ProviderClientsController(
|
||||
|
||||
var clientOrganization = await organizationRepository.GetByIdAsync(providerOrganization.OrganizationId);
|
||||
|
||||
if (clientOrganization == null)
|
||||
{
|
||||
logger.LogError("Newly created client organization ({ID}) could not be found", providerOrganization.OrganizationId);
|
||||
|
||||
return TypedResults.Problem();
|
||||
}
|
||||
|
||||
await providerBillingService.ScaleSeats(
|
||||
provider,
|
||||
requestBody.PlanType,
|
||||
@ -103,18 +96,11 @@ public class ProviderClientsController(
|
||||
|
||||
if (providerOrganization == null)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
return NotFoundResponse();
|
||||
}
|
||||
|
||||
var clientOrganization = await organizationRepository.GetByIdAsync(providerOrganization.OrganizationId);
|
||||
|
||||
if (clientOrganization == null)
|
||||
{
|
||||
logger.LogError("The client organization ({OrganizationID}) represented by provider organization ({ProviderOrganizationID}) could not be found.", providerOrganization.OrganizationId, providerOrganization.Id);
|
||||
|
||||
return TypedResults.Problem();
|
||||
}
|
||||
|
||||
if (clientOrganization.Seats != requestBody.AssignedSeats)
|
||||
{
|
||||
await providerBillingService.AssignSeatsToClientOrganization(
|
||||
|
@ -1,59 +0,0 @@
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Api.Billing.Models.Responses;
|
||||
|
||||
public record ConsolidatedBillingSubscriptionResponse(
|
||||
string Status,
|
||||
DateTime CurrentPeriodEndDate,
|
||||
decimal? DiscountPercentage,
|
||||
string CollectionMethod,
|
||||
IEnumerable<ProviderPlanResponse> Plans,
|
||||
long AccountCredit,
|
||||
TaxInformationDTO TaxInformation,
|
||||
DateTime? CancelAt,
|
||||
SubscriptionSuspensionDTO Suspension)
|
||||
{
|
||||
private const string _annualCadence = "Annual";
|
||||
private const string _monthlyCadence = "Monthly";
|
||||
|
||||
public static ConsolidatedBillingSubscriptionResponse From(
|
||||
ConsolidatedBillingSubscriptionDTO consolidatedBillingSubscription)
|
||||
{
|
||||
var (providerPlans, subscription, taxInformation, suspension) = consolidatedBillingSubscription;
|
||||
|
||||
var providerPlanResponses = providerPlans
|
||||
.Select(providerPlan =>
|
||||
{
|
||||
var plan = StaticStore.GetPlan(providerPlan.PlanType);
|
||||
var cost = (providerPlan.SeatMinimum + providerPlan.PurchasedSeats) * plan.PasswordManager.ProviderPortalSeatPrice;
|
||||
var cadence = plan.IsAnnual ? _annualCadence : _monthlyCadence;
|
||||
return new ProviderPlanResponse(
|
||||
plan.Name,
|
||||
providerPlan.SeatMinimum,
|
||||
providerPlan.PurchasedSeats,
|
||||
providerPlan.AssignedSeats,
|
||||
cost,
|
||||
cadence);
|
||||
});
|
||||
|
||||
return new ConsolidatedBillingSubscriptionResponse(
|
||||
subscription.Status,
|
||||
subscription.CurrentPeriodEnd,
|
||||
subscription.Customer?.Discount?.Coupon?.PercentOff,
|
||||
subscription.CollectionMethod,
|
||||
providerPlanResponses,
|
||||
subscription.Customer?.Balance ?? 0,
|
||||
taxInformation,
|
||||
subscription.CancelAt,
|
||||
suspension);
|
||||
}
|
||||
}
|
||||
|
||||
public record ProviderPlanResponse(
|
||||
string PlanName,
|
||||
int SeatMinimum,
|
||||
int PurchasedSeats,
|
||||
int AssignedSeats,
|
||||
decimal Cost,
|
||||
string Cadence);
|
@ -3,16 +3,16 @@
|
||||
namespace Bit.Api.Billing.Models.Responses;
|
||||
|
||||
public record InvoicesResponse(
|
||||
List<InvoiceDTO> Invoices)
|
||||
List<InvoiceResponse> Invoices)
|
||||
{
|
||||
public static InvoicesResponse From(IEnumerable<Invoice> invoices) => new(
|
||||
invoices
|
||||
.Where(i => i.Status is "open" or "paid" or "uncollectible")
|
||||
.OrderByDescending(i => i.Created)
|
||||
.Select(InvoiceDTO.From).ToList());
|
||||
.Select(InvoiceResponse.From).ToList());
|
||||
}
|
||||
|
||||
public record InvoiceDTO(
|
||||
public record InvoiceResponse(
|
||||
string Id,
|
||||
DateTime Date,
|
||||
string Number,
|
||||
@ -21,7 +21,7 @@ public record InvoiceDTO(
|
||||
DateTime? DueDate,
|
||||
string Url)
|
||||
{
|
||||
public static InvoiceDTO From(Invoice invoice) => new(
|
||||
public static InvoiceResponse From(Invoice invoice) => new(
|
||||
invoice.Id,
|
||||
invoice.Created,
|
||||
invoice.Number,
|
||||
|
@ -5,7 +5,7 @@ namespace Bit.Api.Billing.Models.Responses;
|
||||
public record PaymentInformationResponse(
|
||||
long AccountCredit,
|
||||
MaskedPaymentMethodDTO PaymentMethod,
|
||||
TaxInformationDTO TaxInformation)
|
||||
TaxInformation TaxInformation)
|
||||
{
|
||||
public static PaymentInformationResponse From(PaymentInformationDTO paymentInformation) =>
|
||||
new(
|
||||
|
@ -0,0 +1,66 @@
|
||||
using Bit.Core.Billing.Entities;
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Utilities;
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Api.Billing.Models.Responses;
|
||||
|
||||
public record ProviderSubscriptionResponse(
|
||||
string Status,
|
||||
DateTime CurrentPeriodEndDate,
|
||||
decimal? DiscountPercentage,
|
||||
string CollectionMethod,
|
||||
IEnumerable<ProviderPlanResponse> Plans,
|
||||
decimal AccountCredit,
|
||||
TaxInformation TaxInformation,
|
||||
DateTime? CancelAt,
|
||||
SubscriptionSuspension Suspension)
|
||||
{
|
||||
private const string _annualCadence = "Annual";
|
||||
private const string _monthlyCadence = "Monthly";
|
||||
|
||||
public static ProviderSubscriptionResponse From(
|
||||
Subscription subscription,
|
||||
ICollection<ProviderPlan> providerPlans,
|
||||
TaxInformation taxInformation,
|
||||
SubscriptionSuspension subscriptionSuspension)
|
||||
{
|
||||
var providerPlanResponses = providerPlans
|
||||
.Where(providerPlan => providerPlan.IsConfigured())
|
||||
.Select(ConfiguredProviderPlan.From)
|
||||
.Select(configuredProviderPlan =>
|
||||
{
|
||||
var plan = StaticStore.GetPlan(configuredProviderPlan.PlanType);
|
||||
var cost = (configuredProviderPlan.SeatMinimum + configuredProviderPlan.PurchasedSeats) * plan.PasswordManager.ProviderPortalSeatPrice;
|
||||
var cadence = plan.IsAnnual ? _annualCadence : _monthlyCadence;
|
||||
return new ProviderPlanResponse(
|
||||
plan.Name,
|
||||
configuredProviderPlan.SeatMinimum,
|
||||
configuredProviderPlan.PurchasedSeats,
|
||||
configuredProviderPlan.AssignedSeats,
|
||||
cost,
|
||||
cadence);
|
||||
});
|
||||
|
||||
var accountCredit = Convert.ToDecimal(subscription.Customer?.Balance) * -1 / 100;
|
||||
|
||||
return new ProviderSubscriptionResponse(
|
||||
subscription.Status,
|
||||
subscription.CurrentPeriodEnd,
|
||||
subscription.Customer?.Discount?.Coupon?.PercentOff,
|
||||
subscription.CollectionMethod,
|
||||
providerPlanResponses,
|
||||
accountCredit,
|
||||
taxInformation,
|
||||
subscription.CancelAt,
|
||||
subscriptionSuspension);
|
||||
}
|
||||
}
|
||||
|
||||
public record ProviderPlanResponse(
|
||||
string PlanName,
|
||||
int SeatMinimum,
|
||||
int PurchasedSeats,
|
||||
int AssignedSeats,
|
||||
decimal Cost,
|
||||
string Cadence);
|
@ -11,7 +11,7 @@ public record TaxInformationResponse(
|
||||
string City,
|
||||
string State)
|
||||
{
|
||||
public static TaxInformationResponse From(TaxInformationDTO taxInformation)
|
||||
public static TaxInformationResponse From(TaxInformation taxInformation)
|
||||
=> new(
|
||||
taxInformation.Country,
|
||||
taxInformation.PostalCode,
|
||||
|
@ -107,7 +107,7 @@ public class CollectionsController : Controller
|
||||
}
|
||||
else
|
||||
{
|
||||
var assignedCollections = await _collectionRepository.GetManyByUserIdAsync(_currentContext.UserId.Value, false);
|
||||
var assignedCollections = await _collectionRepository.GetManyByUserIdAsync(_currentContext.UserId.Value);
|
||||
orgCollections = assignedCollections.Where(c => c.OrganizationId == orgId && c.Manage).ToList();
|
||||
}
|
||||
|
||||
@ -119,7 +119,7 @@ public class CollectionsController : Controller
|
||||
public async Task<ListResponseModel<CollectionDetailsResponseModel>> GetUser()
|
||||
{
|
||||
var collections = await _collectionRepository.GetManyByUserIdAsync(
|
||||
_userService.GetProperUserId(User).Value, false);
|
||||
_userService.GetProperUserId(User).Value);
|
||||
var responses = collections.Select(c => new CollectionDetailsResponseModel(c));
|
||||
return new ListResponseModel<CollectionDetailsResponseModel>(responses);
|
||||
}
|
||||
|
@ -3,6 +3,7 @@ using Bit.Api.Auth.Models.Request;
|
||||
using Bit.Api.Auth.Models.Request.Accounts;
|
||||
using Bit.Api.Models.Request;
|
||||
using Bit.Api.Models.Response;
|
||||
using Bit.Core;
|
||||
using Bit.Core.Auth.Models.Api.Request;
|
||||
using Bit.Core.Auth.Models.Api.Response;
|
||||
using Bit.Core.Context;
|
||||
@ -25,19 +26,22 @@ public class DevicesController : Controller
|
||||
private readonly IUserService _userService;
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
private readonly ILogger<DevicesController> _logger;
|
||||
|
||||
public DevicesController(
|
||||
IDeviceRepository deviceRepository,
|
||||
IDeviceService deviceService,
|
||||
IUserService userService,
|
||||
IUserRepository userRepository,
|
||||
ICurrentContext currentContext)
|
||||
ICurrentContext currentContext,
|
||||
ILogger<DevicesController> logger)
|
||||
{
|
||||
_deviceRepository = deviceRepository;
|
||||
_deviceService = deviceService;
|
||||
_userService = userService;
|
||||
_userRepository = userRepository;
|
||||
_currentContext = currentContext;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
@ -231,4 +235,25 @@ public class DevicesController : Controller
|
||||
var device = await _deviceRepository.GetByIdentifierAsync(identifier, user.Id);
|
||||
return device != null;
|
||||
}
|
||||
|
||||
[RequireFeature(FeatureFlagKeys.DeviceTrustLogging)]
|
||||
[HttpPost("lost-trust")]
|
||||
public void PostLostTrust()
|
||||
{
|
||||
var userId = _currentContext.UserId.GetValueOrDefault();
|
||||
if (userId == default)
|
||||
{
|
||||
throw new UnauthorizedAccessException();
|
||||
}
|
||||
|
||||
var deviceId = _currentContext.DeviceIdentifier;
|
||||
if (deviceId == null)
|
||||
{
|
||||
throw new BadRequestException("Please provide a device identifier");
|
||||
}
|
||||
|
||||
_logger.LogError("User {id} has a device key, but didn't receive decryption keys for device {device}", userId,
|
||||
deviceId);
|
||||
}
|
||||
|
||||
}
|
||||
|
119
src/Api/SecretsManager/Controllers/CountsController.cs
Normal file
119
src/Api/SecretsManager/Controllers/CountsController.cs
Normal file
@ -0,0 +1,119 @@
|
||||
#nullable enable
|
||||
using Bit.Api.SecretsManager.Models.Response;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.SecretsManager.Queries.Interfaces;
|
||||
using Bit.Core.SecretsManager.Repositories;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Bit.Api.SecretsManager.Controllers;
|
||||
|
||||
[Authorize("secrets")]
|
||||
public class CountsController : Controller
|
||||
{
|
||||
private readonly ICurrentContext _currentContext;
|
||||
private readonly IAccessClientQuery _accessClientQuery;
|
||||
private readonly IProjectRepository _projectRepository;
|
||||
private readonly ISecretRepository _secretRepository;
|
||||
private readonly IServiceAccountRepository _serviceAccountRepository;
|
||||
|
||||
public CountsController(
|
||||
ICurrentContext currentContext,
|
||||
IAccessClientQuery accessClientQuery,
|
||||
IProjectRepository projectRepository,
|
||||
ISecretRepository secretRepository,
|
||||
IServiceAccountRepository serviceAccountRepository)
|
||||
{
|
||||
_currentContext = currentContext;
|
||||
_accessClientQuery = accessClientQuery;
|
||||
_projectRepository = projectRepository;
|
||||
_secretRepository = secretRepository;
|
||||
_serviceAccountRepository = serviceAccountRepository;
|
||||
}
|
||||
|
||||
[HttpGet("organizations/{organizationId}/sm-counts")]
|
||||
public async Task<OrganizationCountsResponseModel> GetByOrganizationAsync([FromRoute] Guid organizationId)
|
||||
{
|
||||
var (accessType, userId) = await GetAccessClientAsync(organizationId);
|
||||
|
||||
var projectsCountTask = _projectRepository.GetProjectCountByOrganizationIdAsync(organizationId,
|
||||
userId, accessType);
|
||||
|
||||
var secretsCountTask = _secretRepository.GetSecretsCountByOrganizationIdAsync(organizationId,
|
||||
userId, accessType);
|
||||
|
||||
var serviceAccountsCountsTask = _serviceAccountRepository.GetServiceAccountCountByOrganizationIdAsync(
|
||||
organizationId, userId, accessType);
|
||||
|
||||
var counts = await Task.WhenAll(projectsCountTask, secretsCountTask, serviceAccountsCountsTask);
|
||||
|
||||
return new OrganizationCountsResponseModel
|
||||
{
|
||||
Projects = counts[0],
|
||||
Secrets = counts[1],
|
||||
ServiceAccounts = counts[2]
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
[HttpGet("projects/{projectId}/sm-counts")]
|
||||
public async Task<ProjectCountsResponseModel> GetByProjectAsync([FromRoute] Guid projectId)
|
||||
{
|
||||
var project = await _projectRepository.GetByIdAsync(projectId);
|
||||
if (project == null)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var (accessType, userId) = await GetAccessClientAsync(project.OrganizationId);
|
||||
|
||||
var projectsCounts = await _projectRepository.GetProjectCountsByIdAsync(projectId, userId, accessType);
|
||||
|
||||
return new ProjectCountsResponseModel
|
||||
{
|
||||
Secrets = projectsCounts.Secrets,
|
||||
People = projectsCounts.People,
|
||||
ServiceAccounts = projectsCounts.ServiceAccounts
|
||||
};
|
||||
}
|
||||
|
||||
[HttpGet("service-accounts/{serviceAccountId}/sm-counts")]
|
||||
public async Task<ServiceAccountCountsResponseModel> GetByServiceAccountAsync([FromRoute] Guid serviceAccountId)
|
||||
{
|
||||
var serviceAccount = await _serviceAccountRepository.GetByIdAsync(serviceAccountId);
|
||||
if (serviceAccount == null)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var (accessType, userId) = await GetAccessClientAsync(serviceAccount.OrganizationId);
|
||||
|
||||
var serviceAccountCounts =
|
||||
await _serviceAccountRepository.GetServiceAccountCountsByIdAsync(serviceAccountId, userId, accessType);
|
||||
|
||||
return new ServiceAccountCountsResponseModel
|
||||
{
|
||||
Projects = serviceAccountCounts.Projects,
|
||||
People = serviceAccountCounts.People,
|
||||
AccessTokens = serviceAccountCounts.AccessTokens
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<(AccessClientType, Guid)> GetAccessClientAsync(Guid organizationId)
|
||||
{
|
||||
if (!_currentContext.AccessSecretsManager(organizationId))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var (accessType, userId) = await _accessClientQuery.GetAccessClientAsync(User, organizationId);
|
||||
if (accessType == AccessClientType.ServiceAccount)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
return (accessType, userId);
|
||||
}
|
||||
}
|
@ -0,0 +1,55 @@
|
||||
using Bit.Api.SecretsManager.Models.Request;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.SecretsManager.Commands.Requests.Interfaces;
|
||||
using Bit.Core.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Bit.Api.SecretsManager.Controllers;
|
||||
|
||||
[Route("request-access")]
|
||||
[Authorize("Web")]
|
||||
public class RequestSMAccessController : Controller
|
||||
{
|
||||
private readonly IRequestSMAccessCommand _requestSMAccessCommand;
|
||||
private readonly IUserService _userService;
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly IOrganizationUserRepository _organizationUserRepository;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
|
||||
public RequestSMAccessController(
|
||||
IRequestSMAccessCommand requestSMAccessCommand, IUserService userService, IOrganizationRepository organizationRepository, IOrganizationUserRepository organizationUserRepository, ICurrentContext currentContext)
|
||||
{
|
||||
_requestSMAccessCommand = requestSMAccessCommand;
|
||||
_userService = userService;
|
||||
_organizationRepository = organizationRepository;
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
_currentContext = currentContext;
|
||||
}
|
||||
|
||||
[HttpPost("request-sm-access")]
|
||||
public async Task RequestSMAccessFromAdmins([FromBody] RequestSMAccessRequestModel model)
|
||||
{
|
||||
var user = await _userService.GetUserByPrincipalAsync(User);
|
||||
if (user == null)
|
||||
{
|
||||
throw new UnauthorizedAccessException();
|
||||
}
|
||||
|
||||
if (!await _currentContext.OrganizationUser(model.OrganizationId))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var organization = await _organizationRepository.GetByIdAsync(model.OrganizationId);
|
||||
if (organization == null)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var orgUsers = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(organization.Id);
|
||||
await _requestSMAccessCommand.SendRequestAccessToSM(organization, orgUsers, user, model.EmailContent);
|
||||
}
|
||||
}
|
@ -260,25 +260,13 @@ public class SecretsController : Controller
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
// Ensure all secrets belong to the same organization.
|
||||
var organizationId = secrets.First().OrganizationId;
|
||||
if (secrets.Any(secret => secret.OrganizationId != organizationId) ||
|
||||
!_currentContext.AccessSecretsManager(organizationId))
|
||||
var authorizationResult = await _authorizationService.AuthorizeAsync(User, secrets, BulkSecretOperations.ReadAll);
|
||||
if (!authorizationResult.Succeeded)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
|
||||
foreach (var secret in secrets)
|
||||
{
|
||||
var authorizationResult = await _authorizationService.AuthorizeAsync(User, secret, SecretOperations.Read);
|
||||
if (!authorizationResult.Succeeded)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
}
|
||||
|
||||
await LogSecretsRetrievalAsync(organizationId, secrets);
|
||||
await LogSecretsRetrievalAsync(secrets.First().OrganizationId, secrets);
|
||||
|
||||
var responses = secrets.Select(s => new BaseSecretResponseModel(s));
|
||||
return new ListResponseModel<BaseSecretResponseModel>(responses);
|
||||
|
@ -0,0 +1,11 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Bit.Api.SecretsManager.Models.Request;
|
||||
|
||||
public class RequestSMAccessRequestModel
|
||||
{
|
||||
[Required]
|
||||
public Guid OrganizationId { get; set; }
|
||||
[Required(ErrorMessage = "Add a note is a required field")]
|
||||
public string EmailContent { get; set; }
|
||||
}
|
@ -0,0 +1,15 @@
|
||||
#nullable enable
|
||||
using Bit.Core.Models.Api;
|
||||
|
||||
namespace Bit.Api.SecretsManager.Models.Response;
|
||||
|
||||
public class OrganizationCountsResponseModel() : ResponseModel(_objectName)
|
||||
{
|
||||
private const string _objectName = "organizationCounts";
|
||||
|
||||
public int Projects { get; set; }
|
||||
|
||||
public int Secrets { get; set; }
|
||||
|
||||
public int ServiceAccounts { get; set; }
|
||||
}
|
@ -0,0 +1,15 @@
|
||||
#nullable enable
|
||||
using Bit.Core.Models.Api;
|
||||
|
||||
namespace Bit.Api.SecretsManager.Models.Response;
|
||||
|
||||
public class ProjectCountsResponseModel() : ResponseModel(_objectName)
|
||||
{
|
||||
private const string _objectName = "projectCounts";
|
||||
|
||||
public int Secrets { get; set; }
|
||||
|
||||
public int People { get; set; }
|
||||
|
||||
public int ServiceAccounts { get; set; }
|
||||
}
|
@ -0,0 +1,15 @@
|
||||
#nullable enable
|
||||
using Bit.Core.Models.Api;
|
||||
|
||||
namespace Bit.Api.SecretsManager.Models.Response;
|
||||
|
||||
public class ServiceAccountCountsResponseModel() : ResponseModel(_objectName)
|
||||
{
|
||||
private const string _objectName = "serviceAccountCounts";
|
||||
|
||||
public int Projects { get; set; }
|
||||
|
||||
public int People { get; set; }
|
||||
|
||||
public int AccessTokens { get; set; }
|
||||
}
|
@ -1,4 +1,6 @@
|
||||
using Bit.Api.Models.Public.Response;
|
||||
using System.Text;
|
||||
using Bit.Api.Models.Public.Response;
|
||||
using Bit.Core.Billing;
|
||||
using Bit.Core.Exceptions;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
@ -49,18 +51,18 @@ public class ExceptionHandlerFilterAttribute : ExceptionFilterAttribute
|
||||
errorMessage = badRequestException.Message;
|
||||
}
|
||||
}
|
||||
else if (exception is StripeException stripeException && stripeException?.StripeError?.Type == "card_error")
|
||||
else if (exception is StripeException { StripeError.Type: "card_error" } stripeCardErrorException)
|
||||
{
|
||||
context.HttpContext.Response.StatusCode = 400;
|
||||
if (_publicApi)
|
||||
{
|
||||
publicErrorModel = new ErrorResponseModel(stripeException.StripeError.Param,
|
||||
stripeException.Message);
|
||||
publicErrorModel = new ErrorResponseModel(stripeCardErrorException.StripeError.Param,
|
||||
stripeCardErrorException.Message);
|
||||
}
|
||||
else
|
||||
{
|
||||
internalErrorModel = new InternalApi.ErrorResponseModel(stripeException.StripeError.Param,
|
||||
stripeException.Message);
|
||||
internalErrorModel = new InternalApi.ErrorResponseModel(stripeCardErrorException.StripeError.Param,
|
||||
stripeCardErrorException.Message);
|
||||
}
|
||||
}
|
||||
else if (exception is GatewayException)
|
||||
@ -68,6 +70,40 @@ public class ExceptionHandlerFilterAttribute : ExceptionFilterAttribute
|
||||
errorMessage = exception.Message;
|
||||
context.HttpContext.Response.StatusCode = 400;
|
||||
}
|
||||
else if (exception is BillingException billingException)
|
||||
{
|
||||
errorMessage = billingException.Response;
|
||||
context.HttpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
|
||||
}
|
||||
else if (exception is StripeException stripeException)
|
||||
{
|
||||
var logger = context.HttpContext.RequestServices.GetRequiredService<ILogger<ExceptionHandlerFilterAttribute>>();
|
||||
|
||||
var error = stripeException.Message;
|
||||
|
||||
if (stripeException.StripeError != null)
|
||||
{
|
||||
var stringBuilder = new StringBuilder();
|
||||
|
||||
if (!string.IsNullOrEmpty(stripeException.StripeError.Code))
|
||||
{
|
||||
stringBuilder.Append($"{stripeException.StripeError.Code} | ");
|
||||
}
|
||||
|
||||
stringBuilder.Append(stripeException.StripeError.Message);
|
||||
|
||||
if (!string.IsNullOrEmpty(stripeException.StripeError.DocUrl))
|
||||
{
|
||||
stringBuilder.Append($" > {stripeException.StripeError.DocUrl}");
|
||||
}
|
||||
|
||||
error = stringBuilder.ToString();
|
||||
}
|
||||
|
||||
logger.LogError("An unhandled error occurred while communicating with Stripe: {Error}", error);
|
||||
errorMessage = "Something went wrong with your request. Please contact support.";
|
||||
context.HttpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
|
||||
}
|
||||
else if (exception is NotSupportedException && !string.IsNullOrWhiteSpace(exception.Message))
|
||||
{
|
||||
errorMessage = exception.Message;
|
||||
|
@ -1,5 +1,4 @@
|
||||
#nullable enable
|
||||
using Bit.Core;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
@ -191,11 +190,8 @@ public class BulkCollectionAuthorizationHandler : BulkAuthorizationHandler<BulkC
|
||||
return true;
|
||||
}
|
||||
|
||||
// If V1 is enabled, Owners and Admins can update any collection only if permitted by collection management settings
|
||||
var organizationAbility = await GetOrganizationAbilityAsync(org);
|
||||
var allowAdminAccessToAllCollectionItems = !_featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1) ||
|
||||
organizationAbility is { AllowAdminAccessToAllCollectionItems: true };
|
||||
if (allowAdminAccessToAllCollectionItems && org is { Type: OrganizationUserType.Owner or OrganizationUserType.Admin })
|
||||
// Owners and Admins can update any collection only if permitted by collection management settings
|
||||
if (await AllowAdminAccessToAllCollectionItems(org) && org is { Type: OrganizationUserType.Owner or OrganizationUserType.Admin })
|
||||
{
|
||||
return true;
|
||||
}
|
||||
@ -244,10 +240,7 @@ public class BulkCollectionAuthorizationHandler : BulkAuthorizationHandler<BulkC
|
||||
}
|
||||
|
||||
// If AllowAdminAccessToAllCollectionItems is true, Owners and Admins can delete any collection, regardless of LimitCollectionCreationDeletion setting
|
||||
var organizationAbility = await GetOrganizationAbilityAsync(org);
|
||||
var allowAdminAccessToAllCollectionItems = !_featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1) ||
|
||||
organizationAbility is { AllowAdminAccessToAllCollectionItems: true };
|
||||
if (allowAdminAccessToAllCollectionItems && org is { Type: OrganizationUserType.Owner or OrganizationUserType.Admin })
|
||||
if (await AllowAdminAccessToAllCollectionItems(org) && org is { Type: OrganizationUserType.Owner or OrganizationUserType.Admin })
|
||||
{
|
||||
return true;
|
||||
}
|
||||
@ -255,6 +248,7 @@ public class BulkCollectionAuthorizationHandler : BulkAuthorizationHandler<BulkC
|
||||
// If LimitCollectionCreationDeletion is false, AllowAdminAccessToAllCollectionItems setting is irrelevant.
|
||||
// Ensure acting user has manage permissions for all collections being deleted
|
||||
// If LimitCollectionCreationDeletion is true, only Owners and Admins can delete collections they manage
|
||||
var organizationAbility = await GetOrganizationAbilityAsync(org);
|
||||
var canDeleteManagedCollections = organizationAbility is { LimitCollectionCreationDeletion: false } ||
|
||||
org is { Type: OrganizationUserType.Owner or OrganizationUserType.Admin };
|
||||
if (canDeleteManagedCollections && await CanManageCollectionsAsync(resources, org))
|
||||
@ -272,7 +266,7 @@ public class BulkCollectionAuthorizationHandler : BulkAuthorizationHandler<BulkC
|
||||
if (_managedCollectionsIds == null)
|
||||
{
|
||||
var allUserCollections = await _collectionRepository
|
||||
.GetManyByUserIdAsync(_currentContext.UserId!.Value, useFlexibleCollections: true);
|
||||
.GetManyByUserIdAsync(_currentContext.UserId!.Value);
|
||||
|
||||
var managedCollectionIds = allUserCollections
|
||||
.Where(c => c.Manage)
|
||||
@ -326,8 +320,6 @@ public class BulkCollectionAuthorizationHandler : BulkAuthorizationHandler<BulkC
|
||||
|
||||
private async Task<bool> AllowAdminAccessToAllCollectionItems(CurrentContextOrganization? org)
|
||||
{
|
||||
var organizationAbility = await GetOrganizationAbilityAsync(org);
|
||||
return !_featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1) ||
|
||||
organizationAbility is { AllowAdminAccessToAllCollectionItems: true };
|
||||
return await GetOrganizationAbilityAsync(org) is { AllowAdminAccessToAllCollectionItems: true };
|
||||
}
|
||||
}
|
||||
|
@ -41,15 +41,11 @@ public class CiphersController : Controller
|
||||
private readonly ICurrentContext _currentContext;
|
||||
private readonly ILogger<CiphersController> _logger;
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
private readonly Version _cipherKeyEncryptionMinimumVersion = new Version(Constants.CipherKeyEncryptionMinimumVersion);
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IOrganizationCiphersQuery _organizationCiphersQuery;
|
||||
private readonly IApplicationCacheService _applicationCacheService;
|
||||
private readonly ICollectionRepository _collectionRepository;
|
||||
|
||||
private bool UseFlexibleCollections =>
|
||||
_featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections);
|
||||
|
||||
public CiphersController(
|
||||
ICipherRepository cipherRepository,
|
||||
ICollectionCipherRepository collectionCipherRepository,
|
||||
@ -127,7 +123,7 @@ public class CiphersController : Controller
|
||||
var userId = _userService.GetProperUserId(User).Value;
|
||||
var hasOrgs = _currentContext.Organizations?.Any() ?? false;
|
||||
// TODO: Use hasOrgs proper for cipher listing here?
|
||||
var ciphers = await _cipherRepository.GetManyByUserIdAsync(userId, useFlexibleCollections: UseFlexibleCollections, withOrganizations: true || hasOrgs);
|
||||
var ciphers = await _cipherRepository.GetManyByUserIdAsync(userId, withOrganizations: true || hasOrgs);
|
||||
Dictionary<Guid, IGrouping<Guid, CollectionCipher>> collectionCiphersGroupDict = null;
|
||||
if (hasOrgs)
|
||||
{
|
||||
@ -199,7 +195,6 @@ public class CiphersController : Controller
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
ValidateClientVersionForItemLevelEncryptionSupport(cipher);
|
||||
ValidateClientVersionForFido2CredentialSupport(cipher);
|
||||
|
||||
var collectionIds = (await _collectionCipherRepository.GetManyByUserIdCipherIdAsync(userId, id)).Select(c => c.CollectionId).ToList();
|
||||
@ -224,7 +219,6 @@ public class CiphersController : Controller
|
||||
var userId = _userService.GetProperUserId(User).Value;
|
||||
var cipher = await _cipherRepository.GetOrganizationDetailsByIdAsync(id);
|
||||
|
||||
ValidateClientVersionForItemLevelEncryptionSupport(cipher);
|
||||
ValidateClientVersionForFido2CredentialSupport(cipher);
|
||||
|
||||
if (cipher == null || !cipher.OrganizationId.HasValue ||
|
||||
@ -245,37 +239,24 @@ public class CiphersController : Controller
|
||||
[HttpGet("organization-details")]
|
||||
public async Task<ListResponseModel<CipherMiniDetailsResponseModel>> GetOrganizationCiphers(Guid organizationId)
|
||||
{
|
||||
// Flexible Collections V1 Logic
|
||||
if (UseFlexibleCollectionsV1())
|
||||
if (!await CanAccessAllCiphersAsync(organizationId))
|
||||
{
|
||||
return await GetAllOrganizationCiphersAsync(organizationId);
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
// Pre-Flexible Collections V1 Logic
|
||||
var userId = _userService.GetProperUserId(User).Value;
|
||||
var allOrganizationCiphers = await _organizationCiphersQuery.GetAllOrganizationCiphers(organizationId);
|
||||
|
||||
(IEnumerable<CipherOrganizationDetails> orgCiphers, Dictionary<Guid, IGrouping<Guid, CollectionCipher>> collectionCiphersGroupDict) = await _cipherService.GetOrganizationCiphers(userId, organizationId);
|
||||
var allOrganizationCipherResponses =
|
||||
allOrganizationCiphers.Select(c =>
|
||||
new CipherMiniDetailsResponseModel(c, _globalSettings, c.OrganizationUseTotp)
|
||||
);
|
||||
|
||||
var responses = orgCiphers.Select(c => new CipherMiniDetailsResponseModel(c, _globalSettings,
|
||||
collectionCiphersGroupDict, c.OrganizationUseTotp));
|
||||
|
||||
var providerId = await _currentContext.ProviderIdForOrg(organizationId);
|
||||
if (providerId.HasValue)
|
||||
{
|
||||
await _providerService.LogProviderAccessToOrganizationAsync(organizationId);
|
||||
}
|
||||
|
||||
return new ListResponseModel<CipherMiniDetailsResponseModel>(responses);
|
||||
return new ListResponseModel<CipherMiniDetailsResponseModel>(allOrganizationCipherResponses);
|
||||
}
|
||||
|
||||
[HttpGet("organization-details/assigned")]
|
||||
public async Task<ListResponseModel<CipherDetailsResponseModel>> GetAssignedOrganizationCiphers(Guid organizationId)
|
||||
{
|
||||
if (!UseFlexibleCollectionsV1())
|
||||
{
|
||||
throw new FeatureUnavailableException();
|
||||
}
|
||||
|
||||
if (!await CanAccessOrganizationCiphersAsync(organizationId) || !_currentContext.UserId.HasValue)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
@ -299,27 +280,6 @@ public class CiphersController : Controller
|
||||
return new ListResponseModel<CipherDetailsResponseModel>(responses);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns all ciphers belonging to the organization if the user has access to All ciphers.
|
||||
/// </summary>
|
||||
/// <exception cref="NotFoundException"></exception>
|
||||
private async Task<ListResponseModel<CipherMiniDetailsResponseModel>> GetAllOrganizationCiphersAsync(Guid organizationId)
|
||||
{
|
||||
if (!await CanAccessAllCiphersAsync(organizationId))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var allOrganizationCiphers = await _organizationCiphersQuery.GetAllOrganizationCiphers(organizationId);
|
||||
|
||||
var allOrganizationCipherResponses =
|
||||
allOrganizationCiphers.Select(c =>
|
||||
new CipherMiniDetailsResponseModel(c, _globalSettings, c.OrganizationUseTotp)
|
||||
);
|
||||
|
||||
return new ListResponseModel<CipherMiniDetailsResponseModel>(allOrganizationCipherResponses);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Permission helper to determine if the current user can use the "/admin" variants of the cipher endpoints.
|
||||
/// Allowed for custom users with EditAnyCollection, providers, unrestricted owners and admins (allowAdminAccess setting is ON).
|
||||
@ -328,12 +288,6 @@ public class CiphersController : Controller
|
||||
/// </summary>
|
||||
private async Task<bool> CanEditCipherAsAdminAsync(Guid organizationId, IEnumerable<Guid> cipherIds)
|
||||
{
|
||||
// Pre-Flexible collections V1 only needs to check EditAnyCollection
|
||||
if (!UseFlexibleCollectionsV1())
|
||||
{
|
||||
return await _currentContext.EditAnyCollection(organizationId);
|
||||
}
|
||||
|
||||
var org = _currentContext.GetOrganization(organizationId);
|
||||
|
||||
// If we're not an "admin", we don't need to check the ciphers
|
||||
@ -396,14 +350,6 @@ public class CiphersController : Controller
|
||||
{
|
||||
var org = _currentContext.GetOrganization(organizationId);
|
||||
|
||||
// If not using V1, owners, admins, and users with EditAnyCollection permissions, and providers can always edit all ciphers
|
||||
if (!UseFlexibleCollectionsV1())
|
||||
{
|
||||
return org is { Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or
|
||||
{ Permissions.EditAnyCollection: true } ||
|
||||
await _currentContext.ProviderUserForOrgAsync(organizationId);
|
||||
}
|
||||
|
||||
// Custom users with EditAnyCollection permissions can always edit all ciphers
|
||||
if (org is { Type: OrganizationUserType.Custom, Permissions.EditAnyCollection: true })
|
||||
{
|
||||
@ -553,7 +499,7 @@ public class CiphersController : Controller
|
||||
}
|
||||
|
||||
var userId = _userService.GetProperUserId(User).Value;
|
||||
var editableCollections = (await _collectionRepository.GetManyByUserIdAsync(userId, true))
|
||||
var editableCollections = (await _collectionRepository.GetManyByUserIdAsync(userId))
|
||||
.Where(c => c.OrganizationId == organizationId && !c.ReadOnly)
|
||||
.ToDictionary(c => c.Id);
|
||||
|
||||
@ -590,7 +536,6 @@ public class CiphersController : Controller
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
ValidateClientVersionForItemLevelEncryptionSupport(cipher);
|
||||
ValidateClientVersionForFido2CredentialSupport(cipher);
|
||||
|
||||
var original = cipher.Clone();
|
||||
@ -669,7 +614,7 @@ public class CiphersController : Controller
|
||||
|
||||
// In V1, we still need to check if the user can edit the collections they're submitting
|
||||
// This should only happen for unassigned ciphers (otherwise restricted admins would use the normal collections endpoint)
|
||||
if (UseFlexibleCollectionsV1() && !await CanEditItemsInCollections(cipher.OrganizationId.Value, collectionIds))
|
||||
if (!await CanEditItemsInCollections(cipher.OrganizationId.Value, collectionIds))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
@ -926,7 +871,7 @@ public class CiphersController : Controller
|
||||
}
|
||||
|
||||
var userId = _userService.GetProperUserId(User).Value;
|
||||
var ciphers = await _cipherRepository.GetManyByUserIdAsync(userId, useFlexibleCollections: UseFlexibleCollections, withOrganizations: false);
|
||||
var ciphers = await _cipherRepository.GetManyByUserIdAsync(userId, withOrganizations: false);
|
||||
var ciphersDict = ciphers.ToDictionary(c => c.Id);
|
||||
|
||||
var shareCiphers = new List<(Cipher, DateTime?)>();
|
||||
@ -939,7 +884,6 @@ public class CiphersController : Controller
|
||||
|
||||
var existingCipher = ciphersDict[cipher.Id.Value];
|
||||
|
||||
ValidateClientVersionForItemLevelEncryptionSupport(existingCipher);
|
||||
ValidateClientVersionForFido2CredentialSupport(existingCipher);
|
||||
|
||||
shareCiphers.Add((cipher.ToCipher(existingCipher), cipher.LastKnownRevisionDate));
|
||||
@ -994,8 +938,6 @@ public class CiphersController : Controller
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
ValidateClientVersionForItemLevelEncryptionSupport(cipher);
|
||||
|
||||
if (request.FileSize > CipherService.MAX_FILE_SIZE)
|
||||
{
|
||||
throw new BadRequestException($"Max file size is {CipherService.MAX_FILE_SIZE_READABLE}.");
|
||||
@ -1205,32 +1147,6 @@ public class CiphersController : Controller
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the user is an admin or owner of an organization with unassigned ciphers (i.e. ciphers that
|
||||
/// are not assigned to a collection).
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[HttpGet("has-unassigned-ciphers")]
|
||||
public async Task<bool> HasUnassignedCiphers()
|
||||
{
|
||||
// We don't filter for organization.FlexibleCollections here, it's shown for all orgs, and the client determines
|
||||
// whether the message is shown in future tense (not yet migrated) or present tense (already migrated)
|
||||
var adminOrganizations = _currentContext.Organizations
|
||||
.Where(o => o.Type is OrganizationUserType.Admin or OrganizationUserType.Owner);
|
||||
|
||||
foreach (var org in adminOrganizations)
|
||||
{
|
||||
var unassignedCiphers = await _cipherRepository.GetManyUnassignedOrganizationDetailsByOrganizationIdAsync(org.Id);
|
||||
// We only care about non-deleted ciphers
|
||||
if (unassignedCiphers.Any(c => c.DeletedDate == null))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void ValidateAttachment()
|
||||
{
|
||||
if (!Request?.ContentType.Contains("multipart/") ?? true)
|
||||
@ -1239,14 +1155,6 @@ public class CiphersController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateClientVersionForItemLevelEncryptionSupport(Cipher cipher)
|
||||
{
|
||||
if (cipher.Key != null && _currentContext.ClientVersion < _cipherKeyEncryptionMinimumVersion)
|
||||
{
|
||||
throw new BadRequestException("Cannot edit item. Update to the latest version of Bitwarden and try again.");
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateClientVersionForFido2CredentialSupport(Cipher cipher)
|
||||
{
|
||||
if (cipher.Type == Core.Vault.Enums.CipherType.Login)
|
||||
@ -1263,9 +1171,4 @@ public class CiphersController : Controller
|
||||
{
|
||||
return await _cipherRepository.GetByIdAsync(cipherId, userId);
|
||||
}
|
||||
|
||||
private bool UseFlexibleCollectionsV1()
|
||||
{
|
||||
return _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1);
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,4 @@
|
||||
using Bit.Api.Vault.Models.Response;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
@ -31,10 +30,6 @@ public class SyncController : Controller
|
||||
private readonly IPolicyRepository _policyRepository;
|
||||
private readonly ISendRepository _sendRepository;
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
private readonly IFeatureService _featureService;
|
||||
|
||||
private bool UseFlexibleCollections =>
|
||||
_featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections);
|
||||
|
||||
public SyncController(
|
||||
IUserService userService,
|
||||
@ -46,8 +41,7 @@ public class SyncController : Controller
|
||||
IProviderUserRepository providerUserRepository,
|
||||
IPolicyRepository policyRepository,
|
||||
ISendRepository sendRepository,
|
||||
GlobalSettings globalSettings,
|
||||
IFeatureService featureService)
|
||||
GlobalSettings globalSettings)
|
||||
{
|
||||
_userService = userService;
|
||||
_folderRepository = folderRepository;
|
||||
@ -59,7 +53,6 @@ public class SyncController : Controller
|
||||
_policyRepository = policyRepository;
|
||||
_sendRepository = sendRepository;
|
||||
_globalSettings = globalSettings;
|
||||
_featureService = featureService;
|
||||
}
|
||||
|
||||
[HttpGet("")]
|
||||
@ -81,7 +74,7 @@ public class SyncController : Controller
|
||||
var hasEnabledOrgs = organizationUserDetails.Any(o => o.Enabled);
|
||||
|
||||
var folders = await _folderRepository.GetManyByUserIdAsync(user.Id);
|
||||
var ciphers = await _cipherRepository.GetManyByUserIdAsync(user.Id, useFlexibleCollections: UseFlexibleCollections, withOrganizations: hasEnabledOrgs);
|
||||
var ciphers = await _cipherRepository.GetManyByUserIdAsync(user.Id, withOrganizations: hasEnabledOrgs);
|
||||
var sends = await _sendRepository.GetManyByUserIdAsync(user.Id);
|
||||
|
||||
IEnumerable<CollectionDetails> collections = null;
|
||||
@ -90,7 +83,7 @@ public class SyncController : Controller
|
||||
|
||||
if (hasEnabledOrgs)
|
||||
{
|
||||
collections = await _collectionRepository.GetManyByUserIdAsync(user.Id, UseFlexibleCollections);
|
||||
collections = await _collectionRepository.GetManyByUserIdAsync(user.Id);
|
||||
var collectionCiphers = await _collectionCipherRepository.GetManyByUserIdAsync(user.Id);
|
||||
collectionCiphersGroupDict = collectionCiphers.GroupBy(c => c.CipherId).ToDictionary(s => s.Key);
|
||||
}
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user