1
0
mirror of https://github.com/bitwarden/server.git synced 2024-11-24 12:35:25 +01:00

Merge branch 'main' into auth/pm-6631/handle-webauthn-creation-exception

This commit is contained in:
Todd Martin 2024-08-31 17:44:51 -04:00
commit dd73045ee4
No known key found for this signature in database
GPG Key ID: 663E7AF5C839BC8F
981 changed files with 137211 additions and 28243 deletions

View File

@ -3,11 +3,11 @@
"isRoot": true,
"tools": {
"swashbuckle.aspnetcore.cli": {
"version": "6.5.0",
"version": "6.7.3",
"commands": ["swagger"]
},
"dotnet-ef": {
"version": "8.0.2",
"version": "8.0.8",
"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": {},

2
.github/CODEOWNERS vendored
View File

@ -38,6 +38,8 @@ src/Identity @bitwarden/team-auth-dev
bitwarden_license/src/Scim @bitwarden/team-admin-console-dev
bitwarden_license/src/test/Scim.IntegrationTest @bitwarden/team-admin-console-dev
bitwarden_license/src/test/Scim.ScimTest @bitwarden/team-admin-console-dev
src/Events @bitwarden/team-admin-console-dev
src/EventsProcessor @bitwarden/team-admin-console-dev
# Billing team
**/*billing* @bitwarden/team-billing-dev

View File

@ -1,30 +1,35 @@
## Type of change
## 🎟️ Tracking
<!-- (mark with an `X`) -->
<!-- Paste the link to the Jira or GitHub issue or otherwise describe / point to where this change is coming from. -->
```
- [ ] Bug fix
- [ ] New feature development
- [ ] Tech debt (refactoring, code cleanup, dependency upgrades, etc)
- [ ] Build/deploy pipeline (DevOps)
- [ ] Other
```
## 📔 Objective
## Objective
<!--Describe what the purpose of this PR is. For example: what bug you're fixing or what new feature you're adding-->
<!-- Describe what the purpose of this PR is, for example what bug you're fixing or new feature you're adding. -->
## 📸 Screenshots
<!-- Required for any UI changes; delete if not applicable. Use fixed width images for better display. -->
## Code changes
<!--Explain the changes you've made to each file or major component. This should help the reviewer understand your changes-->
<!--Also refer to any related changes or PRs in other repositories-->
## ⏰ Reminders before review
* **file.ext:** Description of what was changed and why
- Contributor guidelines followed
- All formatters and local linters executed and passed
- Written new unit and / or integration tests where applicable
- Protected functional changes with optionality (feature flags)
- Used internationalization (i18n) for all UI strings
- CI builds passed
- Communicated to DevOps any deployment requirements
- Updated any necessary documentation (Confluence, contributing docs) or informed the documentation team
## Before you submit
## 🦮 Reviewer guidelines
- Please check for formatting errors (`dotnet format --verify-no-changes`) (required)
- If making database changes - make sure you also update Entity Framework queries and/or migrations
- Please add **unit tests** where it makes sense to do so (encouraged but not required)
- If this change requires a **documentation update** - notify the documentation team
- If this change has particular **deployment requirements** - notify the DevOps team
<!-- Suggested interactions but feel free to use (or not) as you desire! -->
- 👍 (`:+1:`) or similar for great changes
- 📝 (`:memo:`) or (`:information_source:`) for notes or general info
- ❓ (`:question:`) for questions
- 🤔 (`:thinking:`) or 💭 (`:thought_balloon:`) for more open inquiry that's not quite a confirmed issue and could potentially benefit from discussion
- 🎨 (`:art:`) for suggestions / improvements
- ❌ (`:x:`) or ⚠️ (`:warning:`) for more significant problems or concerns needing attention
- 🌱 (`:seedling:`) or ♻️ (`:recycle:`) for future improvements or indications of technical debt
- ⛏ (`:pick:`) for minor or nitpick changes

11
.github/renovate.json vendored
View File

@ -40,12 +40,6 @@
"commitMessagePrefix": "[deps] Auth:",
"reviewers": ["team:team-auth-dev"]
},
{
"matchPackageNames": ["bootstrap", "del", "gulp"],
"matchUpdateTypes": ["major"],
"description": "Lock bootstrap, del, and gulp major versions due to ASP.NET conflicts",
"enabled": false
},
{
"matchPackageNames": [
"AspNetCoreRateLimit",
@ -59,8 +53,6 @@
"DuoUniversal",
"Fido2.AspNet",
"Duende.IdentityServer",
"Microsoft.Azure.Cosmos",
"Microsoft.Extensions.Caching.StackExchangeRedis",
"Microsoft.Extensions.Identity.Stores",
"Otp.NET",
"Sustainsys.Saml2.AspNetCore2",
@ -112,12 +104,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

@ -14,7 +14,7 @@ jobs:
# Feature request
- if: github.event.label.name == 'feature-request'
name: Feature request
uses: peter-evans/close-issue@1373cadf1f0c96c1420bc000cfba2273ea307fd1 # v2.2.0
uses: peter-evans/close-issue@276d7966e389d888f011539a86c8920025ea0626 # v3.0.1
with:
comment: |
We use GitHub issues as a place to track bugs and other development related issues. The [Bitwarden Community Forums](https://community.bitwarden.com/) has a [Feature Requests](https://community.bitwarden.com/c/feature-requests) section for submitting, voting for, and discussing requests like this one.
@ -25,7 +25,7 @@ jobs:
# Intended behavior
- if: github.event.label.name == 'intended-behavior'
name: Intended behavior
uses: peter-evans/close-issue@1373cadf1f0c96c1420bc000cfba2273ea307fd1 # v2.2.0
uses: peter-evans/close-issue@276d7966e389d888f011539a86c8920025ea0626 # v3.0.1
with:
comment: |
Your issue appears to be describing the intended behavior of the software. If you want this to be changed, it would be a feature request.
@ -38,7 +38,7 @@ jobs:
# Customer support request
- if: github.event.label.name == 'customer-support'
name: Customer Support request
uses: peter-evans/close-issue@1373cadf1f0c96c1420bc000cfba2273ea307fd1 # v2.2.0
uses: peter-evans/close-issue@276d7966e389d888f011539a86c8920025ea0626 # v3.0.1
with:
comment: |
We use GitHub issues as a place to track bugs and other development related issues. Your issue appears to be a support request, or would otherwise be better handled by our dedicated Customer Success team.
@ -49,14 +49,14 @@ jobs:
# Resolved
- if: github.event.label.name == 'resolved'
name: Resolved
uses: peter-evans/close-issue@1373cadf1f0c96c1420bc000cfba2273ea307fd1 # v2.2.0
uses: peter-evans/close-issue@276d7966e389d888f011539a86c8920025ea0626 # v3.0.1
with:
comment: |
Weve closed this issue, as it appears the original problem has been resolved. If this happens again or continues to be an problem, please respond to this issue with any additional detail to assist with reproduction and root cause analysis.
# Stale
- if: github.event.label.name == 'stale'
name: Stale
uses: peter-evans/close-issue@1373cadf1f0c96c1420bc000cfba2273ea307fd1 # v2.2.0
uses: peter-evans/close-issue@276d7966e389d888f011539a86c8920025ea0626 # v3.0.1
with:
comment: |
As we havent heard from you about this problem in some time, this issue will now be closed.

View File

@ -19,10 +19,10 @@ 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
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
- name: Verify format
run: dotnet format --verify-no-changes
@ -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
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
- name: Set up Node
uses: actions/setup-node@5e21ff4d9bc1a8cf6de233a3057d20ec6b3fb69d # v3.8.1
uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3
with:
cache: "npm"
cache-dependency-path: "**/package-lock.json"
@ -110,7 +110,7 @@ jobs:
ls -atlh ../../../
- name: Upload project artifact
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
with:
name: ${{ matrix.project_name }}.zip
path: ${{ matrix.base_path }}/${{ matrix.project_name }}/${{ matrix.project_name }}.zip
@ -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 }}
@ -251,7 +251,7 @@ jobs:
- name: Get build artifact
if: ${{ matrix.dotnet }}
uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3.0.2
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
with:
name: ${{ matrix.project_name }}.zip
@ -263,7 +263,7 @@ jobs:
-d ${{ matrix.base_path }}/${{ matrix.project_name }}/obj/build-output/publish
- name: Build Docker image
uses: docker/build-push-action@1104d471370f9806843c095c1db02b5a90c5f8b6 # v3.3.1
uses: docker/build-push-action@5cd11c3a4ced054e52742c5fd54dca954e0edd85 # v6.7.0
with:
context: ${{ matrix.base_path }}/${{ matrix.project_name }}
file: ${{ matrix.base_path }}/${{ matrix.project_name }}/Dockerfile
@ -275,14 +275,14 @@ jobs:
- name: Scan Docker image
id: container-scan
uses: anchore/scan-action@3343887d815d7b07465f6fdcd395bd66508d486a # v3.6.4
uses: anchore/scan-action@64a33b277ea7a1215a3c142735a1091341939ff5 # v4.1.2
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@2c779ab0d087cd7fe7b826087247c2c81f27bfa6 # v3.26.5
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
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
- 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 }}
@ -355,7 +355,7 @@ jobs:
- name: Upload Docker stub US artifact
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc'
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
with:
name: docker-stub-US.zip
path: docker-stub-US.zip
@ -363,7 +363,7 @@ jobs:
- name: Upload Docker stub EU artifact
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc'
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
with:
name: docker-stub-EU.zip
path: docker-stub-EU.zip
@ -371,7 +371,7 @@ jobs:
- name: Upload Docker stub US checksum artifact
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc'
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
with:
name: docker-stub-US-sha256.txt
path: docker-stub-US-sha256.txt
@ -379,13 +379,13 @@ jobs:
- name: Upload Docker stub EU checksum artifact
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc'
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
with:
name: docker-stub-EU-sha256.txt
path: docker-stub-EU-sha256.txt
if-no-files-found: error
- name: Build Swagger
- name: Build Public API Swagger
run: |
cd ./src/Api
echo "Restore tools"
@ -402,12 +402,53 @@ jobs:
DOTNET_ROLL_FORWARD_ON_NO_CANDIDATE_FX: 2
GLOBALSETTINGS__SQLSERVER__CONNECTIONSTRING: "placeholder"
- name: Upload Swagger artifact
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3
- name: Upload Public API Swagger artifact
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
with:
name: swagger.json
path: swagger.json
if-no-files-found: error
- name: Build Internal API Swagger
run: |
cd ./src/Api
echo "Restore API tools"
dotnet tool restore
echo "Publish API"
dotnet publish -c "Release" -o obj/build-output/publish
dotnet swagger tofile --output ../../internal.json --host https://api.bitwarden.com \
./obj/build-output/publish/Api.dll internal
cd ../Identity
echo "Restore Identity tools"
dotnet tool restore
echo "Publish Identity"
dotnet publish -c "Release" -o obj/build-output/publish
dotnet swagger tofile --output ../../identity.json --host https://identity.bitwarden.com \
./obj/build-output/publish/Identity.dll v1
cd ../..
env:
ASPNETCORE_ENVIRONMENT: Development
swaggerGen: "True"
DOTNET_ROLL_FORWARD_ON_NO_CANDIDATE_FX: 2
GLOBALSETTINGS__SQLSERVER__CONNECTIONSTRING: "placeholder"
- name: Upload Internal API Swagger artifact
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
with:
name: internal.json
path: internal.json
if-no-files-found: error
- name: Upload Identity Swagger artifact
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
with:
name: identity.json
path: identity.json
if-no-files-found: error
build-mssqlmigratorutility:
name: Build MSSQL migrator utility
@ -426,10 +467,10 @@ 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
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
- name: Print environment
run: |
@ -445,7 +486,7 @@ jobs:
- name: Upload project artifact for Windows
if: ${{ contains(matrix.target, 'win') == true }}
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
with:
name: MsSqlMigratorUtility-${{ matrix.target }}
path: util/MsSqlMigratorUtility/obj/build-output/publish/MsSqlMigratorUtility.exe
@ -453,7 +494,7 @@ jobs:
- name: Upload project artifact
if: ${{ contains(matrix.target, 'win') == false }}
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
with:
name: MsSqlMigratorUtility-${{ matrix.target }}
path: util/MsSqlMigratorUtility/obj/build-output/publish/MsSqlMigratorUtility
@ -465,7 +506,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 }}
@ -477,7 +518,7 @@ jobs:
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
- name: Trigger self-host build
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6.4.1
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
script: |
@ -498,7 +539,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 }}
@ -510,7 +551,7 @@ jobs:
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
- name: Trigger k8s deploy
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6.4.1
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
script: |
@ -547,7 +588,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 +602,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

@ -1,26 +1,43 @@
---
name: Collect code references
on:
pull_request:
branches-ignore:
- "renovate/**"
permissions:
contents: read
pull-requests: write
jobs:
check-ld-secret:
name: Check for LD secret
runs-on: ubuntu-22.04
outputs:
available: ${{ steps.check-ld-secret.outputs.available }}
permissions:
contents: read
steps:
- name: Check
id: check-ld-secret
run: |
if [ "${{ secrets.LD_ACCESS_TOKEN }}" != '' ]; then
echo "available=true" >> $GITHUB_OUTPUT;
else
echo "available=false" >> $GITHUB_OUTPUT;
fi
refs:
name: Code reference collection
runs-on: ubuntu-22.04
needs: check-ld-secret
if: ${{ needs.check-ld-secret.outputs.available == 'true' }}
permissions:
contents: read
pull-requests: write
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@1fe318de2993222574e6249750ba9000a4e2a6cd # 2.0.33
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@2c779ab0d087cd7fe7b826087247c2c81f27bfa6 # v3.26.5
with:
sarif_file: cx_result.sarif
@ -59,19 +59,33 @@ jobs:
pull-requests: write
steps:
- name: Set up JDK 17
uses: actions/setup-java@6a0805fcefea3d4657a47ac4c165951e33482018 # v4.2.2
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

@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Check
uses: actions/stale@1160a2240286f5da8ec72b1c0816ce2481aabf84 # v8.0.0
uses: actions/stale@28ca1036281a5e5922ead5184a1bbf96e5fc984e # v9.0.0
with:
stale-issue-label: "needs-reply"
stale-pr-label: "needs-changes"

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

@ -9,7 +9,7 @@ on:
- "rc"
- "hotfix-rc"
paths:
- ".github/workflows/infrastructure-tests.yml" # This file
- ".github/workflows/test-database.yml" # This file
- "src/Sql/**" # SQL Server Database Changes
- "util/Migrator/**" # New SQL Server Migrations
- "util/MySqlMigrations/**" # Changes to MySQL
@ -20,7 +20,7 @@ on:
- "test/Infrastructure.IntegrationTest/**" # Any changes to the tests
pull_request:
paths:
- ".github/workflows/infrastructure-tests.yml" # This file
- ".github/workflows/test-database.yml" # This file
- "src/Sql/**" # SQL Server Database Changes
- "util/Migrator/**" # New SQL Server Migrations
- "util/MySqlMigrations/**" # Changes to MySQL
@ -36,10 +36,10 @@ 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
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
- name: Restore tools
run: dotnet tool restore
@ -55,6 +55,24 @@ jobs:
# I've seen the SQL Server container not be ready for commands right after starting up and just needing a bit longer to be ready
- name: Sleep
run: sleep 15s
- name: Checking pending model changes (MySQL)
working-directory: "util/MySqlMigrations"
run: 'dotnet ef migrations has-pending-model-changes -- --GlobalSettings:MySql:ConnectionString="$CONN_STR"'
env:
CONN_STR: "server=localhost;uid=root;pwd=SET_A_PASSWORD_HERE_123;database=vault_dev;Allow User Variables=true"
- name: Checking pending model changes (Postgres)
working-directory: "util/PostgresMigrations"
run: 'dotnet ef migrations has-pending-model-changes -- --GlobalSettings:PostgreSql:ConnectionString="$CONN_STR"'
env:
CONN_STR: "Host=localhost;Username=postgres;Password=SET_A_PASSWORD_HERE_123;Database=vault_dev"
- name: Checking pending model changes (SQLite)
working-directory: "util/SqliteMigrations"
run: 'dotnet ef migrations has-pending-model-changes -- --GlobalSettings:Sqlite:ConnectionString="$CONN_STR"'
env:
CONN_STR: "Data Source=${{ runner.temp }}/test.db"
- name: Migrate SQL Server
run: 'dotnet run --project util/MsSqlMigratorUtility/ "$CONN_STR"'
@ -98,7 +116,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,10 +135,10 @@ 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
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
- name: Print environment
run: |
@ -134,7 +152,7 @@ jobs:
shell: pwsh
- name: Upload DACPAC
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
with:
name: sql.dacpac
path: Sql.dacpac
@ -160,7 +178,7 @@ jobs:
shell: pwsh
- name: Report validation results
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
with:
name: report.xml
path: |

View File

@ -1,4 +1,3 @@
---
name: Testing
on:
@ -14,18 +13,43 @@ env:
_AZ_REGISTRY: "bitwardenprod.azurecr.io"
jobs:
check-test-secrets:
name: Check for test secrets
runs-on: ubuntu-22.04
outputs:
available: ${{ steps.check-test-secrets.outputs.available }}
permissions:
contents: read
steps:
- name: Check
id: check-test-secrets
run: |
if [ "${{ secrets.CODECOV_TOKEN }}" != '' ]; then
echo "available=true" >> $GITHUB_OUTPUT;
else
echo "available=false" >> $GITHUB_OUTPUT;
fi
testing:
name: Run tests
if: ${{ startsWith(github.head_ref, 'version_bump_') == false }}
runs-on: ubuntu-22.04
needs: check-test-secrets
permissions:
checks: write
contents: read
pull-requests: write
env:
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
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
- name: Print environment
run: |
@ -44,8 +68,8 @@ 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
if: always()
uses: dorny/test-reporter@31a54ee7ebcacc03a09ea97a7e5465a47b84aea5 # v1.9.1
if: ${{ needs.check-test-secrets.outputs.available == 'true' && !cancelled() }}
with:
name: Test Results
path: "**/*-test-results.trx"
@ -53,6 +77,7 @@ 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
if: ${{ needs.check-test-secrets.outputs.available == 'true' }}
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}

View File

@ -12,6 +12,10 @@ on:
description: "Cut RC branch?"
default: true
type: boolean
enable_slack_notification:
description: "Enable Slack notifications for upcoming release?"
default: false
type: boolean
jobs:
bump_version:
@ -26,10 +30,16 @@ jobs:
with:
version: ${{ inputs.version_number_override }}
- name: Slack Notification Check
run: |
if [[ "${{ inputs.enable_slack_notification }}" == true ]]; then
echo "Slack notifications enabled."
else
echo "Slack notifications disabled."
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 }}
@ -42,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 }}
@ -56,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 }}
@ -198,6 +208,13 @@ jobs:
PR_NUMBER: ${{ steps.create-pr.outputs.pr_number }}
run: gh pr merge $PR_NUMBER --squash --auto --delete-branch
- name: Report upcoming release version to Slack
if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' && inputs.enable_slack_notification == true }}
uses: bitwarden/gh-actions/report-upcoming-release-version@main
with:
version: ${{ steps.set-final-version-output.outputs.version }}
project: ${{ github.repository }}
AZURE_KV_CI_SERVICE_PRINCIPAL: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
cut_rc:
name: Cut RC branch
@ -206,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

7
.gitignore vendored
View File

@ -205,16 +205,15 @@ 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/*
**/**.swp
.mono
src/Admin/Admin.zip
src/Api/Api.zip

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.5.0</Version>
<Version>2024.8.1</Version>
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>

View File

