1
0
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:
Nicolai Søborg 2024-08-10 11:10:46 +02:00 committed by GitHub
commit b50ad7cc68
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
433 changed files with 30129 additions and 16142 deletions

View File

@ -7,7 +7,7 @@
"commands": ["swagger"]
},
"dotnet-ef": {
"version": "8.0.6",
"version": "8.0.7",
"commands": ["dotnet-ef"]
}
}

View File

@ -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": {},

View File

@ -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": {},

View File

@ -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"
],

View File

@ -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:

View File

@ -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 }}

View File

@ -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 }}

View File

@ -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 }}

View File

@ -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

View File

@ -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 }}

View File

@ -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

View File

@ -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,

View File

@ -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 }}"

View File

@ -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

View File

@ -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

View File

@ -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 }}

View File

@ -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
View File

@ -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
View File

@ -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": []

View File

@ -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>

View File

@ -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,

View File

@ -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(

View File

@ -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);
}
}
}

View File

@ -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);
}
}

View File

@ -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>();

View File

@ -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,

View File

@ -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

View File

@ -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();

View File

@ -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>()
};

View File

@ -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>

View File

@ -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;

File diff suppressed because it is too large Load Diff

View File

@ -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"
}
}

View 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",
}),
],
};

View File

@ -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.

View File

@ -0,0 +1,5 @@
<Project Sdk="Microsoft.Build.Traversal">
<ItemGroup>
<ProjectReference Include="**\*.*proj" />
</ItemGroup>
</Project>

View File

@ -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 &&

View File

@ -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

View File

@ -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();
}
}

View File

@ -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>());
}
}

View File

@ -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);

View File

@ -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

View File

@ -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

View File

@ -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) {

View File

@ -2,5 +2,8 @@
"sdk": {
"version": "8.0.100",
"rollForward": "latestFeature"
},
"msbuild-sdks": {
"Microsoft.Build.Traversal": "4.1.0"
}
}

View File

@ -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);

View File

@ -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)

View File

@ -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";
}
}

View File

@ -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>

View File

@ -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 {

View File

@ -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));

View File

@ -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;

View File

@ -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 @@
&copy; @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)
{

View File

@ -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>

View File

@ -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>

View File

@ -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;

File diff suppressed because it is too large Load Diff

View File

@ -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"
}
}

View 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",
}),
],
};

View File

@ -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

View File

@ -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)
{

View File

@ -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);
}
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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; }

View File

@ -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

View File

@ -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; }
}

View File

@ -46,6 +46,5 @@ public class ProfileProviderOrganizationResponseModel : ProfileOrganizationRespo
ProductTierType = StaticStore.GetPlan(organization.PlanType).ProductTier;
LimitCollectionCreationDeletion = organization.LimitCollectionCreationDeletion;
AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems;
FlexibleCollections = organization.FlexibleCollections;
}
}

View File

@ -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);
}
}

View File

@ -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.

View File

@ -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>

View File

@ -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()
{

View File

@ -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);
}

View File

@ -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; }
}

View File

@ -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; }
}

View File

@ -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; }
}

View File

@ -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));
}
}

View File

@ -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."));
}
}

View File

@ -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)

View File

@ -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();
}

View File

@ -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,

View File

@ -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(

View File

@ -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);

View File

@ -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,

View File

@ -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(

View File

@ -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);

View File

@ -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,

View File

@ -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);
}

View File

@ -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);
}
}

View 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);
}
}

View File

@ -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);
}
}

View File

@ -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);

View File

@ -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; }
}

View File

@ -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; }
}

View File

@ -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; }
}

View File

@ -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; }
}

View File

@ -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;

View File

@ -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 };
}
}

View File

@ -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);
}
}

View File

@ -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