@ -44,7 +44,7 @@ _These dependencies are free to use._
### Linux & macOS
```
```sh
curl -s -L -o bitwarden.sh \
"https://func.bitwarden.com/api/dl/?app=self-host&platform=linux" \
&& chmod +x bitwarden.sh
@ -54,7 +54,7 @@ curl -s -L -o bitwarden.sh \
### Windows
```
```cmd
Invoke-RestMethod -OutFile bitwarden.ps1 `
-Uri "https://func.bitwarden.com/api/dl/?app=self-host&platform=windows"
.\bitwarden.ps1 -install

View File

@ -5,6 +5,7 @@ using Bit.Core.AdminConsole.Providers.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Entities;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Repositories;
using Bit.Core.Enums;
using Bit.Core.Exceptions;

View File

@ -4,15 +4,14 @@ using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Providers.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Services;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Microsoft.Extensions.Logging;
using Stripe;
namespace Bit.Commercial.Core.AdminConsole.Providers;
@ -20,35 +19,35 @@ namespace Bit.Commercial.Core.AdminConsole.Providers;
public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProviderCommand
{
private readonly IEventService _eventService;
private readonly ILogger<RemoveOrganizationFromProviderCommand> _logger;
private readonly IMailService _mailService;
private readonly IOrganizationRepository _organizationRepository;
private readonly IOrganizationService _organizationService;
private readonly IProviderOrganizationRepository _providerOrganizationRepository;
private readonly IStripeAdapter _stripeAdapter;
private readonly IScaleSeatsCommand _scaleSeatsCommand;
private readonly IFeatureService _featureService;
private readonly IProviderBillingService _providerBillingService;
private readonly ISubscriberService _subscriberService;
public RemoveOrganizationFromProviderCommand(
IEventService eventService,
ILogger<RemoveOrganizationFromProviderCommand> logger,
IMailService mailService,
IOrganizationRepository organizationRepository,
IOrganizationService organizationService,
IProviderOrganizationRepository providerOrganizationRepository,
IStripeAdapter stripeAdapter,
IScaleSeatsCommand scaleSeatsCommand,
IFeatureService featureService)
IFeatureService featureService,
IProviderBillingService providerBillingService,
ISubscriberService subscriberService)
{
_eventService = eventService;
_logger = logger;
_mailService = mailService;
_organizationRepository = organizationRepository;
_organizationService = organizationService;
_providerOrganizationRepository = providerOrganizationRepository;
_stripeAdapter = stripeAdapter;
_scaleSeatsCommand = scaleSeatsCommand;
_featureService = featureService;
_providerBillingService = providerBillingService;
_subscriberService = subscriberService;
}
public async Task RemoveOrganizationFromProvider(
@ -99,23 +98,19 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
Provider provider,
IEnumerable<string> organizationOwnerEmails)
{
if (!organization.IsStripeEnabled())
{
return;
}
var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
var customerUpdateOptions = new CustomerUpdateOptions
if (isConsolidatedBillingEnabled &&
provider.Status == ProviderStatusType.Billable &&
organization.Status == OrganizationStatusType.Managed &&
!string.IsNullOrEmpty(organization.GatewayCustomerId))
{
Coupon = string.Empty,
Email = organization.BillingEmail
};
await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, new CustomerUpdateOptions
{
Description = string.Empty,
Email = organization.BillingEmail
});
await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, customerUpdateOptions);
if (isConsolidatedBillingEnabled && provider.Status == ProviderStatusType.Billable)
{
var plan = StaticStore.GetPlan(organization.PlanType).PasswordManager;
var subscriptionCreateOptions = new SubscriptionCreateOptions
@ -136,19 +131,31 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
var subscription = await _stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
organization.GatewaySubscriptionId = subscription.Id;
organization.Status = OrganizationStatusType.Created;
await _scaleSeatsCommand.ScalePasswordManagerSeats(provider, organization.PlanType,
-(organization.Seats ?? 0));
await _providerBillingService.ScaleSeats(provider, organization.PlanType, -organization.Seats ?? 0);
}
else
else if (organization.IsStripeEnabled())
{
var subscriptionUpdateOptions = new SubscriptionUpdateOptions
var subscription = await _stripeAdapter.SubscriptionGetAsync(organization.GatewaySubscriptionId);
if (subscription.Status is StripeConstants.SubscriptionStatus.Canceled or StripeConstants.SubscriptionStatus.IncompleteExpired)
{
CollectionMethod = "send_invoice",
DaysUntilDue = 30
};
return;
}
await _stripeAdapter.SubscriptionUpdateAsync(organization.GatewaySubscriptionId, subscriptionUpdateOptions);
await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, new CustomerUpdateOptions
{
Coupon = string.Empty,
Email = organization.BillingEmail
});
await _stripeAdapter.SubscriptionUpdateAsync(organization.GatewaySubscriptionId, new SubscriptionUpdateOptions
{
CollectionMethod = StripeConstants.CollectionMethod.SendInvoice,
DaysUntilDue = 30
});
await _subscriberService.RemovePaymentSource(organization);
}
await _mailService.SendProviderUpdatePaymentMethod(

View File

@ -7,7 +7,9 @@ using Bit.Core.AdminConsole.Models.Business.Provider;
using Bit.Core.AdminConsole.Models.Business.Tokenables;
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;
@ -44,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,
@ -52,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;
@ -70,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)
@ -97,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);
@ -435,11 +455,15 @@ public class ProviderService : IProviderService
return;
}
var subscriptionItem = await GetSubscriptionItemAsync(organization.GatewaySubscriptionId, GetStripeSeatPlanId(organization.PlanType));
var extractedPlanType = PlanTypeMappings(organization);
if (subscriptionItem != null)
if (!string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId))
{
await UpdateSubscriptionAsync(subscriptionItem, GetStripeSeatPlanId(extractedPlanType), organization);
var subscriptionItem = await GetSubscriptionItemAsync(organization.GatewaySubscriptionId,
GetStripeSeatPlanId(organization.PlanType));
var extractedPlanType = PlanTypeMappings(organization);
if (subscriptionItem != null)
{
await UpdateSubscriptionAsync(subscriptionItem, GetStripeSeatPlanId(extractedPlanType), organization);
}
}
await _organizationRepository.UpsertAsync(organization);
@ -539,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
@ -554,17 +578,13 @@ public class ProviderService : IProviderService
]
: Array.Empty<CollectionAccessSelection>();
await _organizationService.InviteUsersAsync(organization.Id, user.Id,
await _organizationService.InviteUsersAsync(organization.Id, user.Id, systemUser: null,
new (OrganizationUserInvite, string)[]
{
(
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

@ -0,0 +1,29 @@
using System.Globalization;
using Bit.Core.Billing.Entities;
using CsvHelper.Configuration.Attributes;
namespace Bit.Commercial.Core.Billing.Models;
public class ProviderClientInvoiceReportRow
{
public string Client { get; set; }
public string Id { get; set; }
public int Assigned { get; set; }
public int Used { get; set; }
public int Remaining { get; set; }
public string Plan { get; set; }
[Name("Estimated total")]
public string Total { get; set; }
public static ProviderClientInvoiceReportRow From(ProviderInvoiceItem providerInvoiceItem)
=> new()
{
Client = providerInvoiceItem.ClientName,
Id = providerInvoiceItem.ClientId?.ToString(),
Assigned = providerInvoiceItem.AssignedSeats,
Used = providerInvoiceItem.UsedSeats,
Remaining = providerInvoiceItem.AssignedSeats - providerInvoiceItem.UsedSeats,
Plan = providerInvoiceItem.PlanName,
Total = string.Format(new CultureInfo("en-US", false), "{0:C}", providerInvoiceItem.Total)
};
}

View File

@ -0,0 +1,623 @@
using System.Globalization;
using Bit.Commercial.Core.Billing.Models;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing;
using Bit.Core.Billing.Constants;
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.Context;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Business;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using CsvHelper;
using Microsoft.Extensions.Logging;
using Stripe;
namespace Bit.Commercial.Core.Billing;
public class ProviderBillingService(
ICurrentContext currentContext,
IGlobalSettings globalSettings,
ILogger<ProviderBillingService> logger,
IOrganizationRepository organizationRepository,
IPaymentService paymentService,
IProviderInvoiceItemRepository providerInvoiceItemRepository,
IProviderOrganizationRepository providerOrganizationRepository,
IProviderPlanRepository providerPlanRepository,
IProviderRepository providerRepository,
IStripeAdapter stripeAdapter,
ISubscriberService subscriberService) : IProviderBillingService
{
public async Task AssignSeatsToClientOrganization(
Provider provider,
Organization organization,
int seats)
{
ArgumentNullException.ThrowIfNull(organization);
if (seats < 0)
{
throw new BillingException(
"You cannot assign negative seats to a client.",
"MSP cannot assign negative seats to a client organization");
}
if (seats == organization.Seats)
{
logger.LogWarning("Client organization ({ID}) already has {Seats} seats assigned to it", organization.Id, organization.Seats);
return;
}
var seatAdjustment = seats - (organization.Seats ?? 0);
await ScaleSeats(provider, organization.PlanType, seatAdjustment);
organization.Seats = seats;
await organizationRepository.ReplaceAsync(organization);
}
public async Task CreateCustomerForClientOrganization(
Provider provider,
Organization organization)
{
ArgumentNullException.ThrowIfNull(provider);
ArgumentNullException.ThrowIfNull(organization);
if (!string.IsNullOrEmpty(organization.GatewayCustomerId))
{
logger.LogWarning("Client organization ({ID}) already has a populated {FieldName}", organization.Id, nameof(organization.GatewayCustomerId));
return;
}
var providerCustomer = await subscriberService.GetCustomerOrThrow(provider, new CustomerGetOptions
{
Expand = ["tax_ids"]
});
var providerTaxId = providerCustomer.TaxIds.FirstOrDefault();
var organizationDisplayName = organization.DisplayName();
var customerCreateOptions = new CustomerCreateOptions
{
Address = new AddressOptions
{
Country = providerCustomer.Address?.Country,
PostalCode = providerCustomer.Address?.PostalCode,
Line1 = providerCustomer.Address?.Line1,
Line2 = providerCustomer.Address?.Line2,
City = providerCustomer.Address?.City,
State = providerCustomer.Address?.State
},
Name = organizationDisplayName,
Description = $"{provider.Name} Client Organization",
Email = provider.BillingEmail,
InvoiceSettings = new CustomerInvoiceSettingsOptions
{
CustomFields =
[
new CustomerInvoiceSettingsCustomFieldOptions
{
Name = organization.SubscriberType(),
Value = organizationDisplayName.Length <= 30
? organizationDisplayName
: organizationDisplayName[..30]
}
]
},
Metadata = new Dictionary<string, string>
{
{ "region", globalSettings.BaseServiceUri.CloudRegion }
},
TaxIdData = providerTaxId == null ? null :
[
new CustomerTaxIdDataOptions
{
Type = providerTaxId.Type,
Value = providerTaxId.Value
}
]
};
var customer = await stripeAdapter.CustomerCreateAsync(customerCreateOptions);
organization.GatewayCustomerId = customer.Id;
await organizationRepository.ReplaceAsync(organization);
}
public async Task<byte[]> GenerateClientInvoiceReport(
string 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;
}
var csvRows = invoiceItems.Select(ProviderClientInvoiceReportRow.From);
using var memoryStream = new MemoryStream();
await using var streamWriter = new StreamWriter(memoryStream);
await using var csvWriter = new CsvWriter(streamWriter, CultureInfo.CurrentCulture);
await csvWriter.WriteRecordsAsync(csvRows);
await streamWriter.FlushAsync();
memoryStream.Seek(0, SeekOrigin.Begin);
return memoryStream.ToArray();
}
public async Task<int> GetAssignedSeatTotalForPlanOrThrow(
Guid providerId,
PlanType planType)
{
var provider = await providerRepository.GetByIdAsync(providerId);
if (provider == null)
{
logger.LogError(
"Could not find provider ({ID}) when retrieving assigned seat total",
providerId);
throw new BillingException();
}
if (provider.Type == ProviderType.Reseller)
{
logger.LogError("Assigned seats cannot be retrieved for reseller-type provider ({ID})", providerId);
throw new BillingException();
}
var providerOrganizations = await providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId);
var plan = StaticStore.GetPlan(planType);
return providerOrganizations
.Where(providerOrganization => providerOrganization.Plan == plan.Name && providerOrganization.Status == OrganizationStatusType.Managed)
.Sum(providerOrganization => providerOrganization.Seats ?? 0);
}
public async Task ScaleSeats(
Provider provider,
PlanType planType,
int seatAdjustment)
{
ArgumentNullException.ThrowIfNull(provider);
if (provider.Type != ProviderType.Msp)
{
logger.LogError("Non-MSP provider ({ProviderID}) cannot scale their seats", provider.Id);
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 new BillingException();
}
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
var providerPlan = providerPlans.FirstOrDefault(providerPlan => providerPlan.PlanType == planType);
if (providerPlan == null || !providerPlan.IsConfigured())
{
logger.LogError("Cannot scale provider ({ProviderID}) seats for plan type {PlanType} when their matching provider plan is not configured", provider.Id, planType);
throw new BillingException();
}
var seatMinimum = providerPlan.SeatMinimum.GetValueOrDefault(0);
var currentlyAssignedSeatTotal = await GetAssignedSeatTotalForPlanOrThrow(provider.Id, planType);
var newlyAssignedSeatTotal = currentlyAssignedSeatTotal + seatAdjustment;
var update = CurrySeatScalingUpdate(
provider,
providerPlan,
newlyAssignedSeatTotal);
/*
* Below the limit => Below the limit:
* No subscription update required. We can safely update the provider's allocated seats.
*/
if (currentlyAssignedSeatTotal <= seatMinimum &&
newlyAssignedSeatTotal <= seatMinimum)
{
providerPlan.AllocatedSeats = newlyAssignedSeatTotal;
await providerPlanRepository.ReplaceAsync(providerPlan);
}
/*
* Below the limit => Above the limit:
* We have to scale the subscription up from the seat minimum to the newly assigned seat total.
*/
else if (currentlyAssignedSeatTotal <= seatMinimum &&
newlyAssignedSeatTotal > seatMinimum)
{
if (!currentContext.ProviderProviderAdmin(provider.Id))
{
logger.LogError("Service user for provider ({ProviderID}) cannot scale a provider's seat count over the seat minimum", provider.Id);
throw new BillingException();
}
await update(
seatMinimum,
newlyAssignedSeatTotal);
}
/*
* Above the limit => Above the limit:
* We have to scale the subscription from the currently assigned seat total to the newly assigned seat total.
*/
else if (currentlyAssignedSeatTotal > seatMinimum &&
newlyAssignedSeatTotal > seatMinimum)
{
await update(
currentlyAssignedSeatTotal,
newlyAssignedSeatTotal);
}
/*
* Above the limit => Below the limit:
* We have to scale the subscription down from the currently assigned seat total to the seat minimum.
*/
else if (currentlyAssignedSeatTotal > seatMinimum &&
newlyAssignedSeatTotal <= seatMinimum)
{
await update(
currentlyAssignedSeatTotal,
seatMinimum);
}
}
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);
var customer = await subscriberService.GetCustomerOrThrow(provider);
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
if (providerPlans == null || providerPlans.Count == 0)
{
logger.LogError("Cannot start subscription for provider ({ProviderID}) that has no configured plans", provider.Id);
throw new BillingException();
}
var subscriptionItemOptionsList = new List<SubscriptionItemOptions>();
var teamsProviderPlan =
providerPlans.SingleOrDefault(providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly);
if (teamsProviderPlan == null || !teamsProviderPlan.IsConfigured())
{
logger.LogError("Cannot start subscription for provider ({ProviderID}) that has no configured Teams plan", provider.Id);
throw new BillingException();
}
var teamsPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{
Price = teamsPlan.PasswordManager.StripeProviderPortalSeatPlanId,
Quantity = teamsProviderPlan.SeatMinimum
});
var enterpriseProviderPlan =
providerPlans.SingleOrDefault(providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly);
if (enterpriseProviderPlan == null || !enterpriseProviderPlan.IsConfigured())
{
logger.LogError("Cannot start subscription for provider ({ProviderID}) that has no configured Enterprise plan", provider.Id);
throw new BillingException();
}
var enterprisePlan = StaticStore.GetPlan(PlanType.EnterpriseMonthly);
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{
Price = enterprisePlan.PasswordManager.StripeProviderPortalSeatPlanId,
Quantity = enterpriseProviderPlan.SeatMinimum
});
var subscriptionCreateOptions = new SubscriptionCreateOptions
{
AutomaticTax = new SubscriptionAutomaticTaxOptions
{
Enabled = true
},
CollectionMethod = StripeConstants.CollectionMethod.SendInvoice,
Customer = customer.Id,
DaysUntilDue = 30,
Items = subscriptionItemOptionsList,
Metadata = new Dictionary<string, string>
{
{ "providerId", provider.Id.ToString() }
},
OffSession = true,
ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations
};
try
{
var subscription = await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
if (subscription.Status == StripeConstants.SubscriptionStatus.Active)
{
return subscription;
}
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.");
}
var subscription = await stripeAdapter.SubscriptionGetAsync(provider.GatewaySubscriptionId);
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)
{
if (enterpriseProviderPlan.AllocatedSeats > enterpriseSeatMinimum)
{
enterpriseProviderPlan.PurchasedSeats =
enterpriseProviderPlan.AllocatedSeats - enterpriseSeatMinimum;
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{
Id = enterpriseSubscriptionItem.Id,
Price = enterprisePriceId,
Quantity = enterpriseProviderPlan.AllocatedSeats
});
}
else
{
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)
{
if (teamsProviderPlan.AllocatedSeats > teamsSeatMinimum)
{
teamsProviderPlan.PurchasedSeats = teamsProviderPlan.AllocatedSeats - teamsSeatMinimum;
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{
Id = teamsSubscriptionItem.Id,
Price = teamsPriceId,
Quantity = teamsProviderPlan.AllocatedSeats
});
}
else
{
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{
Id = teamsSubscriptionItem.Id,
Price = teamsPriceId,
Quantity = teamsSeatMinimum
});
}
}
else
{
var totalTeamsSeats = teamsProviderPlan.SeatMinimum + teamsProviderPlan.PurchasedSeats;
if (teamsSeatMinimum <= totalTeamsSeats)
{
teamsProviderPlan.PurchasedSeats = totalTeamsSeats - teamsSeatMinimum;
}
else
{
teamsProviderPlan.PurchasedSeats = 0;
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{
Id = teamsSubscriptionItem.Id,
Price = teamsPriceId,
Quantity = teamsSeatMinimum
});
}
}
teamsProviderPlan.SeatMinimum = teamsSeatMinimum;
await providerPlanRepository.ReplaceAsync(teamsProviderPlan);
}
if (subscriptionItemOptionsList.Count > 0)
{
await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId,
new SubscriptionUpdateOptions { Items = subscriptionItemOptionsList });
}
}
private Func<int, int, Task> CurrySeatScalingUpdate(
Provider provider,
ProviderPlan providerPlan,
int newlyAssignedSeats) => async (currentlySubscribedSeats, newlySubscribedSeats) =>
{
var plan = StaticStore.GetPlan(providerPlan.PlanType);
await paymentService.AdjustSeats(
provider,
plan,
currentlySubscribedSeats,
newlySubscribedSeats);
var newlyPurchasedSeats = newlySubscribedSeats > providerPlan.SeatMinimum
? newlySubscribedSeats - providerPlan.SeatMinimum
: 0;
providerPlan.PurchasedSeats = newlyPurchasedSeats;
providerPlan.AllocatedSeats = newlyAssignedSeats;
await providerPlanRepository.ReplaceAsync(providerPlan);
};
}

View File

@ -4,4 +4,8 @@
<ProjectReference Include="..\..\..\src\Core\Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="CsvHelper" Version="32.0.3" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,162 @@
#nullable enable
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.SecretsManager.AuthorizationRequirements;
using Bit.Core.SecretsManager.Enums.AccessPolicies;
using Bit.Core.SecretsManager.Models.Data.AccessPolicyUpdates;
using Bit.Core.SecretsManager.Queries.AccessPolicies.Interfaces;
using Bit.Core.SecretsManager.Queries.Interfaces;
using Bit.Core.SecretsManager.Repositories;
using Microsoft.AspNetCore.Authorization;
namespace Bit.Commercial.Core.SecretsManager.AuthorizationHandlers.AccessPolicies;
public class SecretAccessPoliciesUpdatesAuthorizationHandler : AuthorizationHandler<
SecretAccessPoliciesOperationRequirement,
SecretAccessPoliciesUpdates>
{
private readonly IAccessClientQuery _accessClientQuery;
private readonly ICurrentContext _currentContext;
private readonly ISameOrganizationQuery _sameOrganizationQuery;
private readonly ISecretRepository _secretRepository;
private readonly IServiceAccountRepository _serviceAccountRepository;
public SecretAccessPoliciesUpdatesAuthorizationHandler(ICurrentContext currentContext,
IAccessClientQuery accessClientQuery,
ISecretRepository secretRepository,
ISameOrganizationQuery sameOrganizationQuery,
IServiceAccountRepository serviceAccountRepository)
{
_currentContext = currentContext;
_accessClientQuery = accessClientQuery;
_sameOrganizationQuery = sameOrganizationQuery;
_serviceAccountRepository = serviceAccountRepository;
_secretRepository = secretRepository;
}
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context,
SecretAccessPoliciesOperationRequirement requirement,
SecretAccessPoliciesUpdates resource)
{
if (!_currentContext.AccessSecretsManager(resource.OrganizationId))
{
return;
}
// Only users and admins should be able to manipulate access policies
var (accessClient, userId) =
await _accessClientQuery.GetAccessClientAsync(context.User, resource.OrganizationId);
if (accessClient != AccessClientType.User && accessClient != AccessClientType.NoAccessCheck)
{
return;
}
switch (requirement)
{
case not null when requirement == SecretAccessPoliciesOperations.Updates:
await CanUpdateAsync(context, requirement, resource, accessClient,
userId);
break;
case not null when requirement == SecretAccessPoliciesOperations.Create:
await CanCreateAsync(context, requirement, resource, accessClient,
userId);
break;
default:
throw new ArgumentException("Unsupported operation requirement type provided.",
nameof(requirement));
}
}
private async Task CanUpdateAsync(AuthorizationHandlerContext context,
SecretAccessPoliciesOperationRequirement requirement,
SecretAccessPoliciesUpdates resource,
AccessClientType accessClient, Guid userId)
{
var access = await _secretRepository
.AccessToSecretAsync(resource.SecretId, userId, accessClient);
if (!access.Write)
{
return;
}
if (!await GranteesInTheSameOrganizationAsync(resource))
{
return;
}
// Users can only create access policies for service accounts they have access to.
// User can delete and update any service account access policy if they have write access to the secret.
if (await HasAccessToTargetServiceAccountsAsync(resource, accessClient, userId))
{
context.Succeed(requirement);
}
}
private async Task CanCreateAsync(AuthorizationHandlerContext context,
SecretAccessPoliciesOperationRequirement requirement,
SecretAccessPoliciesUpdates resource,
AccessClientType accessClient, Guid userId)
{
if (resource.UserAccessPolicyUpdates.Any(x => x.Operation != AccessPolicyOperation.Create) ||
resource.GroupAccessPolicyUpdates.Any(x => x.Operation != AccessPolicyOperation.Create) ||
resource.ServiceAccountAccessPolicyUpdates.Any(x => x.Operation != AccessPolicyOperation.Create))
{
return;
}
if (!await GranteesInTheSameOrganizationAsync(resource))
{
return;
}
// Users can only create access policies for service accounts they have access to.
if (await HasAccessToTargetServiceAccountsAsync(resource, accessClient, userId))
{
context.Succeed(requirement);
}
}
private async Task<bool> GranteesInTheSameOrganizationAsync(SecretAccessPoliciesUpdates resource)
{
var organizationUserIds = resource.UserAccessPolicyUpdates.Select(update =>
update.AccessPolicy.OrganizationUserId!.Value).ToList();
var groupIds = resource.GroupAccessPolicyUpdates.Select(update =>
update.AccessPolicy.GroupId!.Value).ToList();
var serviceAccountIds = resource.ServiceAccountAccessPolicyUpdates.Select(update =>
update.AccessPolicy.ServiceAccountId!.Value).ToList();
var usersInSameOrg = organizationUserIds.Count == 0 ||
await _sameOrganizationQuery.OrgUsersInTheSameOrgAsync(organizationUserIds,
resource.OrganizationId);
var groupsInSameOrg = groupIds.Count == 0 ||
await _sameOrganizationQuery.GroupsInTheSameOrgAsync(groupIds, resource.OrganizationId);
var serviceAccountsInSameOrg = serviceAccountIds.Count == 0 ||
await _serviceAccountRepository.ServiceAccountsAreInOrganizationAsync(
serviceAccountIds,
resource.OrganizationId);
return usersInSameOrg && groupsInSameOrg && serviceAccountsInSameOrg;
}
private async Task<bool> HasAccessToTargetServiceAccountsAsync(SecretAccessPoliciesUpdates resource,
AccessClientType accessClient, Guid userId)
{
var serviceAccountIdsToCheck = resource.ServiceAccountAccessPolicyUpdates
.Where(update => update.Operation == AccessPolicyOperation.Create).Select(update =>
update.AccessPolicy.ServiceAccountId!.Value).ToList();
if (serviceAccountIdsToCheck.Count == 0)
{
return true;
}
var serviceAccountsAccess =
await _serviceAccountRepository.AccessToServiceAccountsAsync(serviceAccountIdsToCheck, userId,
accessClient);
return serviceAccountsAccess.Count == serviceAccountIdsToCheck.Count &&
serviceAccountsAccess.All(a => a.Value.Write);
}
}

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

@ -47,6 +47,9 @@ public class SecretAuthorizationHandler : AuthorizationHandler<SecretOperationRe
case not null when requirement == SecretOperations.Delete:
await CanDeleteSecretAsync(context, requirement, resource);
break;
case not null when requirement == SecretOperations.ReadAccessPolicies:
await CanReadAccessPoliciesAsync(context, requirement, resource);
break;
default:
throw new ArgumentException("Unsupported operation requirement type provided.", nameof(requirement));
}
@ -106,9 +109,9 @@ public class SecretAuthorizationHandler : AuthorizationHandler<SecretOperationRe
{
var (accessClient, userId) = await _accessClientQuery.GetAccessClientAsync(context.User, resource.OrganizationId);
// All projects should be apart of the same organization
// All projects should be in the same organization
if (resource.Projects != null
&& resource.Projects.Any()
&& resource.Projects.Count != 0
&& !await _projectRepository.ProjectsAreInOrganization(resource.Projects.Select(p => p.Id).ToList(),
resource.OrganizationId))
{
@ -152,10 +155,42 @@ public class SecretAuthorizationHandler : AuthorizationHandler<SecretOperationRe
}
}
private async Task CanReadAccessPoliciesAsync(AuthorizationHandlerContext context,
SecretOperationRequirement requirement, Secret resource)
{
var (accessClient, userId) = await _accessClientQuery.GetAccessClientAsync(context.User, resource.OrganizationId);
// Only users and admins can read access policies
if (accessClient != AccessClientType.User && accessClient != AccessClientType.NoAccessCheck)
{
return;
}
var access = await _secretRepository.AccessToSecretAsync(resource.Id, userId, accessClient);
if (access.Write)
{
context.Succeed(requirement);
}
}
private async Task<bool> GetAccessToUpdateSecretAsync(Secret resource, Guid userId, AccessClientType accessClient)
{
var newProject = resource.Projects?.FirstOrDefault();
// Request was to remove all projects from the secret. This is not allowed for non admin users.
if (resource.Projects?.Count == 0)
{
return false;
}
var access = (await _secretRepository.AccessToSecretAsync(resource.Id, userId, accessClient)).Write;
// No project mapping changes requested, return secret access.
if (resource.Projects == null)
{
return access;
}
var newProject = resource.Projects?.FirstOrDefault();
var accessToNew = newProject != null &&
(await _projectRepository.AccessToProjectAsync(newProject.Id, userId, accessClient))
.Write;

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

@ -1,5 +1,7 @@
using Bit.Core.SecretsManager.Commands.Secrets.Interfaces;
#nullable enable
using Bit.Core.SecretsManager.Commands.Secrets.Interfaces;
using Bit.Core.SecretsManager.Entities;
using Bit.Core.SecretsManager.Models.Data.AccessPolicyUpdates;
using Bit.Core.SecretsManager.Repositories;
namespace Bit.Commercial.Core.SecretsManager.Commands.Secrets;
@ -13,8 +15,8 @@ public class CreateSecretCommand : ICreateSecretCommand
_secretRepository = secretRepository;
}
public async Task<Secret> CreateAsync(Secret secret)
public async Task<Secret> CreateAsync(Secret secret, SecretAccessPoliciesUpdates? accessPoliciesUpdates)
{
return await _secretRepository.CreateAsync(secret);
return await _secretRepository.CreateAsync(secret, accessPoliciesUpdates);
}
}

View File

@ -1,6 +1,7 @@
using Bit.Core.Exceptions;
#nullable enable
using Bit.Core.SecretsManager.Commands.Secrets.Interfaces;
using Bit.Core.SecretsManager.Entities;
using Bit.Core.SecretsManager.Models.Data.AccessPolicyUpdates;
using Bit.Core.SecretsManager.Repositories;
namespace Bit.Commercial.Core.SecretsManager.Commands.Secrets;
@ -14,21 +15,8 @@ public class UpdateSecretCommand : IUpdateSecretCommand
_secretRepository = secretRepository;
}
public async Task<Secret> UpdateAsync(Secret updatedSecret)
public async Task<Secret> UpdateAsync(Secret secret, SecretAccessPoliciesUpdates? accessPolicyUpdates)
{
var secret = await _secretRepository.GetByIdAsync(updatedSecret.Id);
if (secret == null)
{
throw new NotFoundException();
}
secret.Key = updatedSecret.Key;
secret.Value = updatedSecret.Value;
secret.Note = updatedSecret.Note;
secret.Projects = updatedSecret.Projects;
secret.RevisionDate = DateTime.UtcNow;
await _secretRepository.UpdateAsync(secret);
return secret;
return await _secretRepository.UpdateAsync(secret, accessPolicyUpdates);
}
}

View File

@ -0,0 +1,24 @@
#nullable enable
using Bit.Core.SecretsManager.Models.Data;
using Bit.Core.SecretsManager.Models.Data.AccessPolicyUpdates;
using Bit.Core.SecretsManager.Queries.AccessPolicies.Interfaces;
using Bit.Core.SecretsManager.Repositories;
namespace Bit.Commercial.Core.SecretsManager.Queries.AccessPolicies;
public class SecretAccessPoliciesUpdatesQuery : ISecretAccessPoliciesUpdatesQuery
{
private readonly IAccessPolicyRepository _accessPolicyRepository;
public SecretAccessPoliciesUpdatesQuery(IAccessPolicyRepository accessPolicyRepository)
{
_accessPolicyRepository = accessPolicyRepository;
}
public async Task<SecretAccessPoliciesUpdates> GetAsync(SecretAccessPolicies accessPolicies, Guid userId)
{
var currentPolicies = await _accessPolicyRepository.GetSecretAccessPoliciesAsync(accessPolicies.SecretId, userId);
return currentPolicies == null ? new SecretAccessPoliciesUpdates(accessPolicies) : currentPolicies.GetPolicyUpdates(accessPolicies);
}
}

View File

@ -1,4 +1,4 @@
using Bit.Core.Enums;
using Bit.Core.Billing.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.SecretsManager.Queries.Projects.Interfaces;

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;
@ -42,17 +44,21 @@ public static class SecretsManagerCollectionExtensions
services.AddScoped<IAuthorizationHandler, ServiceAccountPeopleAccessPoliciesAuthorizationHandler>();
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>();
services.AddScoped<IServiceAccountSecretsDetailsQuery, ServiceAccountSecretsDetailsQuery>();
services.AddScoped<IServiceAccountGrantedPolicyUpdatesQuery, ServiceAccountGrantedPolicyUpdatesQuery>();
services.AddScoped<ISecretAccessPoliciesUpdatesQuery, SecretAccessPoliciesUpdatesQuery>();
services.AddScoped<ISecretsSyncQuery, SecretsSyncQuery>();
services.AddScoped<IProjectServiceAccountsAccessPoliciesUpdatesQuery, ProjectServiceAccountsAccessPoliciesUpdatesQuery>();
services.AddScoped<ICreateSecretCommand, CreateSecretCommand>();
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

@ -1,7 +1,9 @@
using Bit.Commercial.Core.AdminConsole.Providers;
using Bit.Commercial.Core.AdminConsole.Services;
using Bit.Commercial.Core.Billing;
using Bit.Core.AdminConsole.Providers.Interfaces;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Services;
using Microsoft.Extensions.DependencyInjection;
namespace Bit.Commercial.Core.Utilities;
@ -13,5 +15,6 @@ public static class ServiceCollectionExtensions
services.AddScoped<IProviderService, ProviderService>();
services.AddScoped<ICreateProviderCommand, CreateProviderCommand>();
services.AddScoped<IRemoveOrganizationFromProviderCommand, RemoveOrganizationFromProviderCommand>();
services.AddTransient<IProviderBillingService, ProviderBillingService>();
}
}

View File

@ -20,7 +20,8 @@ public class AccessPolicyRepository : BaseEntityFrameworkRepository, IAccessPoli
{
}
public async Task<List<Core.SecretsManager.Entities.BaseAccessPolicy>> CreateManyAsync(List<Core.SecretsManager.Entities.BaseAccessPolicy> baseAccessPolicies)
public async Task<List<Core.SecretsManager.Entities.BaseAccessPolicy>> CreateManyAsync(
List<Core.SecretsManager.Entities.BaseAccessPolicy> baseAccessPolicies)
{
await using var scope = ServiceScopeFactory.CreateAsyncScope();
var dbContext = GetDatabaseContext(scope);
@ -39,6 +40,13 @@ public class AccessPolicyRepository : BaseEntityFrameworkRepository, IAccessPoli
await dbContext.AddAsync(entity);
break;
}
case Core.SecretsManager.Entities.UserSecretAccessPolicy accessPolicy:
{
var entity =
Mapper.Map<UserSecretAccessPolicy>(accessPolicy);
await dbContext.AddAsync(entity);
break;
}
case Core.SecretsManager.Entities.UserServiceAccountAccessPolicy accessPolicy:
{
var entity =
@ -52,6 +60,12 @@ public class AccessPolicyRepository : BaseEntityFrameworkRepository, IAccessPoli
await dbContext.AddAsync(entity);
break;
}
case Core.SecretsManager.Entities.GroupSecretAccessPolicy accessPolicy:
{
var entity = Mapper.Map<GroupSecretAccessPolicy>(accessPolicy);
await dbContext.AddAsync(entity);
break;
}
case Core.SecretsManager.Entities.GroupServiceAccountAccessPolicy accessPolicy:
{
var entity = Mapper.Map<GroupServiceAccountAccessPolicy>(accessPolicy);
@ -65,6 +79,13 @@ public class AccessPolicyRepository : BaseEntityFrameworkRepository, IAccessPoli
serviceAccountIds.Add(entity.ServiceAccountId!.Value);
break;
}
case Core.SecretsManager.Entities.ServiceAccountSecretAccessPolicy accessPolicy:
{
var entity = Mapper.Map<ServiceAccountSecretAccessPolicy>(accessPolicy);
await dbContext.AddAsync(entity);
serviceAccountIds.Add(entity.ServiceAccountId!.Value);
break;
}
}
}
@ -395,6 +416,42 @@ public class AccessPolicyRepository : BaseEntityFrameworkRepository, IAccessPoli
await transaction.CommitAsync();
}
public async Task<SecretAccessPolicies?> GetSecretAccessPoliciesAsync(
Guid secretId,
Guid userId)
{
await using var scope = ServiceScopeFactory.CreateAsyncScope();
var dbContext = GetDatabaseContext(scope);
var entities = await dbContext.AccessPolicies.Where(ap =>
((UserSecretAccessPolicy)ap).GrantedSecretId == secretId ||
((GroupSecretAccessPolicy)ap).GrantedSecretId == secretId ||
((ServiceAccountSecretAccessPolicy)ap).GrantedSecretId == secretId)
.Include(ap => ((UserSecretAccessPolicy)ap).OrganizationUser.User)
.Include(ap => ((GroupSecretAccessPolicy)ap).Group)
.Include(ap => ((ServiceAccountSecretAccessPolicy)ap).ServiceAccount)
.Select(ap => new
{
ap,
CurrentUserInGroup = ap is GroupSecretAccessPolicy &&
((GroupSecretAccessPolicy)ap).Group.GroupUsers.Any(g =>
g.OrganizationUser.UserId == userId)
})
.ToListAsync();
if (entities.Count == 0)
{
return null;
}
var organizationId = await dbContext.Secret.Where(s => s.Id == secretId)
.Select(s => s.OrganizationId)
.SingleAsync();
return new SecretAccessPolicies(secretId, organizationId,
entities.Select(e => MapToCore(e.ap, e.CurrentUserInGroup)).ToList());
}
private static async Task UpsertPeoplePoliciesAsync(DatabaseContext dbContext,
List<BaseAccessPolicy> policies, IReadOnlyCollection<AccessPolicy> userPolicyEntities,
IReadOnlyCollection<AccessPolicy> groupPolicyEntities)
@ -466,13 +523,17 @@ public class AccessPolicyRepository : BaseEntityFrameworkRepository, IAccessPoli
baseAccessPolicyEntity switch
{
UserProjectAccessPolicy ap => Mapper.Map<Core.SecretsManager.Entities.UserProjectAccessPolicy>(ap),
GroupProjectAccessPolicy ap => Mapper.Map<Core.SecretsManager.Entities.GroupProjectAccessPolicy>(ap),
ServiceAccountProjectAccessPolicy ap => Mapper
.Map<Core.SecretsManager.Entities.ServiceAccountProjectAccessPolicy>(ap),
UserSecretAccessPolicy ap => Mapper.Map<Core.SecretsManager.Entities.UserSecretAccessPolicy>(ap),
UserServiceAccountAccessPolicy ap =>
Mapper.Map<Core.SecretsManager.Entities.UserServiceAccountAccessPolicy>(ap),
GroupProjectAccessPolicy ap => Mapper.Map<Core.SecretsManager.Entities.GroupProjectAccessPolicy>(ap),
GroupSecretAccessPolicy ap => Mapper.Map<Core.SecretsManager.Entities.GroupSecretAccessPolicy>(ap),
GroupServiceAccountAccessPolicy ap => Mapper
.Map<Core.SecretsManager.Entities.GroupServiceAccountAccessPolicy>(ap),
ServiceAccountProjectAccessPolicy ap => Mapper
.Map<Core.SecretsManager.Entities.ServiceAccountProjectAccessPolicy>(ap),
ServiceAccountSecretAccessPolicy ap => Mapper
.Map<Core.SecretsManager.Entities.ServiceAccountSecretAccessPolicy>(ap),
_ => throw new ArgumentException("Unsupported access policy type")
};
@ -482,20 +543,26 @@ public class AccessPolicyRepository : BaseEntityFrameworkRepository, IAccessPoli
{
Core.SecretsManager.Entities.UserProjectAccessPolicy accessPolicy => Mapper.Map<UserProjectAccessPolicy>(
accessPolicy),
Core.SecretsManager.Entities.UserSecretAccessPolicy accessPolicy => Mapper.Map<UserSecretAccessPolicy>(
accessPolicy),
Core.SecretsManager.Entities.UserServiceAccountAccessPolicy accessPolicy => Mapper
.Map<UserServiceAccountAccessPolicy>(accessPolicy),
Core.SecretsManager.Entities.GroupProjectAccessPolicy accessPolicy => Mapper.Map<GroupProjectAccessPolicy>(
accessPolicy),
Core.SecretsManager.Entities.GroupSecretAccessPolicy accessPolicy => Mapper.Map<GroupSecretAccessPolicy>(
accessPolicy),
Core.SecretsManager.Entities.GroupServiceAccountAccessPolicy accessPolicy => Mapper
.Map<GroupServiceAccountAccessPolicy>(accessPolicy),
Core.SecretsManager.Entities.ServiceAccountProjectAccessPolicy accessPolicy => Mapper
.Map<ServiceAccountProjectAccessPolicy>(accessPolicy),
Core.SecretsManager.Entities.ServiceAccountSecretAccessPolicy accessPolicy => Mapper
.Map<ServiceAccountSecretAccessPolicy>(accessPolicy),
_ => throw new ArgumentException("Unsupported access policy type")
};
}
private Core.SecretsManager.Entities.BaseAccessPolicy MapToCore(
BaseAccessPolicy baseAccessPolicyEntity, bool currentUserInGroup)
BaseAccessPolicy baseAccessPolicyEntity, bool currentUserInGroup)
{
switch (baseAccessPolicyEntity)
{
@ -505,6 +572,12 @@ public class AccessPolicyRepository : BaseEntityFrameworkRepository, IAccessPoli
mapped.CurrentUserInGroup = currentUserInGroup;
return mapped;
}
case GroupSecretAccessPolicy ap:
{
var mapped = Mapper.Map<Core.SecretsManager.Entities.GroupSecretAccessPolicy>(ap);
mapped.CurrentUserInGroup = currentUserInGroup;
return mapped;
}
case GroupServiceAccountAccessPolicy ap:
{
var mapped = Mapper.Map<Core.SecretsManager.Entities.GroupServiceAccountAccessPolicy>(ap);

View File

@ -16,7 +16,7 @@ public class ProjectRepository : Repository<Core.SecretsManager.Entities.Project
: base(serviceScopeFactory, mapper, db => db.Project)
{ }
public override async Task<Core.SecretsManager.Entities.Project> GetByIdAsync(Guid id)
public override async Task<Core.SecretsManager.Entities.Project?> GetByIdAsync(Guid id)
{
using (var scope = ServiceScopeFactory.CreateScope())
{
@ -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

@ -1,7 +1,9 @@
using System.Linq.Expressions;
using AutoMapper;
using Bit.Core.Enums;
using Bit.Core.SecretsManager.Enums.AccessPolicies;
using Bit.Core.SecretsManager.Models.Data;
using Bit.Core.SecretsManager.Models.Data.AccessPolicyUpdates;
using Bit.Core.SecretsManager.Repositories;
using Bit.Infrastructure.EntityFramework;
using Bit.Infrastructure.EntityFramework.Repositories;
@ -17,7 +19,7 @@ public class SecretRepository : Repository<Core.SecretsManager.Entities.Secret,
: base(serviceScopeFactory, mapper, db => db.Secret)
{ }
public override async Task<Core.SecretsManager.Entities.Secret> GetByIdAsync(Guid id)
public override async Task<Core.SecretsManager.Entities.Secret?> GetByIdAsync(Guid id)
{
using (var scope = ServiceScopeFactory.CreateScope())
{
@ -136,8 +138,8 @@ public class SecretRepository : Repository<Core.SecretsManager.Entities.Secret,
return await secrets.ToListAsync();
}
public override async Task<Core.SecretsManager.Entities.Secret> CreateAsync(
Core.SecretsManager.Entities.Secret secret)
public async Task<Core.SecretsManager.Entities.Secret> CreateAsync(
Core.SecretsManager.Entities.Secret secret, SecretAccessPoliciesUpdates? accessPoliciesUpdates = null)
{
await using var scope = ServiceScopeFactory.CreateAsyncScope();
var dbContext = GetDatabaseContext(scope);
@ -158,13 +160,14 @@ public class SecretRepository : Repository<Core.SecretsManager.Entities.Secret,
}
await dbContext.AddAsync(entity);
await UpdateSecretAccessPoliciesAsync(dbContext, entity, accessPoliciesUpdates);
await dbContext.SaveChangesAsync();
await transaction.CommitAsync();
secret.Id = entity.Id;
return secret;
}
public async Task<Core.SecretsManager.Entities.Secret> UpdateAsync(Core.SecretsManager.Entities.Secret secret)
public async Task<Core.SecretsManager.Entities.Secret> UpdateAsync(Core.SecretsManager.Entities.Secret secret,
SecretAccessPoliciesUpdates? accessPoliciesUpdates = null)
{
await using var scope = ServiceScopeFactory.CreateAsyncScope();
var dbContext = GetDatabaseContext(scope);
@ -173,36 +176,30 @@ public class SecretRepository : Repository<Core.SecretsManager.Entities.Secret,
var entity = await dbContext.Secret
.Include(s => s.Projects)
.Include(s => s.UserAccessPolicies)
.Include(s => s.GroupAccessPolicies)
.Include(s => s.ServiceAccountAccessPolicies)
.FirstAsync(s => s.Id == secret.Id);
var projectsToRemove = entity.Projects.Where(p => mappedEntity.Projects.All(mp => mp.Id != p.Id)).ToList();
var projectsToAdd = mappedEntity.Projects.Where(p => entity.Projects.All(ep => ep.Id != p.Id)).ToList();
dbContext.Entry(entity).CurrentValues.SetValues(mappedEntity);
foreach (var p in projectsToRemove)
if (secret.Projects != null)
{
entity.Projects.Remove(p);
entity = await UpdateProjectMappingAsync(dbContext, entity, mappedEntity);
}
foreach (var project in projectsToAdd)
if (accessPoliciesUpdates != null)
{
var p = dbContext.AttachToOrGet<Project>(x => x.Id == project.Id, () => project);
entity.Projects.Add(p);
}
var projectIds = projectsToRemove.Select(p => p.Id).Concat(projectsToAdd.Select(p => p.Id)).ToList();
if (projectIds.Count > 0)
{
await UpdateServiceAccountRevisionsByProjectIdsAsync(dbContext, projectIds);
await UpdateSecretAccessPoliciesAsync(dbContext, entity, accessPoliciesUpdates);
}
await UpdateServiceAccountRevisionsBySecretIdsAsync(dbContext, [entity.Id]);
dbContext.Entry(entity).CurrentValues.SetValues(mappedEntity);
await dbContext.SaveChangesAsync();
await transaction.CommitAsync();
return secret;
return Mapper.Map<Core.SecretsManager.Entities.Secret>(entity);
}
public async Task SoftDeleteManyByIdAsync(IEnumerable<Guid> ids)
{
await using var scope = ServiceScopeFactory.CreateAsyncScope();
@ -289,41 +286,35 @@ public class SecretRepository : Repository<Core.SecretsManager.Entities.Secret,
public async Task<(bool Read, bool Write)> AccessToSecretAsync(Guid id, Guid userId, AccessClientType accessType)
{
using var scope = ServiceScopeFactory.CreateScope();
await using var scope = ServiceScopeFactory.CreateAsyncScope();
var dbContext = GetDatabaseContext(scope);
var secret = dbContext.Secret
.Where(s => s.Id == id);
var query = accessType switch
{
AccessClientType.NoAccessCheck => secret.Select(_ => new { Read = true, Write = true }),
AccessClientType.User => secret.Select(s => new
{
Read = s.Projects.Any(p =>
p.UserAccessPolicies.Any(ap => ap.OrganizationUser.User.Id == userId && ap.Read) ||
p.GroupAccessPolicies.Any(ap =>
ap.Group.GroupUsers.Any(gu => gu.OrganizationUser.User.Id == userId && ap.Read))),
Write = s.Projects.Any(p =>
p.UserAccessPolicies.Any(ap => ap.OrganizationUser.User.Id == userId && ap.Write) ||
p.GroupAccessPolicies.Any(ap =>
ap.Group.GroupUsers.Any(gu => gu.OrganizationUser.User.Id == userId && ap.Write))),
}),
AccessClientType.ServiceAccount => secret.Select(s => new
{
Read = s.Projects.Any(p =>
p.ServiceAccountAccessPolicies.Any(ap => ap.ServiceAccountId == userId && ap.Read)),
Write = s.Projects.Any(p =>
p.ServiceAccountAccessPolicies.Any(ap => ap.ServiceAccountId == userId && ap.Write)),
}),
_ => secret.Select(_ => new { Read = false, Write = false }),
};
var query = BuildSecretAccessQuery(secret, userId, accessType);
var policy = await query.FirstOrDefaultAsync();
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();
@ -334,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
@ -361,19 +369,27 @@ public class SecretRepository : Repository<Core.SecretsManager.Entities.Secret,
private Expression<Func<Secret, SecretPermissionDetails>> SecretToPermissionsUser(Guid userId, bool read) =>
s => new SecretPermissionDetails
{
Secret = Mapper.Map<Bit.Core.SecretsManager.Entities.Secret>(s),
Secret = Mapper.Map<Core.SecretsManager.Entities.Secret>(s),
Read = read,
Write = s.Projects.Any(p =>
p.UserAccessPolicies.Any(ap => ap.OrganizationUser.User.Id == userId && ap.Write) ||
p.GroupAccessPolicies.Any(ap =>
ap.Group.GroupUsers.Any(gu => gu.OrganizationUser.User.Id == userId && ap.Write))),
Write =
s.UserAccessPolicies.Any(ap => ap.OrganizationUser.User.Id == userId && ap.Write) ||
s.GroupAccessPolicies.Any(ap =>
ap.Group.GroupUsers.Any(gu => gu.OrganizationUser.User.Id == userId && ap.Write)) ||
s.Projects.Any(p =>
p.UserAccessPolicies.Any(ap => ap.OrganizationUser.User.Id == userId && ap.Write) ||
p.GroupAccessPolicies.Any(ap =>
ap.Group.GroupUsers.Any(gu => gu.OrganizationUser.User.Id == userId && ap.Write)))
};
private static Expression<Func<Secret, bool>> ServiceAccountHasReadAccessToSecret(Guid serviceAccountId) => s =>
s.ServiceAccountAccessPolicies.Any(ap => ap.ServiceAccountId == serviceAccountId && ap.Read) ||
s.Projects.Any(p =>
p.ServiceAccountAccessPolicies.Any(ap => ap.ServiceAccount.Id == serviceAccountId && ap.Read));
private static Expression<Func<Secret, bool>> UserHasReadAccessToSecret(Guid userId) => s =>
s.UserAccessPolicies.Any(ap => ap.OrganizationUser.UserId == userId && ap.Read) ||
s.GroupAccessPolicies.Any(ap =>
ap.Group.GroupUsers.Any(gu => gu.OrganizationUser.UserId == userId && ap.Read)) ||
s.Projects.Any(p =>
p.UserAccessPolicies.Any(ap => ap.OrganizationUser.UserId == userId && ap.Read) ||
p.GroupAccessPolicies.Any(ap =>
@ -434,4 +450,197 @@ public class SecretRepository : Repository<Core.SecretsManager.Entities.Secret,
.ExecuteUpdateAsync(setters => setters.SetProperty(b => b.RevisionDate, utcNow));
}
}
private static IQueryable<SecretAccess> BuildSecretAccessQuery(IQueryable<Secret> secrets, Guid accessClientId,
AccessClientType accessType) =>
accessType switch
{
AccessClientType.NoAccessCheck => secrets.Select(s => new SecretAccess(s.Id, true, true)),
AccessClientType.User => secrets.Select(s => new SecretAccess(
s.Id,
s.UserAccessPolicies.Any(ap => ap.OrganizationUser.User.Id == accessClientId && ap.Read) ||
s.GroupAccessPolicies.Any(ap =>
ap.Group.GroupUsers.Any(gu => gu.OrganizationUser.User.Id == accessClientId && ap.Read)) ||
s.Projects.Any(p =>
p.UserAccessPolicies.Any(ap => ap.OrganizationUser.User.Id == accessClientId && ap.Read) ||
p.GroupAccessPolicies.Any(ap =>
ap.Group.GroupUsers.Any(gu => gu.OrganizationUser.User.Id == accessClientId && ap.Read))),
s.UserAccessPolicies.Any(ap => ap.OrganizationUser.User.Id == accessClientId && ap.Write) ||
s.GroupAccessPolicies.Any(ap =>
ap.Group.GroupUsers.Any(gu => gu.OrganizationUser.User.Id == accessClientId && ap.Write)) ||
s.Projects.Any(p =>
p.UserAccessPolicies.Any(ap => ap.OrganizationUser.User.Id == accessClientId && ap.Write) ||
p.GroupAccessPolicies.Any(ap =>
ap.Group.GroupUsers.Any(gu => gu.OrganizationUser.User.Id == accessClientId && ap.Write)))
)),
AccessClientType.ServiceAccount => secrets.Select(s => new SecretAccess(
s.Id,
s.ServiceAccountAccessPolicies.Any(ap => ap.ServiceAccountId == accessClientId && ap.Read) ||
s.Projects.Any(p =>
p.ServiceAccountAccessPolicies.Any(ap => ap.ServiceAccountId == accessClientId && ap.Read)),
s.ServiceAccountAccessPolicies.Any(ap => ap.ServiceAccountId == accessClientId && ap.Write) ||
s.Projects.Any(p =>
p.ServiceAccountAccessPolicies.Any(ap => ap.ServiceAccountId == accessClientId && ap.Write))
)),
_ => secrets.Select(s => new SecretAccess(s.Id, false, false))
};
private static async Task<Secret> UpdateProjectMappingAsync(DatabaseContext dbContext, Secret currentEntity, Secret updatedEntity)
{
var projectsToRemove = currentEntity.Projects.Where(p => updatedEntity.Projects.All(mp => mp.Id != p.Id)).ToList();
var projectsToAdd = updatedEntity.Projects.Where(p => currentEntity.Projects.All(ep => ep.Id != p.Id)).ToList();
foreach (var p in projectsToRemove)
{
currentEntity.Projects.Remove(p);
}
foreach (var project in projectsToAdd)
{
var p = dbContext.AttachToOrGet<Project>(x => x.Id == project.Id, () => project);
currentEntity.Projects.Add(p);
}
var projectIds = projectsToRemove.Select(p => p.Id).Concat(projectsToAdd.Select(p => p.Id)).ToList();
if (projectIds.Count > 0)
{
await UpdateServiceAccountRevisionsByProjectIdsAsync(dbContext, projectIds);
}
return currentEntity;
}
private static async Task DeleteSecretAccessPoliciesAsync(DatabaseContext dbContext, Secret entity,
SecretAccessPoliciesUpdates accessPoliciesUpdates)
{
var userAccessPoliciesIdsToDelete = entity.UserAccessPolicies.Where(uap => accessPoliciesUpdates
.UserAccessPolicyUpdates
.Any(apu => apu.Operation == AccessPolicyOperation.Delete &&
apu.AccessPolicy.OrganizationUserId == uap.OrganizationUserId))
.Select(uap => uap.Id)
.ToList();
var groupAccessPoliciesIdsToDelete = entity.GroupAccessPolicies.Where(gap => accessPoliciesUpdates
.GroupAccessPolicyUpdates
.Any(apu => apu.Operation == AccessPolicyOperation.Delete && apu.AccessPolicy.GroupId == gap.GroupId))
.Select(gap => gap.Id)
.ToList();
var serviceAccountAccessPoliciesIdsToDelete = entity.ServiceAccountAccessPolicies.Where(gap =>
accessPoliciesUpdates.ServiceAccountAccessPolicyUpdates
.Any(apu => apu.Operation == AccessPolicyOperation.Delete &&
apu.AccessPolicy.ServiceAccountId == gap.ServiceAccountId))
.Select(sap => sap.Id)
.ToList();
var accessPoliciesIdsToDelete = userAccessPoliciesIdsToDelete
.Concat(groupAccessPoliciesIdsToDelete)
.Concat(serviceAccountAccessPoliciesIdsToDelete)
.ToList();
await dbContext.AccessPolicies
.Where(ap => accessPoliciesIdsToDelete.Contains(ap.Id))
.ExecuteDeleteAsync();
}
private static async Task UpsertSecretAccessPolicyAsync(DatabaseContext dbContext, BaseAccessPolicy updatedEntity,
AccessPolicyOperation accessPolicyOperation, AccessPolicy? currentEntity, DateTime currentDate)
{
switch (accessPolicyOperation)
{
case AccessPolicyOperation.Create when currentEntity == null:
updatedEntity.SetNewId();
await dbContext.AddAsync(updatedEntity);
break;
case AccessPolicyOperation.Update when currentEntity != null:
dbContext.AccessPolicies.Attach(currentEntity);
currentEntity.Read = updatedEntity.Read;
currentEntity.Write = updatedEntity.Write;
currentEntity.RevisionDate = currentDate;
break;
default:
throw new InvalidOperationException("Policy updates failed due to unexpected state.");
}
}
private async Task UpsertSecretAccessPoliciesAsync(DatabaseContext dbContext,
Secret entity,
SecretAccessPoliciesUpdates policyUpdates)
{
var currentDate = DateTime.UtcNow;
foreach (var policyUpdate in policyUpdates.UserAccessPolicyUpdates.Where(apu =>
apu.Operation != AccessPolicyOperation.Delete))
{
var currentEntity = entity.UserAccessPolicies?.FirstOrDefault(e =>
e.OrganizationUserId == policyUpdate.AccessPolicy.OrganizationUserId!.Value);
await UpsertSecretAccessPolicyAsync(dbContext, MapToEntity(policyUpdate.AccessPolicy),
policyUpdate.Operation,
currentEntity,
currentDate);
}
foreach (var policyUpdate in policyUpdates.GroupAccessPolicyUpdates.Where(apu =>
apu.Operation != AccessPolicyOperation.Delete))
{
var currentEntity = entity.GroupAccessPolicies?.FirstOrDefault(e =>
e.GroupId == policyUpdate.AccessPolicy.GroupId!.Value);
await UpsertSecretAccessPolicyAsync(dbContext, MapToEntity(policyUpdate.AccessPolicy),
policyUpdate.Operation,
currentEntity,
currentDate);
}
foreach (var policyUpdate in policyUpdates.ServiceAccountAccessPolicyUpdates.Where(apu =>
apu.Operation != AccessPolicyOperation.Delete))
{
var currentEntity = entity.ServiceAccountAccessPolicies?.FirstOrDefault(e =>
e.ServiceAccountId == policyUpdate.AccessPolicy.ServiceAccountId!.Value);
await UpsertSecretAccessPolicyAsync(dbContext, MapToEntity(policyUpdate.AccessPolicy),
policyUpdate.Operation,
currentEntity,
currentDate);
}
}
private async Task UpdateSecretAccessPoliciesAsync(DatabaseContext dbContext,
Secret entity,
SecretAccessPoliciesUpdates? accessPoliciesUpdates)
{
if (accessPoliciesUpdates == null || !accessPoliciesUpdates.HasUpdates())
{
return;
}
if ((entity.UserAccessPolicies != null && entity.UserAccessPolicies.Count != 0) ||
(entity.GroupAccessPolicies != null && entity.GroupAccessPolicies.Count != 0) ||
(entity.ServiceAccountAccessPolicies != null && entity.ServiceAccountAccessPolicies.Count != 0))
{
await DeleteSecretAccessPoliciesAsync(dbContext, entity, accessPoliciesUpdates);
}
await UpsertSecretAccessPoliciesAsync(dbContext, entity, accessPoliciesUpdates);
await UpdateServiceAccountRevisionsAsync(dbContext,
accessPoliciesUpdates.ServiceAccountAccessPolicyUpdates
.Select(sap => sap.AccessPolicy.ServiceAccountId!.Value).ToList());
}
private BaseAccessPolicy MapToEntity(Core.SecretsManager.Entities.BaseAccessPolicy baseAccessPolicy) =>
baseAccessPolicy switch
{
Core.SecretsManager.Entities.UserSecretAccessPolicy accessPolicy => Mapper.Map<UserSecretAccessPolicy>(
accessPolicy),
Core.SecretsManager.Entities.GroupSecretAccessPolicy accessPolicy => Mapper.Map<GroupSecretAccessPolicy>(
accessPolicy),
Core.SecretsManager.Entities.ServiceAccountSecretAccessPolicy accessPolicy => Mapper
.Map<ServiceAccountSecretAccessPolicy>(accessPolicy),
_ => throw new ArgumentException("Unsupported access policy type")
};
private record SecretAccess(Guid Id, bool Read, bool Write);
}

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();
@ -135,38 +177,37 @@ public class ServiceAccountRepository : Repository<Core.SecretsManager.Entities.
}
public async Task<IEnumerable<ServiceAccountSecretsDetails>> GetManyByOrganizationIdWithSecretsDetailsAsync(
Guid organizationId, Guid userId, AccessClientType accessType)
Guid organizationId, Guid userId, AccessClientType accessType)
{
using var scope = ServiceScopeFactory.CreateScope();
await using var scope = ServiceScopeFactory.CreateAsyncScope();
var dbContext = GetDatabaseContext(scope);
var query = from sa in dbContext.ServiceAccount
join ap in dbContext.ServiceAccountProjectAccessPolicy
on sa.Id equals ap.ServiceAccountId into grouping
from ap in grouping.DefaultIfEmpty()
where sa.OrganizationId == organizationId
select new
{
ServiceAccount = sa,
AccessToSecrets = ap.GrantedProject.Secrets.Count(s => s.DeletedDate == null)
};
query = accessType switch
var serviceAccountQuery = dbContext.ServiceAccount.Where(c => c.OrganizationId == organizationId);
serviceAccountQuery = accessType switch
{
AccessClientType.NoAccessCheck => query,
AccessClientType.User => query.Where(c =>
c.ServiceAccount.UserAccessPolicies.Any(ap => ap.OrganizationUser.User.Id == userId && ap.Read) ||
c.ServiceAccount.GroupAccessPolicies.Any(ap =>
AccessClientType.NoAccessCheck => serviceAccountQuery,
AccessClientType.User => serviceAccountQuery.Where(c =>
c.UserAccessPolicies.Any(ap => ap.OrganizationUser.User.Id == userId && ap.Read) ||
c.GroupAccessPolicies.Any(ap =>
ap.Group.GroupUsers.Any(gu => gu.OrganizationUser.User.Id == userId && ap.Read))),
_ => throw new ArgumentOutOfRangeException(nameof(accessType), accessType, null),
};
var results = (await query.ToListAsync())
var projectSecretsAccessQuery = BuildProjectSecretsAccessQuery(dbContext, serviceAccountQuery);
var directSecretAccessQuery = BuildDirectSecretAccessQuery(dbContext, serviceAccountQuery);
var projectSecretsAccessResults = await projectSecretsAccessQuery.ToListAsync();
var directSecretAccessResults = await directSecretAccessQuery.ToListAsync();
var applicableDirectSecretAccessResults = FilterDirectSecretAccessResults(projectSecretsAccessResults, directSecretAccessResults);
var results = projectSecretsAccessResults.Concat(applicableDirectSecretAccessResults)
.GroupBy(g => g.ServiceAccount)
.Select(g =>
new ServiceAccountSecretsDetails
{
ServiceAccount = Mapper.Map<Core.SecretsManager.Entities.ServiceAccount>(g.Key),
AccessToSecrets = g.Sum(x => x.AccessToSecrets),
AccessToSecrets = g.Sum(x => x.SecretIds.Count())
}).OrderBy(c => c.ServiceAccount.RevisionDate).ToList();
return results;
@ -200,4 +241,46 @@ public class ServiceAccountRepository : Repository<Core.SecretsManager.Entities.
private static Expression<Func<ServiceAccount, bool>> UserHasWriteAccessToServiceAccount(Guid userId) => sa =>
sa.UserAccessPolicies.Any(ap => ap.OrganizationUser.User.Id == userId && ap.Write) ||
sa.GroupAccessPolicies.Any(ap => ap.Group.GroupUsers.Any(gu => gu.OrganizationUser.User.Id == userId && ap.Write));
private static IQueryable<ServiceAccountSecretsAccess> BuildProjectSecretsAccessQuery(DatabaseContext dbContext,
IQueryable<ServiceAccount> serviceAccountQuery) =>
from sa in serviceAccountQuery
join ap in dbContext.ServiceAccountProjectAccessPolicy
on sa.Id equals ap.ServiceAccountId into grouping
from ap in grouping.DefaultIfEmpty()
select new ServiceAccountSecretsAccess
(
sa, ap.GrantedProject.Secrets.Where(s => s.DeletedDate == null).Select(s => s.Id)
);
private static IQueryable<ServiceAccountSecretsAccess> BuildDirectSecretAccessQuery(
DatabaseContext dbContext,
IQueryable<ServiceAccount> serviceAccountQuery) =>
from sa in serviceAccountQuery
join ap in dbContext.ServiceAccountSecretAccessPolicy
on sa.Id equals ap.ServiceAccountId into grouping
from ap in grouping.DefaultIfEmpty()
where ap.GrantedSecret.DeletedDate == null &&
ap.GrantedSecretId != null
select new ServiceAccountSecretsAccess(sa,
new List<Guid> { ap.GrantedSecretId!.Value });
private static List<ServiceAccountSecretsAccess> FilterDirectSecretAccessResults(
List<ServiceAccountSecretsAccess> projectSecretsAccessResults,
List<ServiceAccountSecretsAccess> directSecretAccessResults) =>
directSecretAccessResults.Where(directSecretAccessResult =>
{
var serviceAccountId = directSecretAccessResult.ServiceAccount.Id;
var secretId = directSecretAccessResult.SecretIds.FirstOrDefault();
if (secretId == Guid.Empty)
{
return false;
}
return !projectSecretsAccessResults
.Where(x => x.ServiceAccount.Id == serviceAccountId)
.Any(x => x.SecretIds.Contains(secretId));
}).ToList();
private record ServiceAccountSecretsAccess(ServiceAccount ServiceAccount, IEnumerable<Guid> SecretIds);
}

View File

@ -1,8 +1,66 @@
namespace Bit.Scim.Models;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.Enums;
using Bit.Core.Models.Business;
using Bit.Core.Models.Data;
using Bit.Core.Utilities;
namespace Bit.Scim.Models;
public class ScimUserRequestModel : BaseScimUserModel
{
public ScimUserRequestModel()
: base(false)
{ }
public OrganizationUserInvite ToOrganizationUserInvite(ScimProviderType scimProvider)
{
return new OrganizationUserInvite
{
Emails = new[] { EmailForInvite(scimProvider) },
// Permissions cannot be set via SCIM so we use default values
Type = OrganizationUserType.User,
Collections = new List<CollectionAccessSelection>(),
Groups = new List<Guid>()
};
}
private string EmailForInvite(ScimProviderType scimProvider)
{
var email = PrimaryEmail?.ToLowerInvariant();
if (!string.IsNullOrWhiteSpace(email))
{
return email;
}
switch (scimProvider)
{
case ScimProviderType.AzureAd:
return UserName?.ToLowerInvariant();
default:
email = WorkEmail?.ToLowerInvariant();
if (string.IsNullOrWhiteSpace(email))
{
email = Emails?.FirstOrDefault()?.Value?.ToLowerInvariant();
}
return email;
}
}
public string ExternalIdForInvite()
{
if (!string.IsNullOrWhiteSpace(ExternalId))
{
return ExternalId;
}
if (!string.IsNullOrWhiteSpace(UserName))
{
return UserName;
}
return CoreHelpers.RandomString(15);
}
}

View File

@ -1,11 +1,8 @@
using Bit.Core.AdminConsole.Enums;
using Bit.Core.Enums;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Data;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Bit.Scim.Context;
using Bit.Scim.Models;
using Bit.Scim.Users.Interfaces;
@ -14,39 +11,33 @@ namespace Bit.Scim.Users;
public class PostUserCommand : IPostUserCommand
{
private readonly IOrganizationRepository _organizationRepository;
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IOrganizationService _organizationService;
private readonly IPaymentService _paymentService;
private readonly IScimContext _scimContext;
public PostUserCommand(
IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository,
IOrganizationService organizationService,
IPaymentService paymentService,
IScimContext scimContext)
{
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
_organizationService = organizationService;
_paymentService = paymentService;
_scimContext = scimContext;
}
public async Task<OrganizationUserUserDetails> PostUserAsync(Guid organizationId, ScimUserRequestModel model)
{
var email = model.PrimaryEmail?.ToLowerInvariant();
if (string.IsNullOrWhiteSpace(email))
{
switch (_scimContext.RequestScimProvider)
{
case ScimProviderType.AzureAd:
email = model.UserName?.ToLowerInvariant();
break;
default:
email = model.WorkEmail?.ToLowerInvariant();
if (string.IsNullOrWhiteSpace(email))
{
email = model.Emails?.FirstOrDefault()?.Value?.ToLowerInvariant();
}
break;
}
}
var scimProvider = _scimContext.RequestScimProvider;
var invite = model.ToOrganizationUserInvite(scimProvider);
var email = invite.Emails.Single();
var externalId = model.ExternalIdForInvite();
if (string.IsNullOrWhiteSpace(email) || !model.Active)
{
@ -60,28 +51,18 @@ public class PostUserCommand : IPostUserCommand
throw new ConflictException();
}
string externalId = null;
if (!string.IsNullOrWhiteSpace(model.ExternalId))
{
externalId = model.ExternalId;
}
else if (!string.IsNullOrWhiteSpace(model.UserName))
{
externalId = model.UserName;
}
else
{
externalId = CoreHelpers.RandomString(15);
}
var orgUserByExternalId = orgUsers.FirstOrDefault(ou => ou.ExternalId == externalId);
if (orgUserByExternalId != null)
{
throw new ConflictException();
}
var invitedOrgUser = await _organizationService.InviteUserAsync(organizationId, EventSystemUser.SCIM, email,
OrganizationUserType.User, false, externalId, new List<CollectionAccessSelection>(), new List<Guid>());
var organization = await _organizationRepository.GetByIdAsync(organizationId);
var hasStandaloneSecretsManager = await _paymentService.HasSecretsManagerStandalone(organization);
invite.AccessSecretsManager = hasStandaloneSecretsManager;
var invitedOrgUser = await _organizationService.InviteUserAsync(organizationId, invitingUserId: null, EventSystemUser.SCIM,
invite, externalId);
var orgUser = await _organizationUserRepository.GetDetailsByIdAsync(invitedOrgUser.Id);
return orgUser;

View File

@ -8,6 +8,7 @@ using Bit.Core.Auth.Models;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.Models.Data;
using Bit.Core.Auth.Repositories;
using Bit.Core.Auth.UserFeatures.Registration;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Api;
@ -51,6 +52,7 @@ public class AccountController : Controller
private readonly Core.Services.IEventService _eventService;
private readonly IDataProtectorTokenFactory<SsoTokenable> _dataProtector;
private readonly IOrganizationDomainRepository _organizationDomainRepository;
private readonly IRegisterUserCommand _registerUserCommand;
public AccountController(
IAuthenticationSchemeProvider schemeProvider,
@ -70,7 +72,8 @@ public class AccountController : Controller
IGlobalSettings globalSettings,
Core.Services.IEventService eventService,
IDataProtectorTokenFactory<SsoTokenable> dataProtector,
IOrganizationDomainRepository organizationDomainRepository)
IOrganizationDomainRepository organizationDomainRepository,
IRegisterUserCommand registerUserCommand)
{
_schemeProvider = schemeProvider;
_clientStore = clientStore;
@ -90,6 +93,7 @@ public class AccountController : Controller
_globalSettings = globalSettings;
_dataProtector = dataProtector;
_organizationDomainRepository = organizationDomainRepository;
_registerUserCommand = registerUserCommand;
}
[HttpGet]
@ -483,7 +487,8 @@ public class AccountController : Controller
if (orgUser.Status == OrganizationUserStatusType.Invited)
{
// Org User is invited - they must manually accept the invite via email and authenticate with MP
throw new Exception(_i18nService.T("UserAlreadyInvited", email, organization.DisplayName()));
// This allows us to enroll them in MP reset if required
throw new Exception(_i18nService.T("AcceptInviteBeforeUsingSSO", organization.DisplayName()));
}
// Accepted or Confirmed - create SSO link and return;
@ -497,7 +502,6 @@ public class AccountController : Controller
var occupiedSeats = await _organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
var initialSeatCount = organization.Seats.Value;
var availableSeats = initialSeatCount - occupiedSeats;
var prorationDate = DateTime.UtcNow;
if (availableSeats < 1)
{
try
@ -507,13 +511,13 @@ public class AccountController : Controller
throw new Exception("Cannot autoscale on self-hosted instance.");
}
await _organizationService.AutoAddSeatsAsync(organization, 1, prorationDate);
await _organizationService.AutoAddSeatsAsync(organization, 1);
}
catch (Exception e)
{
if (organization.Seats.Value != initialSeatCount)
{
await _organizationService.AdjustSeatsAsync(orgId, initialSeatCount - organization.Seats.Value, prorationDate);
await _organizationService.AdjustSeatsAsync(orgId, initialSeatCount - organization.Seats.Value);
}
_logger.LogInformation(e, "SSO auto provisioning failed");
throw new Exception(_i18nService.T("NoSeatsAvailable", organization.DisplayName()));
@ -538,7 +542,7 @@ public class AccountController : Controller
EmailVerified = emailVerified,
ApiKey = CoreHelpers.SecureRandomString(30)
};
await _userService.RegisterUserAsync(user);
await _registerUserCommand.RegisterUser(user);
// If the organization has 2fa policy enabled, make sure to default jit user 2fa to email
var twoFactorPolicy =

View File

@ -10,7 +10,7 @@
<!-- This is a transitive dependency to Sustainsys.Saml2.AspNetCore2 -->
<PackageReference Include="Microsoft.AspNetCore.Http" Version="2.2.2" />
<PackageReference Include="Sustainsys.Saml2.AspNetCore2" Version="2.9.2" />
<PackageReference Include="Sustainsys.Saml2.AspNetCore2" Version="2.10.0" />
</ItemGroup>
<ItemGroup>

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": "5.3.3",
"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.77.8",
"sass-loader": "16.0.0",
"webpack": "5.94.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

@ -4,17 +4,19 @@ using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Services;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using Stripe;
using Xunit;
using IMailService = Bit.Core.Services.IMailService;
namespace Bit.Commercial.Core.Test.AdminConsole.ProviderFeatures;
@ -74,9 +76,9 @@ public class RemoveOrganizationFromProviderCommandTests
providerOrganization.ProviderId = provider.Id;
sutProvider.GetDependency<IOrganizationService>().HasConfirmedOwnersExceptAsync(
providerOrganization.OrganizationId,
Array.Empty<Guid>(),
includeProvider: false)
providerOrganization.OrganizationId,
[],
includeProvider: false)
.Returns(false);
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization));
@ -85,56 +87,53 @@ public class RemoveOrganizationFromProviderCommandTests
}
[Theory, BitAutoData]
public async Task RemoveOrganizationFromProvider_NoStripeObjects_MakesCorrectInvocations(
public async Task RemoveOrganizationFromProvider_OrganizationNotStripeEnabled_MakesCorrectInvocations(
Provider provider,
ProviderOrganization providerOrganization,
Organization organization,
SutProvider<RemoveOrganizationFromProviderCommand> sutProvider)
{
providerOrganization.ProviderId = provider.Id;
organization.GatewayCustomerId = null;
organization.GatewaySubscriptionId = null;
providerOrganization.ProviderId = provider.Id;
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
sutProvider.GetDependency<IOrganizationService>().HasConfirmedOwnersExceptAsync(
providerOrganization.OrganizationId,
Array.Empty<Guid>(),
[],
includeProvider: false)
.Returns(true);
var organizationOwnerEmails = new List<string> { "a@gmail.com", "b@gmail.com" };
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
organizationRepository.GetOwnerEmailAddressesById(organization.Id).Returns(organizationOwnerEmails);
organizationRepository.GetOwnerEmailAddressesById(organization.Id).Returns([
"a@example.com",
"b@example.com"
]);
await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization);
await organizationRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(
org => org.Id == organization.Id && org.BillingEmail == "a@gmail.com"));
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
await stripeAdapter.DidNotReceiveWithAnyArgs().CustomerUpdateAsync(Arg.Any<string>(), Arg.Any<CustomerUpdateOptions>());
await stripeAdapter.DidNotReceiveWithAnyArgs().SubscriptionUpdateAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>());
await sutProvider.GetDependency<IMailService>().DidNotReceiveWithAnyArgs().SendProviderUpdatePaymentMethod(
Arg.Any<Guid>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<IEnumerable<string>>());
await organizationRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(org => org.BillingEmail == "a@example.com"));
await sutProvider.GetDependency<IProviderOrganizationRepository>().Received(1)
.DeleteAsync(providerOrganization);
await sutProvider.GetDependency<IEventService>().Received(1).LogProviderOrganizationEventAsync(
providerOrganization,
EventType.ProviderOrganization_Removed);
await sutProvider.GetDependency<IEventService>().Received(1)
.LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_Removed);
await sutProvider.GetDependency<IMailService>().Received(1)
.SendProviderUpdatePaymentMethod(
organization.Id,
organization.Name,
provider.Name,
Arg.Is<IEnumerable<string>>(emails => emails.FirstOrDefault() == "a@example.com"));
await sutProvider.GetDependency<IStripeAdapter>().DidNotReceiveWithAnyArgs()
.CustomerUpdateAsync(Arg.Any<string>(), Arg.Any<CustomerUpdateOptions>());
}
[Theory, BitAutoData]
public async Task RemoveOrganizationFromProvider_MakesCorrectInvocations__FeatureFlagOff(
public async Task RemoveOrganizationFromProvider_OrganizationStripeEnabled_NonConsolidatedBilling_MakesCorrectInvocations(
Provider provider,
ProviderOrganization providerOrganization,
Organization organization,
@ -142,104 +141,150 @@ public class RemoveOrganizationFromProviderCommandTests
{
providerOrganization.ProviderId = provider.Id;
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
sutProvider.GetDependency<IOrganizationService>().HasConfirmedOwnersExceptAsync(
providerOrganization.OrganizationId,
Array.Empty<Guid>(),
[],
includeProvider: false)
.Returns(true);
var organizationOwnerEmails = new List<string> { "a@example.com", "b@example.com" };
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
organizationRepository.GetOwnerEmailAddressesById(organization.Id).Returns(organizationOwnerEmails);
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
stripeAdapter.SubscriptionCreateAsync(default).ReturnsForAnyArgs(new Stripe.Subscription
{
Id = "S-1",
CurrentPeriodEnd = DateTime.Today.AddDays(10),
});
organizationRepository.GetOwnerEmailAddressesById(organization.Id).Returns([
"a@example.com",
"b@example.com"
]);
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling)
.Returns(false);
sutProvider.GetDependency<IStripeAdapter>().SubscriptionGetAsync(organization.GatewaySubscriptionId)
.Returns(GetSubscription(organization.GatewaySubscriptionId));
await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization);
await organizationRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(
org => org.Id == organization.Id && org.BillingEmail == "a@example.com"));
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
await stripeAdapter.Received(1).CustomerUpdateAsync(
organization.GatewayCustomerId, Arg.Is<CustomerUpdateOptions>(
options => options.Coupon == string.Empty && options.Email == "a@example.com"));
await stripeAdapter.Received(1).CustomerUpdateAsync(organization.GatewayCustomerId,
Arg.Is<CustomerUpdateOptions>(options =>
options.Coupon == string.Empty && options.Email == "a@example.com"));
await sutProvider.GetDependency<IMailService>().Received(1).SendProviderUpdatePaymentMethod(
organization.Id,
organization.Name,
provider.Name,
Arg.Is<IEnumerable<string>>(emails => emails.Contains("a@example.com") && emails.Contains("b@example.com")));
await stripeAdapter.Received(1).SubscriptionUpdateAsync(organization.GatewaySubscriptionId,
Arg.Is<SubscriptionUpdateOptions>(options =>
options.CollectionMethod == StripeConstants.CollectionMethod.SendInvoice &&
options.DaysUntilDue == 30));
await sutProvider.GetDependency<ISubscriberService>().Received(1).RemovePaymentSource(organization);
await organizationRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(org => org.BillingEmail == "a@example.com"));
await sutProvider.GetDependency<IProviderOrganizationRepository>().Received(1)
.DeleteAsync(providerOrganization);
await sutProvider.GetDependency<IEventService>().Received(1).LogProviderOrganizationEventAsync(
providerOrganization,
EventType.ProviderOrganization_Removed);
await sutProvider.GetDependency<IEventService>().Received(1)
.LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_Removed);
await sutProvider.GetDependency<IMailService>().Received(1)
.SendProviderUpdatePaymentMethod(
organization.Id,
organization.Name,
provider.Name,
Arg.Is<IEnumerable<string>>(emails => emails.FirstOrDefault() == "a@example.com"));
}
[Theory, BitAutoData]
public async Task RemoveOrganizationFromProvider_CreatesSubscriptionAndScalesSeats_FeatureFlagON(Provider provider,
public async Task RemoveOrganizationFromProvider_OrganizationStripeEnabled_ConsolidatedBilling_MakesCorrectInvocations(
Provider provider,
ProviderOrganization providerOrganization,
Organization organization,
SutProvider<RemoveOrganizationFromProviderCommand> sutProvider)
{
providerOrganization.ProviderId = provider.Id;
provider.Status = ProviderStatusType.Billable;
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
providerOrganization.ProviderId = provider.Id;
organization.Status = OrganizationStatusType.Managed;
organization.PlanType = PlanType.TeamsMonthly;
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
sutProvider.GetDependency<IOrganizationService>().HasConfirmedOwnersExceptAsync(
providerOrganization.OrganizationId,
Array.Empty<Guid>(),
[],
includeProvider: false)
.Returns(true);
var organizationOwnerEmails = new List<string> { "a@example.com", "b@example.com" };
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
organizationRepository.GetOwnerEmailAddressesById(organization.Id).Returns(organizationOwnerEmails);
organizationRepository.GetOwnerEmailAddressesById(organization.Id).Returns([
"a@example.com",
"b@example.com"
]);
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling)
.Returns(true);
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
stripeAdapter.SubscriptionCreateAsync(default).ReturnsForAnyArgs(new Stripe.Subscription
stripeAdapter.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(new Subscription
{
Id = "S-1",
CurrentPeriodEnd = DateTime.Today.AddDays(10),
Id = "subscription_id"
});
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling).Returns(true);
await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization);
await stripeAdapter.Received(1).CustomerUpdateAsync(
organization.GatewayCustomerId, Arg.Is<CustomerUpdateOptions>(
options => options.Coupon == string.Empty && options.Email == "a@example.com"));
await stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(c =>
c.Customer == organization.GatewayCustomerId &&
c.CollectionMethod == "send_invoice" &&
c.DaysUntilDue == 30 &&
c.Items.Count == 1
));
await stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(options =>
options.Customer == organization.GatewayCustomerId &&
options.CollectionMethod == StripeConstants.CollectionMethod.SendInvoice &&
options.DaysUntilDue == 30 &&
options.AutomaticTax.Enabled == true &&
options.Metadata["organizationId"] == organization.Id.ToString() &&
options.OffSession == true &&
options.ProrationBehavior == StripeConstants.ProrationBehavior.CreateProrations &&
options.Items.First().Price == teamsMonthlyPlan.PasswordManager.StripeSeatPlanId &&
options.Items.First().Quantity == organization.Seats));
await sutProvider.GetDependency<IScaleSeatsCommand>().Received(1)
.ScalePasswordManagerSeats(provider, organization.PlanType, -(int)organization.Seats);
await sutProvider.GetDependency<IProviderBillingService>().Received(1)
.ScaleSeats(provider, organization.PlanType, -organization.Seats ?? 0);
await organizationRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(
org => org.Id == organization.Id && org.BillingEmail == "a@example.com" &&
org.GatewaySubscriptionId == "S-1"));
await sutProvider.GetDependency<IMailService>().Received(1).SendProviderUpdatePaymentMethod(
organization.Id,
organization.Name,
provider.Name,
Arg.Is<IEnumerable<string>>(emails =>
emails.Contains("a@example.com") && emails.Contains("b@example.com")));
org =>
org.BillingEmail == "a@example.com" &&
org.GatewaySubscriptionId == "subscription_id" &&
org.Status == OrganizationStatusType.Created));
await sutProvider.GetDependency<IProviderOrganizationRepository>().Received(1)
.DeleteAsync(providerOrganization);
await sutProvider.GetDependency<IEventService>().Received(1).LogProviderOrganizationEventAsync(
providerOrganization,
EventType.ProviderOrganization_Removed);
await sutProvider.GetDependency<IEventService>().Received(1)
.LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_Removed);
await sutProvider.GetDependency<IMailService>().Received(1)
.SendProviderUpdatePaymentMethod(
organization.Id,
organization.Name,
provider.Name,
Arg.Is<IEnumerable<string>>(emails => emails.FirstOrDefault() == "a@example.com"));
}
private static Subscription GetSubscription(string subscriptionId) =>
new()
{
Id = subscriptionId,
Status = StripeConstants.SubscriptionStatus.Active,
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
new()
{
Id = "sub_item_123",
Price = new Price()
{
Id = "2023-enterprise-org-seat-annually"
}
}
}
}
};
}

View File

@ -7,6 +7,8 @@ using Bit.Core.AdminConsole.Enums.Provider;
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;
@ -80,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)
{
@ -612,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)
{
@ -631,17 +678,16 @@ public class ProviderServiceTests
.Received().LogProviderOrganizationEventAsync(providerOrganization,
EventType.ProviderOrganization_Created);
await sutProvider.GetDependency<IOrganizationService>()
.Received().InviteUsersAsync(organization.Id, user.Id, Arg.Is<IEnumerable<(OrganizationUserInvite, string)>>(
.Received().InviteUsersAsync(organization.Id, user.Id, systemUser: null, Arg.Is<IEnumerable<(OrganizationUserInvite, string)>>(
t => t.Count() == 1 &&
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,
@ -670,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,
@ -709,19 +755,19 @@ public class ProviderServiceTests
.InviteUsersAsync(
organization.Id,
user.Id,
systemUser: null,
Arg.Is<IEnumerable<(OrganizationUserInvite, string)>>(
t =>
t.Count() == 1 &&
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)
{
@ -740,12 +786,11 @@ public class ProviderServiceTests
.Received().LogProviderOrganizationEventAsync(providerOrganization,
EventType.ProviderOrganization_Created);
await sutProvider.GetDependency<IOrganizationService>()
.Received().InviteUsersAsync(organization.Id, user.Id, Arg.Is<IEnumerable<(OrganizationUserInvite, string)>>(
.Received().InviteUsersAsync(organization.Id, user.Id, systemUser: null, Arg.Is<IEnumerable<(OrganizationUserInvite, string)>>(
t => t.Count() == 1 &&
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

@ -0,0 +1,656 @@
using System.Reflection;
using System.Security.Claims;
using Bit.Commercial.Core.SecretsManager.AuthorizationHandlers.AccessPolicies;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.SecretsManager.AuthorizationRequirements;
using Bit.Core.SecretsManager.Entities;
using Bit.Core.SecretsManager.Enums.AccessPolicies;
using Bit.Core.SecretsManager.Models.Data.AccessPolicyUpdates;
using Bit.Core.SecretsManager.Queries.AccessPolicies.Interfaces;
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.AccessPolicies;
[SutProviderCustomize]
[ProjectCustomize]
public class SecretAccessPoliciesUpdatesAuthorizationHandlerTests
{
[Fact]
public void SecretAccessPoliciesOperations_OnlyPublicStatic()
{
var publicStaticFields =
typeof(SecretAccessPoliciesOperations).GetFields(BindingFlags.Public | BindingFlags.Static);
var allFields = typeof(SecretAccessPoliciesOperations).GetFields();
Assert.Equal(publicStaticFields.Length, allFields.Length);
}
[Theory]
[BitAutoData]
public async Task Handler_AccessSecretsManagerFalse_DoesNotSucceed(
SutProvider<SecretAccessPoliciesUpdatesAuthorizationHandler> sutProvider,
SecretAccessPoliciesUpdates resource,
ClaimsPrincipal claimsPrincipal)
{
var requirement = SecretAccessPoliciesOperations.Updates;
sutProvider.GetDependency<ICurrentContext>().AccessSecretsManager(resource.OrganizationId)
.Returns(false);
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
claimsPrincipal, resource);
await sutProvider.Sut.HandleAsync(authzContext);
Assert.False(authzContext.HasSucceeded);
}
[Theory]
[BitAutoData(AccessClientType.ServiceAccount)]
[BitAutoData(AccessClientType.Organization)]
public async Task Handler_UnsupportedClientTypes_DoesNotSucceed(
AccessClientType accessClientType,
SutProvider<SecretAccessPoliciesUpdatesAuthorizationHandler> sutProvider,
SecretAccessPoliciesUpdates resource,
ClaimsPrincipal claimsPrincipal)
{
var requirement = SecretAccessPoliciesOperations.Updates;
SetupUserSubstitutes(sutProvider, accessClientType, resource);
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
claimsPrincipal, resource);
await sutProvider.Sut.HandleAsync(authzContext);
Assert.False(authzContext.HasSucceeded);
}
[Theory]
[BitAutoData]
public async Task Handler_UnsupportedServiceAccountGrantedPoliciesOperationRequirement_Throws(
SutProvider<SecretAccessPoliciesUpdatesAuthorizationHandler> sutProvider,
SecretAccessPoliciesUpdates resource,
ClaimsPrincipal claimsPrincipal)
{
var requirement = new SecretAccessPoliciesOperationRequirement();
SetupUserSubstitutes(sutProvider, AccessClientType.NoAccessCheck, resource);
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
claimsPrincipal, resource);
await Assert.ThrowsAsync<ArgumentException>(() => sutProvider.Sut.HandleAsync(authzContext));
}
[Theory]
[BitAutoData(AccessClientType.NoAccessCheck, false, false)]
[BitAutoData(AccessClientType.NoAccessCheck, true, false)]
[BitAutoData(AccessClientType.User, false, false)]
[BitAutoData(AccessClientType.User, true, false)]
public async Task Handler_CanUpdateAsync_UserHasNoWriteAccessToSecret_DoesNotSucceed(
AccessClientType accessClientType,
bool readAccess,
bool writeAccess,
SutProvider<SecretAccessPoliciesUpdatesAuthorizationHandler> sutProvider,
SecretAccessPoliciesUpdates resource,
Guid userId,
ClaimsPrincipal claimsPrincipal)
{
var requirement = SecretAccessPoliciesOperations.Updates;
SetupUserSubstitutes(sutProvider, accessClientType, resource, userId);
sutProvider.GetDependency<ISecretRepository>()
.AccessToSecretAsync(resource.SecretId, userId, accessClientType)
.Returns((readAccess, writeAccess));
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
claimsPrincipal, resource);
await sutProvider.Sut.HandleAsync(authzContext);
Assert.False(authzContext.HasSucceeded);
}
[Theory]
[BitAutoData(false, false, false)]
[BitAutoData(true, false, false)]
[BitAutoData(false, true, false)]
[BitAutoData(true, true, false)]
[BitAutoData(false, false, true)]
[BitAutoData(true, false, true)]
[BitAutoData(false, true, true)]
public async Task Handler_CanUpdateAsync_TargetGranteesNotInSameOrganization_DoesNotSucceed(
bool orgUsersInSameOrg,
bool groupsInSameOrg,
bool serviceAccountsInSameOrg,
SutProvider<SecretAccessPoliciesUpdatesAuthorizationHandler> sutProvider,
SecretAccessPoliciesUpdates resource,
Guid userId,
ClaimsPrincipal claimsPrincipal)
{
var requirement = SecretAccessPoliciesOperations.Updates;
SetupSameOrganizationRequest(sutProvider, AccessClientType.NoAccessCheck, resource, userId, orgUsersInSameOrg,
groupsInSameOrg, serviceAccountsInSameOrg);
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
claimsPrincipal, resource);
await sutProvider.Sut.HandleAsync(authzContext);
Assert.False(authzContext.HasSucceeded);
}
[Theory]
[BitAutoData(false, false, false)]
[BitAutoData(true, false, false)]
[BitAutoData(false, true, false)]
[BitAutoData(true, true, false)]
[BitAutoData(false, false, true)]
[BitAutoData(true, false, true)]
[BitAutoData(false, true, true)]
public async Task Handler_CanUpdateAsync_TargetGranteesNotInSameOrganizationHasZeroRequests_DoesNotSucceed(
bool orgUsersCountZero,
bool groupsCountZero,
bool serviceAccountsCountZero,
SutProvider<SecretAccessPoliciesUpdatesAuthorizationHandler> sutProvider,
SecretAccessPoliciesUpdates resource,
Guid userId,
ClaimsPrincipal claimsPrincipal)
{
var requirement = SecretAccessPoliciesOperations.Updates;
resource = ClearAccessPolicyUpdate(resource, orgUsersCountZero, groupsCountZero, serviceAccountsCountZero);
SetupSameOrganizationRequest(sutProvider, AccessClientType.NoAccessCheck, resource, userId, false, false,
false);
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
claimsPrincipal, resource);
await sutProvider.Sut.HandleAsync(authzContext);
Assert.False(authzContext.HasSucceeded);
}
[Theory]
[BitAutoData(AccessClientType.NoAccessCheck)]
[BitAutoData(AccessClientType.User)]
public async Task Handler_CanUpdateAsync_NoServiceAccountCreatesRequested_Success(
AccessClientType accessClientType,
SutProvider<SecretAccessPoliciesUpdatesAuthorizationHandler> sutProvider,
SecretAccessPoliciesUpdates resource,
Guid userId,
ClaimsPrincipal claimsPrincipal)
{
var requirement = SecretAccessPoliciesOperations.Updates;
resource = RemoveAllServiceAccountCreates(resource);
SetupSameOrganizationRequest(sutProvider, accessClientType, resource, userId);
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
claimsPrincipal, resource);
await sutProvider.Sut.HandleAsync(authzContext);
Assert.True(authzContext.HasSucceeded);
}
[Theory]
[BitAutoData(AccessClientType.NoAccessCheck)]
[BitAutoData(AccessClientType.User)]
public async Task Handler_CanUpdateAsync_NoAccessToTargetServiceAccounts_DoesNotSucceed(
AccessClientType accessClientType,
SutProvider<SecretAccessPoliciesUpdatesAuthorizationHandler> sutProvider,
SecretAccessPoliciesUpdates resource,
Guid userId,
ClaimsPrincipal claimsPrincipal)
{
var requirement = SecretAccessPoliciesOperations.Updates;
SetupSameOrganizationRequest(sutProvider, accessClientType, resource, userId);
SetupNoServiceAccountAccess(sutProvider, resource, userId, accessClientType);
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
claimsPrincipal, resource);
await sutProvider.Sut.HandleAsync(authzContext);
Assert.False(authzContext.HasSucceeded);
}
[Theory]
[BitAutoData(AccessClientType.NoAccessCheck)]
[BitAutoData(AccessClientType.User)]
public async Task Handler_CanUpdateAsync_ServiceAccountAccessResultsPartial_DoesNotSucceed(
AccessClientType accessClientType,
SutProvider<SecretAccessPoliciesUpdatesAuthorizationHandler> sutProvider,
SecretAccessPoliciesUpdates resource,
Guid userId,
ClaimsPrincipal claimsPrincipal)
{
var requirement = SecretAccessPoliciesOperations.Updates;
resource = AddServiceAccountCreateUpdate(resource);
SetupSameOrganizationRequest(sutProvider, accessClientType, resource, userId);
SetupPartialServiceAccountAccess(sutProvider, resource, userId, accessClientType);
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
claimsPrincipal, resource);
await sutProvider.Sut.HandleAsync(authzContext);
Assert.False(authzContext.HasSucceeded);
}
[Theory]
[BitAutoData(AccessClientType.NoAccessCheck)]
[BitAutoData(AccessClientType.User)]
public async Task Handler_CanUpdateAsync_UserHasAccessToSomeServiceAccounts_DoesNotSucceed(
AccessClientType accessClientType,
SutProvider<SecretAccessPoliciesUpdatesAuthorizationHandler> sutProvider,
SecretAccessPoliciesUpdates resource,
Guid userId,
ClaimsPrincipal claimsPrincipal)
{
var requirement = SecretAccessPoliciesOperations.Updates;
resource = AddServiceAccountCreateUpdate(resource);
SetupSameOrganizationRequest(sutProvider, accessClientType, resource, userId);
SetupSomeServiceAccountAccess(sutProvider, resource, userId, accessClientType);
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
claimsPrincipal, resource);
await sutProvider.Sut.HandleAsync(authzContext);
Assert.False(authzContext.HasSucceeded);
}
[Theory]
[BitAutoData(AccessClientType.NoAccessCheck)]
[BitAutoData(AccessClientType.User)]
public async Task Handler_CanUpdateAsync_UserHasAccessToAllServiceAccounts_Success(
AccessClientType accessClientType,
SutProvider<SecretAccessPoliciesUpdatesAuthorizationHandler> sutProvider,
SecretAccessPoliciesUpdates resource,
Guid userId,
ClaimsPrincipal claimsPrincipal)
{
var requirement = SecretAccessPoliciesOperations.Updates;
resource = AddServiceAccountCreateUpdate(resource);
SetupSameOrganizationRequest(sutProvider, accessClientType, resource, userId);
SetupAllServiceAccountAccess(sutProvider, resource, userId, accessClientType);
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
claimsPrincipal, resource);
await sutProvider.Sut.HandleAsync(authzContext);
Assert.True(authzContext.HasSucceeded);
}
[Theory]
[BitAutoData(AccessClientType.NoAccessCheck)]
[BitAutoData(AccessClientType.User)]
public async Task Handler_CanCreateAsync_NotCreationOperations_DoesNotSucceed(
AccessClientType accessClientType,
SutProvider<SecretAccessPoliciesUpdatesAuthorizationHandler> sutProvider,
SecretAccessPoliciesUpdates resource,
Guid userId,
ClaimsPrincipal claimsPrincipal)
{
var requirement = SecretAccessPoliciesOperations.Create;
SetupUserSubstitutes(sutProvider, accessClientType, resource, userId);
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
claimsPrincipal, resource);
await sutProvider.Sut.HandleAsync(authzContext);
Assert.False(authzContext.HasSucceeded);
}
[Theory]
[BitAutoData(false, false, false)]
[BitAutoData(true, false, false)]
[BitAutoData(false, true, false)]
[BitAutoData(true, true, false)]
[BitAutoData(false, false, true)]
[BitAutoData(true, false, true)]
[BitAutoData(false, true, true)]
public async Task Handler_CanCreateAsync_TargetGranteesNotInSameOrganization_DoesNotSucceed(
bool orgUsersInSameOrg,
bool groupsInSameOrg,
bool serviceAccountsInSameOrg,
SutProvider<SecretAccessPoliciesUpdatesAuthorizationHandler> sutProvider,
SecretAccessPoliciesUpdates resource,
Guid userId,
ClaimsPrincipal claimsPrincipal)
{
var requirement = SecretAccessPoliciesOperations.Create;
resource = SetAllToCreates(resource);
SetupSameOrganizationRequest(sutProvider, AccessClientType.NoAccessCheck, resource, userId, orgUsersInSameOrg,
groupsInSameOrg, serviceAccountsInSameOrg);
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
claimsPrincipal, resource);
await sutProvider.Sut.HandleAsync(authzContext);
Assert.False(authzContext.HasSucceeded);
}
[Theory]
[BitAutoData(false, false, false)]
[BitAutoData(true, false, false)]
[BitAutoData(false, true, false)]
[BitAutoData(true, true, false)]
[BitAutoData(false, false, true)]
[BitAutoData(true, false, true)]
[BitAutoData(false, true, true)]
public async Task Handler_CanCreateAsync_TargetGranteesNotInSameOrganizationHasZeroRequests_DoesNotSucceed(
bool orgUsersCountZero,
bool groupsCountZero,
bool serviceAccountsCountZero,
SutProvider<SecretAccessPoliciesUpdatesAuthorizationHandler> sutProvider,
SecretAccessPoliciesUpdates resource,
Guid userId,
ClaimsPrincipal claimsPrincipal)
{
var requirement = SecretAccessPoliciesOperations.Create;
resource = SetAllToCreates(resource);
resource = ClearAccessPolicyUpdate(resource, orgUsersCountZero, groupsCountZero, serviceAccountsCountZero);
SetupSameOrganizationRequest(sutProvider, AccessClientType.NoAccessCheck, resource, userId, false, false,
false);
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
claimsPrincipal, resource);
await sutProvider.Sut.HandleAsync(authzContext);
Assert.False(authzContext.HasSucceeded);
}
[Theory]
[BitAutoData(AccessClientType.NoAccessCheck)]
[BitAutoData(AccessClientType.User)]
public async Task Handler_CanCreateAsync_NoServiceAccountCreatesRequested_Success(
AccessClientType accessClientType,
SutProvider<SecretAccessPoliciesUpdatesAuthorizationHandler> sutProvider,
SecretAccessPoliciesUpdates resource,
Guid userId,
ClaimsPrincipal claimsPrincipal)
{
var requirement = SecretAccessPoliciesOperations.Create;
resource = SetAllToCreates(resource);
resource = RemoveAllServiceAccountCreates(resource);
SetupSameOrganizationRequest(sutProvider, accessClientType, resource, userId);
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
claimsPrincipal, resource);
await sutProvider.Sut.HandleAsync(authzContext);
Assert.True(authzContext.HasSucceeded);
}
[Theory]
[BitAutoData(AccessClientType.NoAccessCheck)]
[BitAutoData(AccessClientType.User)]
public async Task Handler_CanCreateAsync_NoAccessToTargetServiceAccounts_DoesNotSucceed(
AccessClientType accessClientType,
SutProvider<SecretAccessPoliciesUpdatesAuthorizationHandler> sutProvider,
SecretAccessPoliciesUpdates resource,
Guid userId,
ClaimsPrincipal claimsPrincipal)
{
var requirement = SecretAccessPoliciesOperations.Create;
resource = SetAllToCreates(resource);
SetupSameOrganizationRequest(sutProvider, accessClientType, resource, userId);
SetupNoServiceAccountAccess(sutProvider, resource, userId, accessClientType);
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
claimsPrincipal, resource);
await sutProvider.Sut.HandleAsync(authzContext);
Assert.False(authzContext.HasSucceeded);
}
[Theory]
[BitAutoData(AccessClientType.NoAccessCheck)]
[BitAutoData(AccessClientType.User)]
public async Task Handler_CanCreateAsync_ServiceAccountAccessResultsPartial_DoesNotSucceed(
AccessClientType accessClientType,
SutProvider<SecretAccessPoliciesUpdatesAuthorizationHandler> sutProvider,
SecretAccessPoliciesUpdates resource,
Guid userId,
ClaimsPrincipal claimsPrincipal)
{
var requirement = SecretAccessPoliciesOperations.Create;
resource = SetAllToCreates(resource);
resource = AddServiceAccountCreateUpdate(resource);
SetupSameOrganizationRequest(sutProvider, accessClientType, resource, userId);
SetupPartialServiceAccountAccess(sutProvider, resource, userId, accessClientType);
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
claimsPrincipal, resource);
await sutProvider.Sut.HandleAsync(authzContext);
Assert.False(authzContext.HasSucceeded);
}
[Theory]
[BitAutoData(AccessClientType.NoAccessCheck)]
[BitAutoData(AccessClientType.User)]
public async Task Handler_CanCreateAsync_UserHasAccessToSomeServiceAccounts_DoesNotSucceed(
AccessClientType accessClientType,
SutProvider<SecretAccessPoliciesUpdatesAuthorizationHandler> sutProvider,
SecretAccessPoliciesUpdates resource,
Guid userId,
ClaimsPrincipal claimsPrincipal)
{
var requirement = SecretAccessPoliciesOperations.Create;
resource = SetAllToCreates(resource);
resource = AddServiceAccountCreateUpdate(resource);
SetupSameOrganizationRequest(sutProvider, accessClientType, resource, userId);
SetupSomeServiceAccountAccess(sutProvider, resource, userId, accessClientType);
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
claimsPrincipal, resource);
await sutProvider.Sut.HandleAsync(authzContext);
Assert.False(authzContext.HasSucceeded);
}
[Theory]
[BitAutoData(AccessClientType.NoAccessCheck)]
[BitAutoData(AccessClientType.User)]
public async Task Handler_CanCreateAsync_UserHasAccessToAllServiceAccounts_Success(
AccessClientType accessClientType,
SutProvider<SecretAccessPoliciesUpdatesAuthorizationHandler> sutProvider,
SecretAccessPoliciesUpdates resource,
Guid userId,
ClaimsPrincipal claimsPrincipal)
{
var requirement = SecretAccessPoliciesOperations.Create;
resource = SetAllToCreates(resource);
resource = AddServiceAccountCreateUpdate(resource);
SetupSameOrganizationRequest(sutProvider, accessClientType, resource, userId);
SetupAllServiceAccountAccess(sutProvider, resource, userId, accessClientType);
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
claimsPrincipal, resource);
await sutProvider.Sut.HandleAsync(authzContext);
Assert.True(authzContext.HasSucceeded);
}
private static void SetupNoServiceAccountAccess(
SutProvider<SecretAccessPoliciesUpdatesAuthorizationHandler> sutProvider,
SecretAccessPoliciesUpdates resource,
Guid userId,
AccessClientType accessClientType)
{
var createServiceAccountIds = resource.ServiceAccountAccessPolicyUpdates
.Where(ap => ap.Operation == AccessPolicyOperation.Create)
.Select(uap => uap.AccessPolicy.ServiceAccountId!.Value)
.ToList();
sutProvider.GetDependency<IServiceAccountRepository>()
.AccessToServiceAccountsAsync(Arg.Any<List<Guid>>(), userId, accessClientType)
.Returns(createServiceAccountIds.ToDictionary(id => id, _ => (false, false)));
}
private static void SetupPartialServiceAccountAccess(
SutProvider<SecretAccessPoliciesUpdatesAuthorizationHandler> sutProvider,
SecretAccessPoliciesUpdates resource,
Guid userId,
AccessClientType accessClientType)
{
var accessResult = resource.ServiceAccountAccessPolicyUpdates
.Where(x => x.Operation == AccessPolicyOperation.Create)
.Select(x => x.AccessPolicy.ServiceAccountId!.Value)
.ToDictionary(id => id, _ => (true, true));
accessResult[accessResult.First().Key] = (true, true);
accessResult.Remove(accessResult.Last().Key);
sutProvider.GetDependency<IServiceAccountRepository>()
.AccessToServiceAccountsAsync(Arg.Any<List<Guid>>(), userId, accessClientType)
.Returns(accessResult);
}
private static void SetupSomeServiceAccountAccess(
SutProvider<SecretAccessPoliciesUpdatesAuthorizationHandler> sutProvider,
SecretAccessPoliciesUpdates resource,
Guid userId,
AccessClientType accessClientType)
{
var accessResult = resource.ServiceAccountAccessPolicyUpdates
.Where(x => x.Operation == AccessPolicyOperation.Create)
.Select(x => x.AccessPolicy.ServiceAccountId!.Value)
.ToDictionary(id => id, _ => (false, false));
accessResult[accessResult.First().Key] = (true, true);
sutProvider.GetDependency<IServiceAccountRepository>()
.AccessToServiceAccountsAsync(Arg.Any<List<Guid>>(), userId, accessClientType)
.Returns(accessResult);
}
private static void SetupAllServiceAccountAccess(
SutProvider<SecretAccessPoliciesUpdatesAuthorizationHandler> sutProvider,
SecretAccessPoliciesUpdates resource,
Guid userId,
AccessClientType accessClientType)
{
var accessResult = resource.ServiceAccountAccessPolicyUpdates
.Where(x => x.Operation == AccessPolicyOperation.Create)
.Select(x => x.AccessPolicy.ServiceAccountId!.Value)
.ToDictionary(id => id, _ => (true, true));
sutProvider.GetDependency<IServiceAccountRepository>()
.AccessToServiceAccountsAsync(Arg.Any<List<Guid>>(), userId, accessClientType)
.Returns(accessResult);
}
private static void SetupUserSubstitutes(
SutProvider<SecretAccessPoliciesUpdatesAuthorizationHandler> sutProvider,
AccessClientType accessClientType,
SecretAccessPoliciesUpdates resource,
Guid userId = new())
{
sutProvider.GetDependency<ICurrentContext>().AccessSecretsManager(resource.OrganizationId)
.Returns(true);
sutProvider.GetDependency<IAccessClientQuery>().GetAccessClientAsync(default, resource.OrganizationId)
.ReturnsForAnyArgs((accessClientType, userId));
}
private static void SetupSameOrganizationRequest(
SutProvider<SecretAccessPoliciesUpdatesAuthorizationHandler> sutProvider,
AccessClientType accessClientType,
SecretAccessPoliciesUpdates resource,
Guid userId = new(),
bool orgUsersInSameOrg = true,
bool groupsInSameOrg = true,
bool serviceAccountsInSameOrg = true)
{
SetupUserSubstitutes(sutProvider, accessClientType, resource, userId);
sutProvider.GetDependency<ISecretRepository>()
.AccessToSecretAsync(resource.SecretId, userId, accessClientType)
.Returns((true, true));
sutProvider.GetDependency<ISameOrganizationQuery>()
.OrgUsersInTheSameOrgAsync(Arg.Any<List<Guid>>(), resource.OrganizationId)
.Returns(orgUsersInSameOrg);
sutProvider.GetDependency<ISameOrganizationQuery>()
.GroupsInTheSameOrgAsync(Arg.Any<List<Guid>>(), resource.OrganizationId)
.Returns(groupsInSameOrg);
sutProvider.GetDependency<IServiceAccountRepository>()
.ServiceAccountsAreInOrganizationAsync(Arg.Any<List<Guid>>(), resource.OrganizationId)
.Returns(serviceAccountsInSameOrg);
}
private static SecretAccessPoliciesUpdates RemoveAllServiceAccountCreates(
SecretAccessPoliciesUpdates resource)
{
resource.ServiceAccountAccessPolicyUpdates =
resource.ServiceAccountAccessPolicyUpdates.Where(x => x.Operation != AccessPolicyOperation.Create);
return resource;
}
private static SecretAccessPoliciesUpdates SetAllToCreates(
SecretAccessPoliciesUpdates resource)
{
resource.UserAccessPolicyUpdates = resource.UserAccessPolicyUpdates.Select(x =>
{
x.Operation = AccessPolicyOperation.Create;
return x;
});
resource.GroupAccessPolicyUpdates = resource.GroupAccessPolicyUpdates.Select(x =>
{
x.Operation = AccessPolicyOperation.Create;
return x;
});
resource.ServiceAccountAccessPolicyUpdates = resource.ServiceAccountAccessPolicyUpdates.Select(x =>
{
x.Operation = AccessPolicyOperation.Create;
return x;
});
return resource;
}
private static SecretAccessPoliciesUpdates AddServiceAccountCreateUpdate(
SecretAccessPoliciesUpdates resource)
{
resource.ServiceAccountAccessPolicyUpdates = resource.ServiceAccountAccessPolicyUpdates.Append(
new ServiceAccountSecretAccessPolicyUpdate
{
AccessPolicy = new ServiceAccountSecretAccessPolicy
{
ServiceAccountId = Guid.NewGuid(),
GrantedSecretId = resource.SecretId,
Read = true,
Write = true
}
});
return resource;
}
private static SecretAccessPoliciesUpdates ClearAccessPolicyUpdate(SecretAccessPoliciesUpdates resource,
bool orgUsersCountZero,
bool groupsCountZero,
bool serviceAccountsCountZero)
{
if (orgUsersCountZero)
{
resource.UserAccessPolicyUpdates = [];
}
if (groupsCountZero)
{
resource.GroupAccessPolicyUpdates = [];
}
if (serviceAccountsCountZero)
{
resource.ServiceAccountAccessPolicyUpdates = [];
}
return resource;
}
}

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

@ -352,14 +352,16 @@ public class SecretAuthorizationHandlerTests
[Theory]
[BitAutoData]
public async Task CanUpdateSecret_WithoutProjectUser_DoesNotSucceed(
public async Task CanUpdateSecret_ClearProjectsUser_DoesNotSucceed(
SutProvider<SecretAuthorizationHandler> sutProvider, Secret secret,
Guid userId,
ClaimsPrincipal claimsPrincipal)
{
secret.Projects = null;
secret.Projects = [];
var requirement = SecretOperations.Update;
SetupPermission(sutProvider, PermissionType.RunAsUserWithPermission, secret.OrganizationId, userId);
sutProvider.GetDependency<ISecretRepository>().AccessToSecretAsync(secret.Id, Arg.Any<Guid>(), Arg.Any<AccessClientType>()).Returns(
(true, true));
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
claimsPrincipal, secret);
@ -370,12 +372,12 @@ public class SecretAuthorizationHandlerTests
[Theory]
[BitAutoData]
public async Task CanUpdateSecret_WithoutProjectAdmin_Success(SutProvider<SecretAuthorizationHandler> sutProvider,
public async Task CanUpdateSecret_ClearProjectsAdmin_Success(SutProvider<SecretAuthorizationHandler> sutProvider,
Secret secret,
Guid userId,
ClaimsPrincipal claimsPrincipal)
{
secret.Projects = null;
secret.Projects = [];
var requirement = SecretOperations.Update;
SetupPermission(sutProvider, PermissionType.RunAsAdmin, secret.OrganizationId, userId);
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
@ -386,6 +388,35 @@ public class SecretAuthorizationHandlerTests
Assert.True(authzContext.HasSucceeded);
}
[Theory]
[BitAutoData(PermissionType.RunAsUserWithPermission, false, false, false)]
[BitAutoData(PermissionType.RunAsUserWithPermission, false, true, true)]
[BitAutoData(PermissionType.RunAsUserWithPermission, true, false, false)]
[BitAutoData(PermissionType.RunAsUserWithPermission, true, true, true)]
[BitAutoData(PermissionType.RunAsServiceAccountWithPermission, false, false, false)]
[BitAutoData(PermissionType.RunAsServiceAccountWithPermission, false, true, true)]
[BitAutoData(PermissionType.RunAsServiceAccountWithPermission, true, false, false)]
[BitAutoData(PermissionType.RunAsServiceAccountWithPermission, true, true, true)]
public async Task CanUpdateSecret_NoProjectChanges_ReturnsExpected(PermissionType permissionType, bool read,
bool write, bool expected,
SutProvider<SecretAuthorizationHandler> sutProvider, Secret secret,
Guid userId,
ClaimsPrincipal claimsPrincipal)
{
var requirement = SecretOperations.Update;
secret.Projects = null;
SetupPermission(sutProvider, permissionType, secret.OrganizationId, userId);
sutProvider.GetDependency<ISecretRepository>()
.AccessToSecretAsync(secret.Id, userId, Arg.Any<AccessClientType>()).Returns(
(read, write));
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
claimsPrincipal, secret);
await sutProvider.Sut.HandleAsync(authzContext);
Assert.Equal(expected, authzContext.HasSucceeded);
}
[Theory]
[BitAutoData(PermissionType.RunAsUserWithPermission, true, true, true, false)]
[BitAutoData(PermissionType.RunAsUserWithPermission, true, true, false, false)]
@ -517,4 +548,85 @@ public class SecretAuthorizationHandlerTests
Assert.Equal(expected, authzContext.HasSucceeded);
}
[Theory]
[BitAutoData]
public async Task CanReadAccessPolicies_AccessToSecretsManagerFalse_DoesNotSucceed(
SutProvider<SecretAuthorizationHandler> sutProvider, Secret secret,
ClaimsPrincipal claimsPrincipal)
{
var requirement = SecretOperations.ReadAccessPolicies;
sutProvider.GetDependency<ICurrentContext>().AccessSecretsManager(secret.OrganizationId)
.Returns(false);
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
claimsPrincipal, secret);
await sutProvider.Sut.HandleAsync(authzContext);
Assert.False(authzContext.HasSucceeded);
}
[Theory]
[BitAutoData]
public async Task CanReadAccessPolicies_NullResource_DoesNotSucceed(
SutProvider<SecretAuthorizationHandler> sutProvider, Secret secret,
ClaimsPrincipal claimsPrincipal,
Guid userId)
{
var requirement = SecretOperations.ReadAccessPolicies;
SetupPermission(sutProvider, PermissionType.RunAsAdmin, secret.OrganizationId, userId);
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
claimsPrincipal, null);
await sutProvider.Sut.HandleAsync(authzContext);
Assert.False(authzContext.HasSucceeded);
}
[Theory]
[BitAutoData(AccessClientType.ServiceAccount)]
[BitAutoData(AccessClientType.Organization)]
public async Task CanReadAccessPolicies_UnsupportedClient_DoesNotSucceed(
AccessClientType clientType,
SutProvider<SecretAuthorizationHandler> sutProvider, Secret secret,
ClaimsPrincipal claimsPrincipal)
{
var requirement = SecretOperations.ReadAccessPolicies;
sutProvider.GetDependency<ICurrentContext>().AccessSecretsManager(secret.OrganizationId)
.Returns(true);
sutProvider.GetDependency<IAccessClientQuery>()
.GetAccessClientAsync(Arg.Any<ClaimsPrincipal>(), secret.OrganizationId)
.Returns((clientType, Guid.NewGuid()));
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
claimsPrincipal, secret);
await sutProvider.Sut.HandleAsync(authzContext);
Assert.False(authzContext.HasSucceeded);
}
[Theory]
[BitAutoData(PermissionType.RunAsAdmin, true, true, true)]
[BitAutoData(PermissionType.RunAsUserWithPermission, false, false, false)]
[BitAutoData(PermissionType.RunAsUserWithPermission, false, true, true)]
[BitAutoData(PermissionType.RunAsUserWithPermission, true, false, false)]
[BitAutoData(PermissionType.RunAsUserWithPermission, true, true, true)]
public async Task CanReadAccessPolicies_AccessCheck(PermissionType permissionType, bool read, bool write,
bool expected,
SutProvider<SecretAuthorizationHandler> sutProvider, Secret secret,
ClaimsPrincipal claimsPrincipal,
Guid userId)
{
var requirement = SecretOperations.ReadAccessPolicies;
SetupPermission(sutProvider, permissionType, secret.OrganizationId, userId);
sutProvider.GetDependency<ISecretRepository>()
.AccessToSecretAsync(secret.Id, userId, Arg.Any<AccessClientType>())
.Returns((read, write));
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
claimsPrincipal, secret);
await sutProvider.Sut.HandleAsync(authzContext);
Assert.Equal(expected, authzContext.HasSucceeded);
}
}

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

@ -20,9 +20,9 @@ public class CreateSecretCommandTests
{
data.Projects = new List<Project>() { mockProject };
await sutProvider.Sut.CreateAsync(data);
await sutProvider.Sut.CreateAsync(data, null);
await sutProvider.GetDependency<ISecretRepository>().Received(1)
.CreateAsync(data);
.CreateAsync(data, null);
}
}

View File

@ -1,12 +1,11 @@
using Bit.Commercial.Core.SecretsManager.Commands.Secrets;
using Bit.Core.Exceptions;
#nullable enable
using Bit.Commercial.Core.SecretsManager.Commands.Secrets;
using Bit.Core.SecretsManager.Entities;
using Bit.Core.SecretsManager.Repositories;
using Bit.Core.Test.SecretsManager.AutoFixture.ProjectsFixture;
using Bit.Core.Test.SecretsManager.AutoFixture.SecretsFixture;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Bit.Test.Common.Helpers;
using NSubstitute;
using Xunit;
@ -19,109 +18,13 @@ public class UpdateSecretCommandTests
{
[Theory]
[BitAutoData]
public async Task UpdateAsync_SecretDoesNotExist_ThrowsNotFound(Secret data, SutProvider<UpdateSecretCommand> sutProvider)
public async Task UpdateAsync_Success(SutProvider<UpdateSecretCommand> sutProvider, Secret data, Project project)
{
await Assert.ThrowsAsync<NotFoundException>(() => sutProvider.Sut.UpdateAsync(data));
data.Projects = new List<Project> { project };
await sutProvider.GetDependency<ISecretRepository>().DidNotReceiveWithAnyArgs().UpdateAsync(default);
}
[Theory]
[BitAutoData]
public async Task UpdateAsync_Success(Secret existingSecret, Secret data, SutProvider<UpdateSecretCommand> sutProvider, Project mockProject)
{
sutProvider.GetDependency<ISecretRepository>().GetByIdAsync(existingSecret.Id).Returns(existingSecret);
data.Projects = new List<Project>() { mockProject };
sutProvider.GetDependency<ISecretRepository>().GetByIdAsync(data.Id).Returns(data);
await sutProvider.Sut.UpdateAsync(data);
await sutProvider.Sut.UpdateAsync(data, null);
await sutProvider.GetDependency<ISecretRepository>().Received(1)
.UpdateAsync(data);
}
[Theory]
[BitAutoData]
public async Task UpdateAsync_DoesNotModifyOrganizationId(Secret existingSecret, SutProvider<UpdateSecretCommand> sutProvider)
{
var updatedOrgId = Guid.NewGuid();
sutProvider.GetDependency<ISecretRepository>().GetByIdAsync(existingSecret.Id).Returns(existingSecret);
var secretUpdate = new Secret()
{
OrganizationId = updatedOrgId,
Id = existingSecret.Id,
Key = existingSecret.Key,
};
var result = await sutProvider.Sut.UpdateAsync(secretUpdate);
Assert.Equal(existingSecret.OrganizationId, result.OrganizationId);
Assert.NotEqual(existingSecret.OrganizationId, updatedOrgId);
}
[Theory]
[BitAutoData]
public async Task UpdateAsync_DoesNotModifyCreationDate(Secret existingSecret, SutProvider<UpdateSecretCommand> sutProvider)
{
sutProvider.GetDependency<ISecretRepository>().GetByIdAsync(existingSecret.Id).Returns(existingSecret);
var updatedCreationDate = DateTime.UtcNow;
var secretUpdate = new Secret()
{
CreationDate = updatedCreationDate,
Id = existingSecret.Id,
Key = existingSecret.Key,
OrganizationId = existingSecret.OrganizationId
};
var result = await sutProvider.Sut.UpdateAsync(secretUpdate);
Assert.Equal(existingSecret.CreationDate, result.CreationDate);
Assert.NotEqual(existingSecret.CreationDate, updatedCreationDate);
}
[Theory]
[BitAutoData]
public async Task UpdateAsync_DoesNotModifyDeletionDate(Secret existingSecret, SutProvider<UpdateSecretCommand> sutProvider)
{
sutProvider.GetDependency<ISecretRepository>().GetByIdAsync(existingSecret.Id).Returns(existingSecret);
var updatedDeletionDate = DateTime.UtcNow;
var secretUpdate = new Secret()
{
DeletedDate = updatedDeletionDate,
Id = existingSecret.Id,
Key = existingSecret.Key,
OrganizationId = existingSecret.OrganizationId
};
var result = await sutProvider.Sut.UpdateAsync(secretUpdate);
Assert.Equal(existingSecret.DeletedDate, result.DeletedDate);
Assert.NotEqual(existingSecret.DeletedDate, updatedDeletionDate);
}
[Theory]
[BitAutoData]
public async Task UpdateAsync_RevisionDateIsUpdatedToUtcNow(Secret existingSecret, SutProvider<UpdateSecretCommand> sutProvider)
{
sutProvider.GetDependency<ISecretRepository>().GetByIdAsync(existingSecret.Id).Returns(existingSecret);
var updatedRevisionDate = DateTime.UtcNow.AddDays(10);
var secretUpdate = new Secret()
{
RevisionDate = updatedRevisionDate,
Id = existingSecret.Id,
Key = existingSecret.Key,
OrganizationId = existingSecret.OrganizationId
};
var result = await sutProvider.Sut.UpdateAsync(secretUpdate);
Assert.NotEqual(secretUpdate.RevisionDate, result.RevisionDate);
AssertHelper.AssertRecent(result.RevisionDate);
.UpdateAsync(data, null);
}
}

View File

@ -18,13 +18,21 @@ public class RevokeAccessTokenCommandTests
var apiKey1 = new ApiKey
{
Id = Guid.NewGuid(),
ServiceAccountId = serviceAccount.Id
ServiceAccountId = serviceAccount.Id,
Name = "Test Name",
Scope = "Test Scope",
EncryptedPayload = "Test EncryptedPayload",
Key = "Test Key",
};
var apiKey2 = new ApiKey
{
Id = Guid.NewGuid(),
ServiceAccountId = serviceAccount.Id
ServiceAccountId = serviceAccount.Id,
Name = "Test Name",
Scope = "Test Scope",
EncryptedPayload = "Test EncryptedPayload",
Key = "Test Key",
};
sutProvider.GetDependency<IApiKeyRepository>()

View File

@ -0,0 +1,184 @@
#nullable enable
using Bit.Commercial.Core.SecretsManager.Queries.AccessPolicies;
using Bit.Core.SecretsManager.Entities;
using Bit.Core.SecretsManager.Enums.AccessPolicies;
using Bit.Core.SecretsManager.Models.Data;
using Bit.Core.SecretsManager.Repositories;
using Bit.Core.Test.SecretsManager.AutoFixture.ProjectsFixture;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using NSubstitute.ReturnsExtensions;
using Xunit;
namespace Bit.Commercial.Core.Test.SecretsManager.Queries.AccessPolicies;
[SutProviderCustomize]
[ProjectCustomize]
public class SecretAccessPoliciesUpdatesQueryTests
{
[Theory]
[BitAutoData]
public async Task GetAsync_NoCurrentAccessPolicies_ReturnsAllCreates(
SutProvider<SecretAccessPoliciesUpdatesQuery> sutProvider,
SecretAccessPolicies data,
Guid userId)
{
sutProvider.GetDependency<IAccessPolicyRepository>()
.GetSecretAccessPoliciesAsync(data.SecretId, userId)
.ReturnsNullForAnyArgs();
var result = await sutProvider.Sut.GetAsync(data, userId);
Assert.Equal(data.SecretId, result.SecretId);
Assert.Equal(data.OrganizationId, result.OrganizationId);
Assert.Equal(data.UserAccessPolicies.Count(), result.UserAccessPolicyUpdates.Count());
Assert.All(result.UserAccessPolicyUpdates, p =>
{
Assert.Equal(AccessPolicyOperation.Create, p.Operation);
Assert.Contains(data.UserAccessPolicies, x => x == p.AccessPolicy);
});
Assert.Equal(data.GroupAccessPolicies.Count(), result.GroupAccessPolicyUpdates.Count());
Assert.All(result.GroupAccessPolicyUpdates, p =>
{
Assert.Equal(AccessPolicyOperation.Create, p.Operation);
Assert.Contains(data.GroupAccessPolicies, x => x == p.AccessPolicy);
});
Assert.Equal(data.ServiceAccountAccessPolicies.Count(), result.ServiceAccountAccessPolicyUpdates.Count());
Assert.All(result.ServiceAccountAccessPolicyUpdates, p =>
{
Assert.Equal(AccessPolicyOperation.Create, p.Operation);
Assert.Contains(data.ServiceAccountAccessPolicies, x => x == p.AccessPolicy);
});
}
[Theory]
[BitAutoData]
public async Task GetAsync_CurrentAccessPolicies_ReturnsChanges(
SutProvider<SecretAccessPoliciesUpdatesQuery> sutProvider,
SecretAccessPolicies data,
Guid userId,
UserSecretAccessPolicy userPolicyToDelete,
GroupSecretAccessPolicy groupPolicyToDelete,
ServiceAccountSecretAccessPolicy serviceAccountPolicyToDelete)
{
data = SetupSecretAccessPolicies(data);
var userPolicyChanges = SetupUserAccessPolicies(data, userPolicyToDelete);
var groupPolicyChanges = SetupGroupAccessPolicies(data, groupPolicyToDelete);
var serviceAccountPolicyChanges = SetupServiceAccountAccessPolicies(data, serviceAccountPolicyToDelete);
var currentPolicies = new SecretAccessPolicies
{
SecretId = data.SecretId,
OrganizationId = data.OrganizationId,
UserAccessPolicies = [userPolicyChanges.Update, userPolicyChanges.Delete],
GroupAccessPolicies = [groupPolicyChanges.Update, groupPolicyChanges.Delete],
ServiceAccountAccessPolicies = [serviceAccountPolicyChanges.Update, serviceAccountPolicyChanges.Delete]
};
sutProvider.GetDependency<IAccessPolicyRepository>()
.GetSecretAccessPoliciesAsync(data.SecretId, userId)
.ReturnsForAnyArgs(currentPolicies);
var result = await sutProvider.Sut.GetAsync(data, userId);
Assert.Equal(data.SecretId, result.SecretId);
Assert.Equal(data.OrganizationId, result.OrganizationId);
Assert.Single(result.UserAccessPolicyUpdates.Where(x =>
x.Operation == AccessPolicyOperation.Delete && x.AccessPolicy == userPolicyChanges.Delete));
Assert.Single(result.UserAccessPolicyUpdates.Where(x =>
x.Operation == AccessPolicyOperation.Update &&
x.AccessPolicy.OrganizationUserId == userPolicyChanges.Update.OrganizationUserId));
Assert.Equal(result.UserAccessPolicyUpdates.Count() - 2,
result.UserAccessPolicyUpdates.Count(x => x.Operation == AccessPolicyOperation.Create));
Assert.Single(result.GroupAccessPolicyUpdates.Where(x =>
x.Operation == AccessPolicyOperation.Delete && x.AccessPolicy == groupPolicyChanges.Delete));
Assert.Single(result.GroupAccessPolicyUpdates.Where(x =>
x.Operation == AccessPolicyOperation.Update &&
x.AccessPolicy.GroupId == groupPolicyChanges.Update.GroupId));
Assert.Equal(result.GroupAccessPolicyUpdates.Count() - 2,
result.GroupAccessPolicyUpdates.Count(x => x.Operation == AccessPolicyOperation.Create));
Assert.Single(result.ServiceAccountAccessPolicyUpdates.Where(x =>
x.Operation == AccessPolicyOperation.Delete && x.AccessPolicy == serviceAccountPolicyChanges.Delete));
Assert.Single(result.ServiceAccountAccessPolicyUpdates.Where(x =>
x.Operation == AccessPolicyOperation.Update &&
x.AccessPolicy.ServiceAccountId == serviceAccountPolicyChanges.Update.ServiceAccountId));
Assert.Equal(result.ServiceAccountAccessPolicyUpdates.Count() - 2,
result.ServiceAccountAccessPolicyUpdates.Count(x => x.Operation == AccessPolicyOperation.Create));
}
private static (UserSecretAccessPolicy Update, UserSecretAccessPolicy Delete) SetupUserAccessPolicies(
SecretAccessPolicies data, UserSecretAccessPolicy currentPolicyToDelete)
{
currentPolicyToDelete.GrantedSecretId = data.SecretId;
var updatePolicy = new UserSecretAccessPolicy
{
OrganizationUserId = data.UserAccessPolicies.First().OrganizationUserId,
GrantedSecretId = data.SecretId,
Read = !data.UserAccessPolicies.First().Read,
Write = !data.UserAccessPolicies.First().Write
};
return (updatePolicy, currentPolicyToDelete);
}
private static (GroupSecretAccessPolicy Update, GroupSecretAccessPolicy Delete) SetupGroupAccessPolicies(
SecretAccessPolicies data, GroupSecretAccessPolicy currentPolicyToDelete)
{
currentPolicyToDelete.GrantedSecretId = data.SecretId;
var updatePolicy = new GroupSecretAccessPolicy
{
GroupId = data.GroupAccessPolicies.First().GroupId,
GrantedSecretId = data.SecretId,
Read = !data.GroupAccessPolicies.First().Read,
Write = !data.GroupAccessPolicies.First().Write
};
return (updatePolicy, currentPolicyToDelete);
}
private static (ServiceAccountSecretAccessPolicy Update, ServiceAccountSecretAccessPolicy Delete)
SetupServiceAccountAccessPolicies(SecretAccessPolicies data,
ServiceAccountSecretAccessPolicy currentPolicyToDelete)
{
currentPolicyToDelete.GrantedSecretId = data.SecretId;
var updatePolicy = new ServiceAccountSecretAccessPolicy
{
ServiceAccountId = data.ServiceAccountAccessPolicies.First().ServiceAccountId,
GrantedSecretId = data.SecretId,
Read = !data.ServiceAccountAccessPolicies.First().Read,
Write = !data.ServiceAccountAccessPolicies.First().Write
};
return (updatePolicy, currentPolicyToDelete);
}
private static SecretAccessPolicies SetupSecretAccessPolicies(SecretAccessPolicies data)
{
foreach (var policy in data.UserAccessPolicies)
{
policy.GrantedSecretId = data.SecretId;
}
foreach (var policy in data.GroupAccessPolicies)
{
policy.GrantedSecretId = data.SecretId;
}
foreach (var policy in data.ServiceAccountAccessPolicies)
{
policy.GrantedSecretId = data.SecretId;
}
return data;
}
}

View File

@ -1,6 +1,6 @@
using Bit.Commercial.Core.SecretsManager.Queries.Projects;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Enums;
using Bit.Core.Billing.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.SecretsManager.Repositories;

View File

@ -201,7 +201,14 @@ public class ScimApplicationFactory : WebApplicationFactoryBase<Startup>
{
return new List<Organization>()
{
new Organization { Id = TestOrganizationId1, Name = "Test Organization 1", UseGroups = true }
new Organization
{
Id = TestOrganizationId1,
Name = "Test Organization 1",
BillingEmail = $"billing-email+{TestOrganizationId1}@example.com",
UseGroups = true,
Plan = "Enterprise",
},
};
}

View File

@ -38,7 +38,6 @@ public class PutGroupCommandTests
var expectedResult = new Group
{
Id = group.Id,
AccessAll = group.AccessAll,
ExternalId = group.ExternalId,
Name = displayName,
OrganizationId = group.OrganizationId
@ -77,7 +76,6 @@ public class PutGroupCommandTests
var expectedResult = new Group
{
Id = group.Id,
AccessAll = group.AccessAll,
ExternalId = group.ExternalId,
Name = displayName,
OrganizationId = group.OrganizationId

View File

@ -1,6 +1,7 @@
using Bit.Core.Enums;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Data;
using Bit.Core.Models.Business;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Repositories;
using Bit.Core.Services;
@ -19,7 +20,7 @@ public class PostUserCommandTests
{
[Theory]
[BitAutoData]
public async Task PostUser_Success(SutProvider<PostUserCommand> sutProvider, string externalId, Guid organizationId, List<BaseScimUserModel.EmailModel> emails, ICollection<OrganizationUserUserDetails> organizationUsers, Core.Entities.OrganizationUser newUser)
public async Task PostUser_Success(SutProvider<PostUserCommand> sutProvider, string externalId, Guid organizationId, List<BaseScimUserModel.EmailModel> emails, ICollection<OrganizationUserUserDetails> organizationUsers, Core.Entities.OrganizationUser newUser, Organization organization)
{
var scimUserRequestModel = new ScimUserRequestModel
{
@ -33,16 +34,30 @@ public class PostUserCommandTests
.GetManyDetailsByOrganizationAsync(organizationId)
.Returns(organizationUsers);
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId).Returns(organization);
sutProvider.GetDependency<IPaymentService>().HasSecretsManagerStandalone(organization).Returns(true);
sutProvider.GetDependency<IOrganizationService>()
.InviteUserAsync(organizationId, EventSystemUser.SCIM, scimUserRequestModel.PrimaryEmail.ToLowerInvariant(),
OrganizationUserType.User, false, externalId, Arg.Any<List<CollectionAccessSelection>>(),
Arg.Any<List<Guid>>())
.InviteUserAsync(organizationId, invitingUserId: null, EventSystemUser.SCIM,
Arg.Is<OrganizationUserInvite>(i =>
i.Emails.Single().Equals(scimUserRequestModel.PrimaryEmail.ToLowerInvariant()) &&
i.Type == OrganizationUserType.User &&
!i.Collections.Any() &&
!i.Groups.Any() &&
i.AccessSecretsManager), externalId)
.Returns(newUser);
var user = await sutProvider.Sut.PostUserAsync(organizationId, scimUserRequestModel);
await sutProvider.GetDependency<IOrganizationService>().Received(1).InviteUserAsync(organizationId, EventSystemUser.SCIM, scimUserRequestModel.PrimaryEmail.ToLowerInvariant(),
OrganizationUserType.User, false, scimUserRequestModel.ExternalId, Arg.Any<List<CollectionAccessSelection>>(), Arg.Any<List<Guid>>());
await sutProvider.GetDependency<IOrganizationService>().Received(1).InviteUserAsync(organizationId,
invitingUserId: null, EventSystemUser.SCIM,
Arg.Is<OrganizationUserInvite>(i =>
i.Emails.Single().Equals(scimUserRequestModel.PrimaryEmail.ToLowerInvariant()) &&
i.Type == OrganizationUserType.User &&
!i.Collections.Any() &&
!i.Groups.Any() &&
i.AccessSecretsManager), externalId);
await sutProvider.GetDependency<IOrganizationUserRepository>().Received(1).GetDetailsByIdAsync(newUser.Id);
}

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

@ -1,48 +0,0 @@
#!/bin/bash
#
# !!! UPDATED 2024 for MsSqlMigratorUtility !!!
#
# There seems to be [a bug with docker-compose](https://github.com/docker/compose/issues/4076#issuecomment-324932294)
# where it takes ~40ms to connect to the terminal output of the container, so stuff logged to the terminal in this time is lost.
# The best workaround seems to be adding tiny delay like so:
sleep 0.1;
SERVER='mssql'
DATABASE="vault_dev"
USER="SA"
PASSWD=$MSSQL_PASSWORD
while getopts "s" arg; do
case $arg in
s)
echo "Running for self-host environment"
DATABASE="vault_dev_self_host"
;;
esac
done
QUERY="IF OBJECT_ID('[$DATABASE].[dbo].[Migration]') IS NULL AND OBJECT_ID('[migrations_$DATABASE].[dbo].[migrations]') IS NOT NULL
BEGIN
-- Create [database].dbo.Migration with the schema expected by MsSqlMigratorUtility
SET ANSI_NULLS ON;
SET QUOTED_IDENTIFIER ON;
CREATE TABLE [$DATABASE].[dbo].[Migration](
[Id] [int] IDENTITY(1,1) NOT NULL,
[ScriptName] [nvarchar](255) NOT NULL,
[Applied] [datetime] NOT NULL
) ON [PRIMARY];
ALTER TABLE [$DATABASE].[dbo].[Migration] ADD CONSTRAINT [PK_Migration_Id] PRIMARY KEY CLUSTERED
(
[Id] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, SORT_IN_TEMPDB = OFF, IGNORE_DUP_KEY = OFF, ONLINE = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY];
-- Copy across old data
INSERT INTO [$DATABASE].[dbo].[Migration] (ScriptName, Applied)
SELECT CONCAT('Bit.Migrator.DbScripts.', [Filename]), CreationDate
FROM [migrations_$DATABASE].[dbo].[migrations];
END
"
/opt/mssql-tools/bin/sqlcmd -S $SERVER -d master -U $USER -P $PASSWD -I -Q "$QUERY"

View File

@ -1,9 +1,6 @@
#!/usr/bin/env pwsh
# Creates the vault_dev database, and runs all the migrations.
# Due to azure-edge-sql not containing the mssql-tools on ARM, we manually use
# the mssql-tools container which runs under x86_64.
param(
[switch]$all,
[switch]$postgres,
@ -30,21 +27,17 @@ 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) {
$msSqlConnectionString = $(Get-UserSecrets).'dev:selfHostOverride:globalSettings:sqlServer:connectionString'
$envName = "self-host"
Write-Output "Migrating your migrations to use MsSqlMigratorUtility (if needed)"
./migrate_migration_record.ps1 -s
} else {
$msSqlConnectionString = $(Get-UserSecrets).'globalSettings:sqlServer:connectionString'
$envName = "cloud"
Write-Output "Migrating your migrations to use MsSqlMigratorUtility (if needed)"
./migrate_migration_record.ps1
}
Write-Host "Starting Microsoft SQL Server Migrations for $envName"

View File

@ -1,21 +0,0 @@
#!/usr/bin/env pwsh
# !!! UPDATED 2024 for MsSqlMigratorUtility !!!
#
# This is a migration script to move data from [migrations_vault_dev].[dbo].[migrations] (used by our custom
# migrator script) to [vault_dev].[dbo].[Migration] (used by MsSqlMigratorUtility). It is safe to run multiple
# times because it will not perform any migration if it detects that the new table is already present.
# This will be deleted after a few months after everyone has (presumably) migrated to the new schema.
# Due to azure-edge-sql not containing the mssql-tools on ARM, we manually use
# the mssql-tools container which runs under x86_64.
docker run `
-v "$(pwd)/helpers/mssql:/mnt/helpers" `
-v "$(pwd)/../util/Migrator:/mnt/migrator/" `
-v "$(pwd)/.data/mssql:/mnt/data" `
--env-file .env `
--network=bitwardenserver_default `
--rm `
-it `
mcr.microsoft.com/mssql-tools `
/mnt/helpers/migrate_migrations.sh @args

View File

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

View File

@ -15,7 +15,7 @@ public class StaticClientStoreTests
}
[Params("mobile", "connector", "invalid", "a_much_longer_invalid_value_that_i_am_making_up", "WEB", "")]
public string? ClientId { get; set; }
public string ClientId { get; set; } = null!;
[Benchmark]
public Client? TryGetValue()

View File

@ -7,8 +7,8 @@ using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Providers.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Services;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
@ -54,9 +54,8 @@ public class OrganizationsController : Controller
private readonly IServiceAccountRepository _serviceAccountRepository;
private readonly IProviderOrganizationRepository _providerOrganizationRepository;
private readonly IRemoveOrganizationFromProviderCommand _removeOrganizationFromProviderCommand;
private readonly IRemovePaymentMethodCommand _removePaymentMethodCommand;
private readonly IFeatureService _featureService;
private readonly IScaleSeatsCommand _scaleSeatsCommand;
private readonly IProviderBillingService _providerBillingService;
public OrganizationsController(
IOrganizationService organizationService,
@ -82,9 +81,8 @@ public class OrganizationsController : Controller
IServiceAccountRepository serviceAccountRepository,
IProviderOrganizationRepository providerOrganizationRepository,
IRemoveOrganizationFromProviderCommand removeOrganizationFromProviderCommand,
IRemovePaymentMethodCommand removePaymentMethodCommand,
IFeatureService featureService,
IScaleSeatsCommand scaleSeatsCommand)
IProviderBillingService providerBillingService)
{
_organizationService = organizationService;
_organizationRepository = organizationRepository;
@ -109,9 +107,8 @@ public class OrganizationsController : Controller
_serviceAccountRepository = serviceAccountRepository;
_providerOrganizationRepository = providerOrganizationRepository;
_removeOrganizationFromProviderCommand = removeOrganizationFromProviderCommand;
_removePaymentMethodCommand = removePaymentMethodCommand;
_featureService = featureService;
_scaleSeatsCommand = scaleSeatsCommand;
_providerBillingService = providerBillingService;
}
[RequirePermission(Permission.Org_List_View)]
@ -201,15 +198,32 @@ public class OrganizationsController : Controller
}
var users = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(id);
var billingInfo = await _paymentService.GetBillingAsync(organization);
var billingHistoryInfo = await _paymentService.GetBillingHistoryAsync(organization);
var billingSyncConnection = _globalSettings.EnableCloudCommunication ? await _organizationConnectionRepository.GetByOrganizationIdTypeAsync(id, OrganizationConnectionType.CloudBillingSync) : null;
var secrets = organization.UseSecretsManager ? await _secretRepository.GetSecretsCountByOrganizationIdAsync(id) : -1;
var projects = organization.UseSecretsManager ? await _projectRepository.GetProjectCountByOrganizationIdAsync(id) : -1;
var serviceAccounts = organization.UseSecretsManager ? await _serviceAccountRepository.GetServiceAccountCountByOrganizationIdAsync(id) : -1;
var smSeats = organization.UseSecretsManager
? await _organizationUserRepository.GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id)
: -1;
return View(new OrganizationEditModel(organization, provider, users, ciphers, collections, groups, policies,
billingInfo, billingSyncConnection, _globalSettings, secrets, projects, serviceAccounts, smSeats));
return View(new OrganizationEditModel(
organization,
provider,
users,
ciphers,
collections,
groups,
policies,
billingInfo,
billingHistoryInfo,
billingSyncConnection,
_globalSettings,
secrets,
projects,
serviceAccounts,
smSeats));
}
[HttpPost]
@ -256,7 +270,7 @@ public class OrganizationsController : Controller
if (provider.IsBillable())
{
await _scaleSeatsCommand.ScalePasswordManagerSeats(
await _providerBillingService.ScaleSeats(
provider,
organization.PlanType,
-organization.Seats ?? 0);
@ -269,6 +283,35 @@ public class OrganizationsController : Controller
return RedirectToAction("Index");
}
[HttpPost]
[ValidateAntiForgeryToken]
[RequirePermission(Permission.Org_Delete)]
public async Task<IActionResult> DeleteInitiation(Guid id, OrganizationInitiateDeleteModel model)
{
if (!ModelState.IsValid)
{
TempData["Error"] = ModelState.GetErrorMessage();
}
else
{
try
{
var organization = await _organizationRepository.GetByIdAsync(id);
if (organization != null)
{
await _organizationService.InitiateDeleteAsync(organization, model.AdminEmail);
TempData["Success"] = "The request to initiate deletion of the organization has been sent.";
}
}
catch (Exception ex)
{
TempData["Error"] = ex.Message;
}
}
return RedirectToAction("Edit", new { id });
}
public async Task<IActionResult> TriggerBillingSync(Guid id)
{
var organization = await _organizationRepository.GetByIdAsync(id);
@ -349,11 +392,6 @@ public class OrganizationsController : Controller
providerOrganization,
organization);
if (organization.IsStripeEnabled())
{
await _removePaymentMethodCommand.RemovePaymentMethod(organization);
}
return Json(null);
}
private async Task<Organization> GetOrganization(Guid id, OrganizationEditModel model)
@ -414,5 +452,4 @@ public class OrganizationsController : Controller
return organization;
}
}

View File

@ -2,8 +2,6 @@
using Bit.Admin.Utilities;
using Bit.Core.AdminConsole.Providers.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Extensions;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Utilities;
@ -20,19 +18,16 @@ public class ProviderOrganizationsController : Controller
private readonly IProviderOrganizationRepository _providerOrganizationRepository;
private readonly IOrganizationRepository _organizationRepository;
private readonly IRemoveOrganizationFromProviderCommand _removeOrganizationFromProviderCommand;
private readonly IRemovePaymentMethodCommand _removePaymentMethodCommand;
public ProviderOrganizationsController(IProviderRepository providerRepository,
IProviderOrganizationRepository providerOrganizationRepository,
IOrganizationRepository organizationRepository,
IRemoveOrganizationFromProviderCommand removeOrganizationFromProviderCommand,
IRemovePaymentMethodCommand removePaymentMethodCommand)
IRemoveOrganizationFromProviderCommand removeOrganizationFromProviderCommand)
{
_providerRepository = providerRepository;
_providerOrganizationRepository = providerOrganizationRepository;
_organizationRepository = organizationRepository;
_removeOrganizationFromProviderCommand = removeOrganizationFromProviderCommand;
_removePaymentMethodCommand = removePaymentMethodCommand;
}
[HttpPost]
@ -69,12 +64,6 @@ public class ProviderOrganizationsController : Controller
return BadRequest(ex.Message);
}
if (organization.IsStripeEnabled())
{
await _removePaymentMethodCommand.RemovePaymentMethod(organization);
}
return Json(null);
}
}

View File

@ -10,8 +10,10 @@ using Bit.Core.AdminConsole.Providers.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
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;
@ -39,6 +41,10 @@ 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;
public ProvidersController(
IOrganizationRepository organizationRepository,
@ -52,7 +58,9 @@ public class ProvidersController : Controller
IUserService userService,
ICreateProviderCommand createProviderCommand,
IFeatureService featureService,
IProviderPlanRepository providerPlanRepository)
IProviderPlanRepository providerPlanRepository,
IProviderBillingService providerBillingService,
IWebHostEnvironment webHostEnvironment)
{
_organizationRepository = organizationRepository;
_organizationService = organizationService;
@ -66,6 +74,10 @@ public class ProvidersController : Controller
_createProviderCommand = createProviderCommand;
_featureService = featureService;
_providerPlanRepository = providerPlanRepository;
_providerBillingService = providerBillingService;
_stripeUrl = webHostEnvironment.GetStripeUrl();
_braintreeMerchantUrl = webHostEnvironment.GetBraintreeMerchantUrl();
_braintreeMerchantId = globalSettings.Braintree.MerchantId;
}
[RequirePermission(Permission.Provider_List_View)]
@ -168,7 +180,9 @@ public class ProvidersController : Controller
var providerPlans = await _providerPlanRepository.GetByProviderId(id);
return View(new ProviderEditModel(provider, users, providerOrganizations, providerPlans.ToList()));
return View(new ProviderEditModel(
provider, users, providerOrganizations,
providerPlans.ToList(), GetGatewayCustomerUrl(provider), GetGatewaySubscriptionUrl(provider)));
}
[HttpPost]
@ -213,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 });
@ -305,9 +310,7 @@ public class ProvidersController : Controller
return RedirectToAction("Index");
}
var flexibleCollectionsSignupEnabled = _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsSignup);
var flexibleCollectionsV1Enabled = _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1);
var organization = model.CreateOrganization(provider, flexibleCollectionsSignupEnabled, flexibleCollectionsV1Enabled);
var organization = model.CreateOrganization(provider);
await _organizationService.CreatePendingOrganization(organization, model.Owners, User, _userService, model.SalesAssistedTrialStarted);
await _providerService.AddOrganization(providerId, organization.Id, null);
@ -373,4 +376,34 @@ public class ProvidersController : Controller
return NoContent();
}
private string GetGatewayCustomerUrl(Provider provider)
{
if (!provider.Gateway.HasValue || string.IsNullOrEmpty(provider.GatewayCustomerId))
{
return null;
}
return provider.Gateway switch
{
GatewayType.Stripe => $"{_stripeUrl}/customers/{provider.GatewayCustomerId}",
GatewayType.PayPal => $"{_braintreeMerchantUrl}/{_braintreeMerchantId}/${provider.GatewayCustomerId}",
_ => null
};
}
private string GetGatewaySubscriptionUrl(Provider provider)
{
if (!provider.Gateway.HasValue || string.IsNullOrEmpty(provider.GatewaySubscriptionId))
{
return null;
}
return provider.Gateway switch
{
GatewayType.Stripe => $"{_stripeUrl}/subscriptions/{provider.GatewaySubscriptionId}",
GatewayType.PayPal => $"{_braintreeMerchantUrl}/{_braintreeMerchantId}/subscriptions/${provider.GatewaySubscriptionId}",
_ => null
};
}
}

View File

@ -3,9 +3,10 @@ using System.Net;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Models;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Business;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Settings;
using Bit.Core.Utilities;
@ -22,19 +23,43 @@ public class OrganizationEditModel : OrganizationViewModel
{
Provider = provider;
BillingEmail = provider.Type == ProviderType.Reseller ? provider.BillingEmail : string.Empty;
PlanType = Core.Enums.PlanType.TeamsMonthly;
Plan = Core.Enums.PlanType.TeamsMonthly.GetDisplayAttribute()?.GetName();
PlanType = Core.Billing.Enums.PlanType.TeamsMonthly;
Plan = Core.Billing.Enums.PlanType.TeamsMonthly.GetDisplayAttribute()?.GetName();
LicenseKey = RandomLicenseKey;
}
public OrganizationEditModel(Organization org, Provider provider, IEnumerable<OrganizationUserUserDetails> orgUsers,
IEnumerable<Cipher> ciphers, IEnumerable<Collection> collections, IEnumerable<Group> groups,
IEnumerable<Policy> policies, BillingInfo billingInfo, IEnumerable<OrganizationConnection> connections,
GlobalSettings globalSettings, int secrets, int projects, int serviceAccounts, int occupiedSmSeats)
: base(org, provider, connections, orgUsers, ciphers, collections, groups, policies, secrets, projects,
serviceAccounts, occupiedSmSeats)
public OrganizationEditModel(
Organization org,
Provider provider,
IEnumerable<OrganizationUserUserDetails> orgUsers,
IEnumerable<Cipher> ciphers,
IEnumerable<Collection> collections,
IEnumerable<Group> groups,
IEnumerable<Policy> policies,
BillingInfo billingInfo,
BillingHistoryInfo billingHistoryInfo,
IEnumerable<OrganizationConnection> connections,
GlobalSettings globalSettings,
int secrets,
int projects,
int serviceAccounts,
int occupiedSmSeats)
: base(
org,
provider,
connections,
orgUsers,
ciphers,
collections,
groups,
policies,
secrets,
projects,
serviceAccounts,
occupiedSmSeats)
{
BillingInfo = billingInfo;
BillingHistoryInfo = billingHistoryInfo;
BraintreeMerchantId = globalSettings.Braintree.MerchantId;
Name = org.DisplayName();
@ -73,6 +98,7 @@ public class OrganizationEditModel : OrganizationViewModel
}
public BillingInfo BillingInfo { get; set; }
public BillingHistoryInfo BillingHistoryInfo { get; set; }
public string RandomLicenseKey => CoreHelpers.SecureRandomString(20);
public string FourteenDayExpirationDate => DateTime.Now.AddDays(14).ToString("yyyy-MM-ddTHH:mm");
public string BraintreeMerchantId { get; set; }
@ -153,31 +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 flexibleCollectionsSignupEnabled, bool flexibleCollectionsV1Enabled)
public Organization CreateOrganization(Provider provider)
{
BillingEmail = provider.BillingEmail;
var newOrg = new Organization
{
// This feature flag indicates that new organizations should be automatically onboarded to
// Flexible Collections enhancements
FlexibleCollections = flexibleCollectionsSignupEnabled,
// These collection management settings smooth the migration for existing organizations by disabling some FC behavior.
// If the organization is onboarded to Flexible Collections on signup, we turn them OFF to enable all new behaviour.
// If the organization is NOT onboarded now, they will have to be migrated later, so they default to ON to limit FC changes on migration.
LimitCollectionCreationDeletion = !flexibleCollectionsSignupEnabled,
AllowAdminAccessToAllCollectionItems = !flexibleCollectionsV1Enabled
};
return ToOrganization(newOrg);
return ToOrganization(new Organization());
}
public Organization ToOrganization(Organization existingOrganization)

View File

@ -0,0 +1,12 @@
using System.ComponentModel.DataAnnotations;
namespace Bit.Admin.AdminConsole.Models;
public class OrganizationInitiateDeleteModel
{
[Required]
[EmailAddress]
[StringLength(256)]
[Display(Name = "Admin Email")]
public string AdminEmail { get; set; }
}

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

@ -2,6 +2,7 @@
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Models.Data.Provider;
using Bit.Core.Billing.Entities;
using Bit.Core.Billing.Enums;
using Bit.Core.Enums;
namespace Bit.Admin.AdminConsole.Models;
@ -14,7 +15,9 @@ public class ProviderEditModel : ProviderViewModel
Provider provider,
IEnumerable<ProviderUserUserDetails> providerUsers,
IEnumerable<ProviderOrganizationOrganizationDetails> organizations,
IReadOnlyCollection<ProviderPlan> providerPlans) : base(provider, providerUsers, organizations)
IReadOnlyCollection<ProviderPlan> providerPlans,
string gatewayCustomerUrl = null,
string gatewaySubscriptionUrl = null) : base(provider, providerUsers, organizations)
{
Name = provider.DisplayName();
BusinessName = provider.DisplayBusinessName();
@ -25,6 +28,8 @@ public class ProviderEditModel : ProviderViewModel
Gateway = provider.Gateway;
GatewayCustomerId = provider.GatewayCustomerId;
GatewaySubscriptionId = provider.GatewaySubscriptionId;
GatewayCustomerUrl = gatewayCustomerUrl;
GatewaySubscriptionUrl = gatewaySubscriptionUrl;
}
[Display(Name = "Billing Email")]
@ -45,6 +50,8 @@ public class ProviderEditModel : ProviderViewModel
public string GatewayCustomerId { get; set; }
[Display(Name = "Gateway Subscription Id")]
public string GatewaySubscriptionId { get; set; }
public string GatewayCustomerUrl { get; }
public string GatewaySubscriptionUrl { get; }
public virtual Provider ToProvider(Provider existingProvider)
{

View File

@ -1,5 +1,6 @@
@using Bit.Admin.Enums;
@using Bit.Admin.Enums;
@using Bit.Admin.Models
@using Bit.Core.Billing.Enums
@using Bit.Core.Enums
@inject Bit.Admin.Services.IAccessControlService AccessControlService
@model OrganizationEditModel
@ -18,24 +19,43 @@
<script>
(() => {
document.getElementById('teams-trial').addEventListener('click', () => {
if (document.getElementById('@(nameof(Model.PlanType))').value !== '@((byte)PlanType.Free)') {
alert('Organization is not on a free plan.');
return;
}
setTrialDefaults('@((byte)PlanType.TeamsAnnually)');
togglePlanFeatures('@((byte)PlanType.TeamsAnnually)');
document.getElementById('@(nameof(Model.Plan))').value = 'Teams (Trial)';
});
document.getElementById('enterprise-trial').addEventListener('click', () => {
if (document.getElementById('@(nameof(Model.PlanType))').value !== '@((byte)PlanType.Free)') {
alert('Organization is not on a free plan.');
return;
}
setTrialDefaults('@((byte)PlanType.EnterpriseAnnually)');
togglePlanFeatures('@((byte)PlanType.EnterpriseAnnually)');
document.getElementById('@(nameof(Model.Plan))').value = 'Enterprise (Trial)';
});
const treamsTrialButton = document.getElementById('teams-trial');
if (treamsTrialButton != null) {
treamsTrialButton.addEventListener('click', () => {
if (document.getElementById('@(nameof(Model.PlanType))').value !== '@((byte)PlanType.Free)') {
alert('Organization is not on a free plan.');
return;
}
setTrialDefaults('@((byte)PlanType.TeamsAnnually)');
togglePlanFeatures('@((byte)PlanType.TeamsAnnually)');
document.getElementById('@(nameof(Model.Plan))').value = 'Teams (Trial)';
});
}
const entTrialButton = document.getElementById('enterprise-trial');
if (entTrialButton != null) {
entTrialButton.addEventListener('click', () => {
if (document.getElementById('@(nameof(Model.PlanType))').value !== '@((byte)PlanType.Free)') {
alert('Organization is not on a free plan.');
return;
}
setTrialDefaults('@((byte)PlanType.EnterpriseAnnually)');
togglePlanFeatures('@((byte)PlanType.EnterpriseAnnually)');
document.getElementById('@(nameof(Model.Plan))').value = 'Enterprise (Trial)';
});
}
const initDeleteButton = document.getElementById('initiate-delete-form');
if (initDeleteButton != null) {
initDeleteButton.addEventListener('submit', (e) => {
const email = prompt('Enter the email address of the owner/admin that your want to ' +
'request the organization delete verification process with.');
document.getElementById('AdminEmail').value = email;
if (email == null || email === '') {
e.preventDefault();
}
});
}
function setTrialDefaults(planType) {
// Plan
@ -76,7 +96,7 @@
{
<h2>Billing Information</h2>
@await Html.PartialAsync("_BillingInformation",
new BillingInformationModel { BillingInfo = Model.BillingInfo, OrganizationId = Model.Organization.Id, Entity = "Organization" })
new BillingInformationModel { BillingInfo = Model.BillingInfo, BillingHistoryInfo = Model.BillingHistoryInfo, OrganizationId = Model.Organization.Id, Entity = "Organization" })
}
@await Html.PartialAsync("~/AdminConsole/Views/Shared/_OrganizationForm.cshtml", Model)
@ -95,18 +115,20 @@
}
@if (canUnlinkFromProvider && Model.Provider is not null)
{
<button
class="btn btn-outline-danger mr-2"
onclick="return unlinkProvider('@Model.Organization.Id');"
>
<button class="btn btn-outline-danger mr-2"
onclick="return unlinkProvider('@Model.Organization.Id');">
Unlink provider
</button>
}
@if (canDelete)
{
<form asp-action="DeleteInitiation" asp-route-id="@Model.Organization.Id" id="initiate-delete-form">
<input type="hidden" name="AdminEmail" id="AdminEmail" />
<button class="btn btn-danger mr-2" type="submit">Request Delete</button>
</form>
<form asp-action="Delete" asp-route-id="@Model.Organization.Id"
onsubmit="return confirm('Are you sure you want to delete this organization?')">
<button class="btn btn-danger" type="submit">Delete</button>
onsubmit="return confirm('Are you sure you want to hard delete this organization?')">
<button class="btn btn-outline-danger" type="submit">Delete</button>
</form>
}
</div>

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

@ -78,9 +78,9 @@
<div class="input-group">
<input type="text" class="form-control" asp-for="GatewayCustomerId">
<div class="input-group-append">
<button class="btn btn-secondary" type="button" id="gateway-customer-link">
<a href="@Model.GatewayCustomerUrl" class="btn btn-secondary" target="_blank">
<i class="fa fa-external-link"></i>
</button>
</a>
</div>
</div>
</div>
@ -91,9 +91,9 @@
<div class="input-group">
<input type="text" class="form-control" asp-for="GatewaySubscriptionId">
<div class="input-group-append">
<button class="btn btn-secondary" type="button" id="gateway-subscription-link">
<a href="@Model.GatewaySubscriptionUrl" class="btn btn-secondary" target="_blank">
<i class="fa fa-external-link"></i>
</button>
</a>
</div>
</div>
</div>

View File

@ -1,6 +1,7 @@
@using Bit.Admin.Enums;
@using Bit.Core.Enums
@using Bit.Core.AdminConsole.Enums.Provider
@using Bit.Core.Billing.Enums
@using Bit.SharedWeb.Utilities
@inject Bit.Admin.Services.IAccessControlService AccessControlService;
@ -70,9 +71,10 @@
@{
var planTypes = Enum.GetValues<PlanType>()
.Where(p =>
Model.Provider == null ||
(Model.Provider != null
&& p is >= PlanType.TeamsMonthly2019 and <= PlanType.EnterpriseAnnually2019 or >= PlanType.TeamsMonthly2020 and <= PlanType.EnterpriseAnnually)
(Model.Provider == null ||
p is >= PlanType.TeamsMonthly2019 and <= PlanType.EnterpriseAnnually2019 or
>= PlanType.TeamsMonthly2020 and <= PlanType.EnterpriseAnnually) &&
(Model.PlanType == PlanType.TeamsStarter || p is not PlanType.TeamsStarter)
)
.Select(e => new SelectListItem
{

View File

@ -1,6 +1,8 @@
@inject IWebHostEnvironment HostingEnvironment
@using Bit.Admin.Utilities
@using Bit.Core.Billing.Enums
@using Bit.Core.Enums
@using Bit.Core.Utilities
@model OrganizationEditModel
<script>
@ -52,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) {
@ -133,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

@ -13,6 +13,7 @@ using Bit.Core.Settings;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using TaxRate = Bit.Core.Entities.TaxRate;
namespace Bit.Admin.Controllers;
@ -518,8 +519,17 @@ public class ToolsController : Controller
{
model.Filter.StartingAfter = null;
}
if (model.Action == StripeSubscriptionsAction.NextPage || model.Action == StripeSubscriptionsAction.Search)
{
if (!string.IsNullOrEmpty(model.Filter.StartingAfter))
{
var subscription = await _stripeAdapter.SubscriptionGetAsync(model.Filter.StartingAfter);
if (subscription.Status == "canceled")
{
model.Filter.StartingAfter = null;
}
}
model.Filter.EndingBefore = null;
}
}

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,9 +100,10 @@ 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);
return View(new UserEditModel(user, ciphers, billingInfo, _globalSettings));
var billingHistoryInfo = await _paymentService.GetBillingHistoryAsync(user);
return View(new UserEditModel(user, ciphers, billingInfo, billingHistoryInfo, _globalSettings));
}
[HttpPost]

View File

@ -1,10 +1,11 @@
using Bit.Core.Models.Business;
using Bit.Core.Billing.Models;
namespace Bit.Admin.Models;
public class BillingInformationModel
{
public BillingInfo BillingInfo { get; set; }
public BillingHistoryInfo BillingHistoryInfo { get; set; }
public Guid? UserId { get; set; }
public Guid? OrganizationId { get; set; }
public string Entity { get; set; }

View File

@ -1,6 +1,6 @@
using System.ComponentModel.DataAnnotations;
using Bit.Core.Billing.Models;
using Bit.Core.Entities;
using Bit.Core.Models.Business;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Bit.Core.Vault.Entities;
@ -11,11 +11,16 @@ public class UserEditModel : UserViewModel
{
public UserEditModel() { }
public UserEditModel(User user, IEnumerable<Cipher> ciphers, BillingInfo billingInfo,
public UserEditModel(
User user,
IEnumerable<Cipher> ciphers,
BillingInfo billingInfo,
BillingHistoryInfo billingHistoryInfo,
GlobalSettings globalSettings)
: base(user, ciphers)
{
BillingInfo = billingInfo;
BillingHistoryInfo = billingHistoryInfo;
BraintreeMerchantId = globalSettings.Braintree.MerchantId;
Name = user.Name;
@ -31,6 +36,7 @@ public class UserEditModel : UserViewModel
}
public BillingInfo BillingInfo { get; set; }
public BillingHistoryInfo BillingHistoryInfo { get; set; }
public string RandomLicenseKey => CoreHelpers.SecureRandomString(20);
public string OneYearExpirationDate => DateTime.Now.AddYears(1).ToString("yyyy-MM-ddTHH:mm");
public string BraintreeMerchantId { get; set; }

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

@ -3,10 +3,10 @@
@model BillingInformationModel
@{
var canManageTransactions = Model.Entity == "User" ? AccessControlService.UserHasPermission(Permission.User_BillingInformation_CreateEditTransaction)
var canManageTransactions = Model.Entity == "User" ? AccessControlService.UserHasPermission(Permission.User_BillingInformation_CreateEditTransaction)
: AccessControlService.UserHasPermission(Permission.Org_BillingInformation_CreateEditTransaction);
var canDownloadInvoice = Model.Entity == "User" ? AccessControlService.UserHasPermission(Permission.User_BillingInformation_DownloadInvoice)
var canDownloadInvoice = Model.Entity == "User" ? AccessControlService.UserHasPermission(Permission.User_BillingInformation_DownloadInvoice)
: AccessControlService.UserHasPermission(Permission.Org_BillingInformation_DownloadInvoice);
}
@ -16,11 +16,11 @@
<dt class="col-sm-4 col-lg-3">Invoices</dt>
<dd class="col-sm-8 col-lg-9">
@if(Model.BillingInfo.Invoices?.Any() ?? false)
@if(Model.BillingHistoryInfo.Invoices?.Any() ?? false)
{
<table class="table">
<tbody>
@foreach(var invoice in Model.BillingInfo.Invoices)
@foreach(var invoice in Model.BillingHistoryInfo.Invoices)
{
<tr>
<td>@invoice.Date</td>
@ -28,7 +28,7 @@
</td>
<td>@invoice.Amount.ToString("C")</td>
<td>@(invoice.Paid ? "Paid" : "Unpaid")</td>
@if (canDownloadInvoice)
@if (canDownloadInvoice)
{
<td>
<a target="_blank" rel="noreferrer" href="@invoice.PdfUrl" title="Download Invoice">
@ -49,11 +49,11 @@
<dt class="col-sm-4 col-lg-3">Transactions</dt>
<dd class="col-sm-8 col-lg-9">
@if(Model.BillingInfo.Transactions?.Any() ?? false)
@if(Model.BillingHistoryInfo.Transactions?.Any() ?? false)
{
<table class="table">
<tbody>
@foreach(var transaction in Model.BillingInfo.Transactions)
@foreach(var transaction in Model.BillingHistoryInfo.Transactions)
{
<tr>
<td>@transaction.CreatedDate</td>

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)
{
@ -174,6 +153,14 @@
});
</script>
}
@if (TempData["Success"] != null)
{
<script>
$(document).ready(function () {
toastr.success("@TempData["Success"]")
});
</script>
}
@RenderSection("Scripts", required: false)
</body>

View File

@ -109,7 +109,7 @@
<option asp-selected="Model.Filter.Price == null" value="@null">All</option>
@foreach (var price in Model.Prices)
{
<option asp-selected='@Model.Filter.Price == @price.Id' value="@price.Id">@price.Id</option>
<option asp-selected='@(Model.Filter.Price == price.Id)' value="@price.Id">@price.Id</option>
}
</select>
</div>
@ -119,7 +119,7 @@
<option asp-selected="Model.Filter.TestClock == null" value="@null">All</option>
@foreach (var clock in Model.TestClocks)
{
<option asp-selected='@Model.Filter.TestClock == @clock.Id' value="@clock.Id">@clock.Name</option>
<option asp-selected='@(Model.Filter.TestClock == clock.Id)' value="@clock.Id">@clock.Name</option>
}
</select>
</div>
@ -149,7 +149,7 @@
<th>Id</th>
<th>Customer Email</th>
<th>Status</th>
<th>Product</th>
<th>Product Tier</th>
<th>Current Period End</th>
</tr>
</thead>
@ -278,4 +278,4 @@
</span>
</span>
</nav>
</form>
</form>

View File

@ -92,7 +92,7 @@
{
<h2>Billing Information</h2>
@await Html.PartialAsync("_BillingInformation",
new BillingInformationModel { BillingInfo = Model.BillingInfo, UserId = Model.User.Id, Entity = "User" })
new BillingInformationModel { BillingInfo = Model.BillingInfo, BillingHistoryInfo = Model.BillingHistoryInfo, UserId = Model.User.Id, Entity = "User" })
}
@if (canViewGeneral)
{

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>

Some files were not shown because too many files have changed in this diff Show More