1
0
mirror of https://github.com/bitwarden/server.git synced 2025-02-17 02:01:53 +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, "isRoot": true,
"tools": { "tools": {
"swashbuckle.aspnetcore.cli": { "swashbuckle.aspnetcore.cli": {
"version": "6.5.0", "version": "6.7.3",
"commands": ["swagger"] "commands": ["swagger"]
}, },
"dotnet-ef": { "dotnet-ef": {
"version": "8.0.2", "version": "8.0.8",
"commands": ["dotnet-ef"] "commands": ["dotnet-ef"]
} }
} }

View File

@ -3,6 +3,13 @@
"dockerComposeFile": "../../.devcontainer/bitwarden_common/docker-compose.yml", "dockerComposeFile": "../../.devcontainer/bitwarden_common/docker-compose.yml",
"service": "bitwarden_server", "service": "bitwarden_server",
"workspaceFolder": "/workspace", "workspaceFolder": "/workspace",
"mounts": [
{
"source": "../../dev/.data/keys",
"target": "/home/vscode/.aspnet/DataProtection-Keys",
"type": "bind"
}
],
"customizations": { "customizations": {
"vscode": { "vscode": {
"settings": {}, "settings": {},

View File

@ -3,8 +3,16 @@
"dockerComposeFile": [ "dockerComposeFile": [
"../../.devcontainer/bitwarden_common/docker-compose.yml", "../../.devcontainer/bitwarden_common/docker-compose.yml",
"../../.devcontainer/internal_dev/docker-compose.override.yml" "../../.devcontainer/internal_dev/docker-compose.override.yml"
], "service": "bitwarden_server", ],
"service": "bitwarden_server",
"workspaceFolder": "/workspace", "workspaceFolder": "/workspace",
"mounts": [
{
"source": "../../dev/.data/keys",
"target": "/home/vscode/.aspnet/DataProtection-Keys",
"type": "bind"
}
],
"customizations": { "customizations": {
"vscode": { "vscode": {
"settings": {}, "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/Scim @bitwarden/team-admin-console-dev
bitwarden_license/src/test/Scim.IntegrationTest @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 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 team
**/*billing* @bitwarden/team-billing-dev **/*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. -->
``` ## 📔 Objective
- [ ] Bug fix
- [ ] New feature development
- [ ] Tech debt (refactoring, code cleanup, dependency upgrades, etc)
- [ ] Build/deploy pipeline (DevOps)
- [ ] Other
```
## Objective <!-- Describe what the purpose of this PR is, for example what bug you're fixing or new feature you're adding. -->
<!--Describe what the purpose of this PR is. For example: what bug you're fixing or what new feature you're adding-->
## 📸 Screenshots
<!-- Required for any UI changes; delete if not applicable. Use fixed width images for better display. -->
## Code changes ## ⏰ Reminders before review
<!--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-->
* **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) <!-- Suggested interactions but feel free to use (or not) as you desire! -->
- 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) - 👍 (`:+1:`) or similar for great changes
- If this change requires a **documentation update** - notify the documentation team - 📝 (`:memo:`) or (`:information_source:`) for notes or general info
- If this change has particular **deployment requirements** - notify the DevOps team - ❓ (`: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:", "commitMessagePrefix": "[deps] Auth:",
"reviewers": ["team:team-auth-dev"] "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": [ "matchPackageNames": [
"AspNetCoreRateLimit", "AspNetCoreRateLimit",
@ -59,8 +53,6 @@
"DuoUniversal", "DuoUniversal",
"Fido2.AspNet", "Fido2.AspNet",
"Duende.IdentityServer", "Duende.IdentityServer",
"Microsoft.Azure.Cosmos",
"Microsoft.Extensions.Caching.StackExchangeRedis",
"Microsoft.Extensions.Identity.Stores", "Microsoft.Extensions.Identity.Stores",
"Otp.NET", "Otp.NET",
"Sustainsys.Saml2.AspNetCore2", "Sustainsys.Saml2.AspNetCore2",
@ -112,12 +104,15 @@
"dbup-sqlserver", "dbup-sqlserver",
"dotnet-ef", "dotnet-ef",
"linq2db.EntityFrameworkCore", "linq2db.EntityFrameworkCore",
"Microsoft.Azure.Cosmos",
"Microsoft.Data.SqlClient", "Microsoft.Data.SqlClient",
"Microsoft.EntityFrameworkCore.Design", "Microsoft.EntityFrameworkCore.Design",
"Microsoft.EntityFrameworkCore.InMemory", "Microsoft.EntityFrameworkCore.InMemory",
"Microsoft.EntityFrameworkCore.Relational", "Microsoft.EntityFrameworkCore.Relational",
"Microsoft.EntityFrameworkCore.Sqlite", "Microsoft.EntityFrameworkCore.Sqlite",
"Microsoft.EntityFrameworkCore.SqlServer", "Microsoft.EntityFrameworkCore.SqlServer",
"Microsoft.Extensions.Caching.SqlServer",
"Microsoft.Extensions.Caching.StackExchangeRedis",
"Npgsql.EntityFrameworkCore.PostgreSQL", "Npgsql.EntityFrameworkCore.PostgreSQL",
"Pomelo.EntityFrameworkCore.MySql" "Pomelo.EntityFrameworkCore.MySql"
], ],

View File

@ -18,7 +18,7 @@ jobs:
copy_finalization_scripts: ${{ steps.check-finalization-scripts-existence.outputs.copy_finalization_scripts }} copy_finalization_scripts: ${{ steps.check-finalization-scripts-existence.outputs.copy_finalization_scripts }}
steps: steps:
- name: Log in to Azure - name: Log in to Azure
uses: Azure/login@de95379fe4dadc2defb305917eaa7e5dde727294 # v1.5.1 uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
with: with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
@ -30,7 +30,7 @@ jobs:
secrets: "github-pat-bitwarden-devops-bot-repo-scope" secrets: "github-pat-bitwarden-devops-bot-repo-scope"
- name: Check out branch - name: Check out branch
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
with: with:
token: ${{ steps.retrieve-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }} token: ${{ steps.retrieve-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
@ -54,7 +54,7 @@ jobs:
if: ${{ needs.setup.outputs.copy_finalization_scripts == 'true' }} if: ${{ needs.setup.outputs.copy_finalization_scripts == 'true' }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
with: with:
fetch-depth: 0 fetch-depth: 0
@ -94,7 +94,7 @@ jobs:
echo "moved_files=$moved_files" >> $GITHUB_OUTPUT echo "moved_files=$moved_files" >> $GITHUB_OUTPUT
- name: Log in to Azure - production subscription - name: Log in to Azure - production subscription
uses: Azure/login@de95379fe4dadc2defb305917eaa7e5dde727294 # v1.5.1 uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
with: with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
@ -108,7 +108,7 @@ jobs:
devops-alerts-slack-webhook-url" devops-alerts-slack-webhook-url"
- name: Import GPG keys - name: Import GPG keys
uses: crazy-max/ghaction-import-gpg@82a020f1f7f605c65dd2449b392a52c3fcfef7ef # v6.0.0 uses: crazy-max/ghaction-import-gpg@01dd5d3ca463c7f10f7f4f7b4f177225ac661ee4 # v6.1.0
with: with:
gpg_private_key: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key }} gpg_private_key: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key }}
passphrase: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key-passphrase }} passphrase: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key-passphrase }}
@ -154,7 +154,7 @@ jobs:
- name: Notify Slack about creation of PR - name: Notify Slack about creation of PR
if: ${{ steps.commit.outputs.pr_needed == 'true' }} if: ${{ steps.commit.outputs.pr_needed == 'true' }}
uses: act10ns/slack@ed1309ab9862e57e9e583e51c7889486b9a00b0f # v2.0.0 uses: act10ns/slack@44541246747a30eb3102d87f7a4cc5471b0ffb7d # v2.1.0
env: env:
SLACK_WEBHOOK_URL: ${{ steps.retrieve-secrets.outputs.devops-alerts-slack-webhook-url }} SLACK_WEBHOOK_URL: ${{ steps.retrieve-secrets.outputs.devops-alerts-slack-webhook-url }}
with: with:

View File

@ -14,7 +14,7 @@ jobs:
# Feature request # Feature request
- if: github.event.label.name == 'feature-request' - if: github.event.label.name == 'feature-request'
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: with:
comment: | 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. 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 # Intended behavior
- if: github.event.label.name == 'intended-behavior' - if: github.event.label.name == 'intended-behavior'
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: with:
comment: | 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. 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 # Customer support request
- if: github.event.label.name == 'customer-support' - if: github.event.label.name == 'customer-support'
name: Customer Support request name: Customer Support request
uses: peter-evans/close-issue@1373cadf1f0c96c1420bc000cfba2273ea307fd1 # v2.2.0 uses: peter-evans/close-issue@276d7966e389d888f011539a86c8920025ea0626 # v3.0.1
with: with:
comment: | 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. 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 # Resolved
- if: github.event.label.name == 'resolved' - if: github.event.label.name == 'resolved'
name: Resolved name: Resolved
uses: peter-evans/close-issue@1373cadf1f0c96c1420bc000cfba2273ea307fd1 # v2.2.0 uses: peter-evans/close-issue@276d7966e389d888f011539a86c8920025ea0626 # v3.0.1
with: with:
comment: | 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. 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 # Stale
- if: github.event.label.name == 'stale' - if: github.event.label.name == 'stale'
name: Stale name: Stale
uses: peter-evans/close-issue@1373cadf1f0c96c1420bc000cfba2273ea307fd1 # v2.2.0 uses: peter-evans/close-issue@276d7966e389d888f011539a86c8920025ea0626 # v3.0.1
with: with:
comment: | comment: |
As we havent heard from you about this problem in some time, this issue will now be closed. 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 runs-on: ubuntu-22.04
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # v3.2.0 uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
- name: Verify format - name: Verify format
run: dotnet format --verify-no-changes run: dotnet format --verify-no-changes
@ -68,13 +68,13 @@ jobs:
node: true node: true
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # v3.2.0 uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
- name: Set up Node - name: Set up Node
uses: actions/setup-node@5e21ff4d9bc1a8cf6de233a3057d20ec6b3fb69d # v3.8.1 uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3
with: with:
cache: "npm" cache: "npm"
cache-dependency-path: "**/package-lock.json" cache-dependency-path: "**/package-lock.json"
@ -110,7 +110,7 @@ jobs:
ls -atlh ../../../ ls -atlh ../../../
- name: Upload project artifact - name: Upload project artifact
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3 uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
with: with:
name: ${{ matrix.project_name }}.zip name: ${{ matrix.project_name }}.zip
path: ${{ matrix.base_path }}/${{ matrix.project_name }}/${{ matrix.project_name }}.zip path: ${{ matrix.base_path }}/${{ matrix.project_name }}/${{ matrix.project_name }}.zip
@ -173,7 +173,7 @@ jobs:
dotnet: true dotnet: true
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: Check branch to publish - name: Check branch to publish
env: env:
@ -190,7 +190,7 @@ jobs:
########## ACRs ########## ########## ACRs ##########
- name: Log in to Azure - production subscription - name: Log in to Azure - production subscription
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7 uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
with: with:
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }} creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
@ -198,7 +198,7 @@ jobs:
run: az acr login -n bitwardenprod run: az acr login -n bitwardenprod
- name: Log in to Azure - CI subscription - name: Log in to Azure - CI subscription
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7 uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
with: with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
@ -251,7 +251,7 @@ jobs:
- name: Get build artifact - name: Get build artifact
if: ${{ matrix.dotnet }} if: ${{ matrix.dotnet }}
uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3.0.2 uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
with: with:
name: ${{ matrix.project_name }}.zip name: ${{ matrix.project_name }}.zip
@ -263,7 +263,7 @@ jobs:
-d ${{ matrix.base_path }}/${{ matrix.project_name }}/obj/build-output/publish -d ${{ matrix.base_path }}/${{ matrix.project_name }}/obj/build-output/publish
- name: Build Docker image - name: Build Docker image
uses: docker/build-push-action@1104d471370f9806843c095c1db02b5a90c5f8b6 # v3.3.1 uses: docker/build-push-action@5cd11c3a4ced054e52742c5fd54dca954e0edd85 # v6.7.0
with: with:
context: ${{ matrix.base_path }}/${{ matrix.project_name }} context: ${{ matrix.base_path }}/${{ matrix.project_name }}
file: ${{ matrix.base_path }}/${{ matrix.project_name }}/Dockerfile file: ${{ matrix.base_path }}/${{ matrix.project_name }}/Dockerfile
@ -275,14 +275,14 @@ jobs:
- name: Scan Docker image - name: Scan Docker image
id: container-scan id: container-scan
uses: anchore/scan-action@3343887d815d7b07465f6fdcd395bd66508d486a # v3.6.4 uses: anchore/scan-action@64a33b277ea7a1215a3c142735a1091341939ff5 # v4.1.2
with: with:
image: ${{ steps.image-tags.outputs.primary_tag }} image: ${{ steps.image-tags.outputs.primary_tag }}
fail-build: false fail-build: false
output-format: sarif output-format: sarif
- name: Upload Grype results to GitHub - name: Upload Grype results to GitHub
uses: github/codeql-action/upload-sarif@b7bf0a3ed3ecfa44160715d7c442788f65f0f923 # v3.23.2 uses: github/codeql-action/upload-sarif@2c779ab0d087cd7fe7b826087247c2c81f27bfa6 # v3.26.5
with: with:
sarif_file: ${{ steps.container-scan.outputs.sarif }} sarif_file: ${{ steps.container-scan.outputs.sarif }}
@ -292,13 +292,13 @@ jobs:
needs: build-docker needs: build-docker
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: Set up .NET - 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 - name: Log in to Azure - production subscription
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7 uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
with: with:
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }} creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
@ -355,7 +355,7 @@ jobs:
- name: Upload Docker stub US artifact - name: Upload Docker stub US artifact
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc' if: github.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: with:
name: docker-stub-US.zip name: docker-stub-US.zip
path: docker-stub-US.zip path: docker-stub-US.zip
@ -363,7 +363,7 @@ jobs:
- name: Upload Docker stub EU artifact - name: Upload Docker stub EU artifact
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc' if: github.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: with:
name: docker-stub-EU.zip name: docker-stub-EU.zip
path: docker-stub-EU.zip path: docker-stub-EU.zip
@ -371,7 +371,7 @@ jobs:
- name: Upload Docker stub US checksum artifact - name: Upload Docker stub US checksum artifact
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc' if: github.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: with:
name: docker-stub-US-sha256.txt name: docker-stub-US-sha256.txt
path: docker-stub-US-sha256.txt path: docker-stub-US-sha256.txt
@ -379,13 +379,13 @@ jobs:
- name: Upload Docker stub EU checksum artifact - name: Upload Docker stub EU checksum artifact
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc' if: github.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: with:
name: docker-stub-EU-sha256.txt name: docker-stub-EU-sha256.txt
path: docker-stub-EU-sha256.txt path: docker-stub-EU-sha256.txt
if-no-files-found: error if-no-files-found: error
- name: Build Swagger - name: Build Public API Swagger
run: | run: |
cd ./src/Api cd ./src/Api
echo "Restore tools" echo "Restore tools"
@ -402,13 +402,54 @@ jobs:
DOTNET_ROLL_FORWARD_ON_NO_CANDIDATE_FX: 2 DOTNET_ROLL_FORWARD_ON_NO_CANDIDATE_FX: 2
GLOBALSETTINGS__SQLSERVER__CONNECTIONSTRING: "placeholder" GLOBALSETTINGS__SQLSERVER__CONNECTIONSTRING: "placeholder"
- name: Upload Swagger artifact - name: Upload Public API Swagger artifact
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3 uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
with: with:
name: swagger.json name: swagger.json
path: swagger.json path: swagger.json
if-no-files-found: error 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: build-mssqlmigratorutility:
name: Build MSSQL migrator utility name: Build MSSQL migrator utility
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
@ -426,10 +467,10 @@ jobs:
- win-x64 - win-x64
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # v3.2.0 uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
- name: Print environment - name: Print environment
run: | run: |
@ -445,7 +486,7 @@ jobs:
- name: Upload project artifact for Windows - name: Upload project artifact for Windows
if: ${{ contains(matrix.target, 'win') == true }} if: ${{ contains(matrix.target, 'win') == true }}
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3 uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
with: with:
name: MsSqlMigratorUtility-${{ matrix.target }} name: MsSqlMigratorUtility-${{ matrix.target }}
path: util/MsSqlMigratorUtility/obj/build-output/publish/MsSqlMigratorUtility.exe path: util/MsSqlMigratorUtility/obj/build-output/publish/MsSqlMigratorUtility.exe
@ -453,7 +494,7 @@ jobs:
- name: Upload project artifact - name: Upload project artifact
if: ${{ contains(matrix.target, 'win') == false }} if: ${{ contains(matrix.target, 'win') == false }}
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3 uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
with: with:
name: MsSqlMigratorUtility-${{ matrix.target }} name: MsSqlMigratorUtility-${{ matrix.target }}
path: util/MsSqlMigratorUtility/obj/build-output/publish/MsSqlMigratorUtility path: util/MsSqlMigratorUtility/obj/build-output/publish/MsSqlMigratorUtility
@ -465,7 +506,7 @@ jobs:
needs: build-docker needs: build-docker
steps: steps:
- name: Log in to Azure - CI subscription - name: Log in to Azure - CI subscription
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7 uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
with: with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
@ -477,7 +518,7 @@ jobs:
secrets: "github-pat-bitwarden-devops-bot-repo-scope" secrets: "github-pat-bitwarden-devops-bot-repo-scope"
- name: Trigger self-host build - name: Trigger self-host build
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6.4.1 uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with: with:
github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }} github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
script: | script: |
@ -498,7 +539,7 @@ jobs:
needs: build-docker needs: build-docker
steps: steps:
- name: Log in to Azure - CI subscription - name: Log in to Azure - CI subscription
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7 uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
with: with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
@ -510,7 +551,7 @@ jobs:
secrets: "github-pat-bitwarden-devops-bot-repo-scope" secrets: "github-pat-bitwarden-devops-bot-repo-scope"
- name: Trigger k8s deploy - name: Trigger k8s deploy
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6.4.1 uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with: with:
github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }} github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
script: | script: |
@ -547,7 +588,7 @@ jobs:
run: exit 1 run: exit 1
- name: Log in to Azure - CI subscription - name: Log in to Azure - CI subscription
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7 uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
if: failure() if: failure()
with: with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
@ -561,7 +602,7 @@ jobs:
secrets: "devops-alerts-slack-webhook-url" secrets: "devops-alerts-slack-webhook-url"
- name: Notify Slack on failure - name: Notify Slack on failure
uses: act10ns/slack@ed1309ab9862e57e9e583e51c7889486b9a00b0f # v2.0.0 uses: act10ns/slack@44541246747a30eb3102d87f7a4cc5471b0ffb7d # v2.1.0
if: failure() if: failure()
env: env:
SLACK_WEBHOOK_URL: ${{ steps.retrieve-secrets.outputs.devops-alerts-slack-webhook-url }} SLACK_WEBHOOK_URL: ${{ steps.retrieve-secrets.outputs.devops-alerts-slack-webhook-url }}

View File

@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- name: Log in to Azure - production subscription - name: Log in to Azure - production subscription
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7 uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
with: with:
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }} creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}

View File

@ -24,7 +24,7 @@ jobs:
secrets: "github-pat-bitwarden-devops-bot-repo-scope" secrets: "github-pat-bitwarden-devops-bot-repo-scope"
- name: Checkout main - name: Checkout main
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
with: with:
ref: main ref: main
token: ${{ steps.retrieve-bot-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }} token: ${{ steps.retrieve-bot-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }}

View File

@ -1,26 +1,43 @@
---
name: Collect code references name: Collect code references
on: on:
pull_request: pull_request:
branches-ignore:
- "renovate/**"
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: permissions:
contents: read contents: read
pull-requests: write pull-requests: write
jobs:
refs:
name: Code reference collection
runs-on: ubuntu-22.04
steps: steps:
- name: Check out repository - name: Check out repository
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: Collect - name: Collect
id: 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: with:
project-key: default project-key: default
environment-key: dev environment-key: dev

View File

@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- name: Log in to Azure - name: Log in to Azure
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7 uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
with: with:
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }} creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
@ -80,7 +80,7 @@ jobs:
run: exit 1 run: exit 1
- name: Log in to Azure - CI subscription - name: Log in to Azure - CI subscription
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7 uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
if: failure() if: failure()
with: with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
@ -94,7 +94,7 @@ jobs:
secrets: "devops-alerts-slack-webhook-url" secrets: "devops-alerts-slack-webhook-url"
- name: Notify Slack on failure - name: Notify Slack on failure
uses: act10ns/slack@ed1309ab9862e57e9e583e51c7889486b9a00b0f # v2.0.0 uses: act10ns/slack@44541246747a30eb3102d87f7a4cc5471b0ffb7d # v2.1.0
if: failure() if: failure()
env: env:
SLACK_WEBHOOK_URL: ${{ steps.retrieve-secrets.outputs.devops-alerts-slack-webhook-url }} SLACK_WEBHOOK_URL: ${{ steps.retrieve-secrets.outputs.devops-alerts-slack-webhook-url }}

View File

@ -29,7 +29,7 @@ jobs:
label: "DB-migrations-changed" label: "DB-migrations-changed"
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
with: with:
fetch-depth: 2 fetch-depth: 2

View File

@ -27,7 +27,7 @@ jobs:
branch-name: ${{ steps.branch.outputs.branch-name }} branch-name: ${{ steps.branch.outputs.branch-name }}
steps: steps:
- name: Branch check - name: Branch check
if: ${{ github.event.inputs.release_type != 'Dry Run' }} if: ${{ inputs.release_type != 'Dry Run' }}
run: | run: |
if [[ "$GITHUB_REF" != "refs/heads/rc" ]] && [[ "$GITHUB_REF" != "refs/heads/hotfix-rc" ]]; then if [[ "$GITHUB_REF" != "refs/heads/rc" ]] && [[ "$GITHUB_REF" != "refs/heads/hotfix-rc" ]]; then
echo "===================================" echo "==================================="
@ -37,13 +37,13 @@ jobs:
fi fi
- name: Check out repo - name: Check out repo
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: Check release version - name: Check release version
id: version id: version
uses: bitwarden/gh-actions/release-version-check@main uses: bitwarden/gh-actions/release-version-check@main
with: with:
release-type: ${{ github.event.inputs.release_type }} release-type: ${{ inputs.release_type }}
project-type: dotnet project-type: dotnet
file: Directory.Build.props file: Directory.Build.props
@ -53,125 +53,6 @@ jobs:
BRANCH_NAME=$(basename ${{ github.ref }}) BRANCH_NAME=$(basename ${{ github.ref }})
echo "branch-name=$BRANCH_NAME" >> $GITHUB_OUTPUT 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: release-docker:
name: Build Docker images name: Build Docker images
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
@ -202,7 +83,7 @@ jobs:
steps: steps:
- name: Print environment - name: Print environment
env: env:
RELEASE_OPTION: ${{ github.event.inputs.release_type }} RELEASE_OPTION: ${{ inputs.release_type }}
run: | run: |
whoami whoami
docker --version docker --version
@ -211,7 +92,7 @@ jobs:
echo "Github Release Option: $RELEASE_OPTION" echo "Github Release Option: $RELEASE_OPTION"
- name: Check out repo - name: Check out repo
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: Set up project name - name: Set up project name
id: setup id: setup
@ -223,7 +104,7 @@ jobs:
########## ACR PROD ########## ########## ACR PROD ##########
- name: Log in to Azure - production subscription - name: Log in to Azure - production subscription
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7 uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
with: with:
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }} creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
@ -234,7 +115,7 @@ jobs:
env: env:
PROJECT_NAME: ${{ steps.setup.outputs.project_name }} PROJECT_NAME: ${{ steps.setup.outputs.project_name }}
run: | run: |
if [[ "${{ github.event.inputs.release_type }}" == "Dry Run" ]]; then if [[ "${{ inputs.release_type }}" == "Dry Run" ]]; then
docker pull $_AZ_REGISTRY/$PROJECT_NAME:latest docker pull $_AZ_REGISTRY/$PROJECT_NAME:latest
else else
docker pull $_AZ_REGISTRY/$PROJECT_NAME:$_BRANCH_NAME docker pull $_AZ_REGISTRY/$PROJECT_NAME:$_BRANCH_NAME
@ -244,7 +125,7 @@ jobs:
env: env:
PROJECT_NAME: ${{ steps.setup.outputs.project_name }} PROJECT_NAME: ${{ steps.setup.outputs.project_name }}
run: | 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 docker tag $_AZ_REGISTRY/$PROJECT_NAME:latest $_AZ_REGISTRY/$PROJECT_NAME:dryrun
else else
docker tag $_AZ_REGISTRY/$PROJECT_NAME:$_BRANCH_NAME $_AZ_REGISTRY/$PROJECT_NAME:$_RELEASE_VERSION docker tag $_AZ_REGISTRY/$PROJECT_NAME:$_BRANCH_NAME $_AZ_REGISTRY/$PROJECT_NAME:$_RELEASE_VERSION
@ -255,7 +136,7 @@ jobs:
env: env:
PROJECT_NAME: ${{ steps.setup.outputs.project_name }} PROJECT_NAME: ${{ steps.setup.outputs.project_name }}
run: | run: |
if [[ "${{ github.event.inputs.release_type }}" == "Dry Run" ]]; then if [[ "${{ inputs.release_type }}" == "Dry Run" ]]; then
docker push $_AZ_REGISTRY/$PROJECT_NAME:dryrun docker push $_AZ_REGISTRY/$PROJECT_NAME:dryrun
else else
docker push $_AZ_REGISTRY/$PROJECT_NAME:$_RELEASE_VERSION docker push $_AZ_REGISTRY/$PROJECT_NAME:$_RELEASE_VERSION
@ -268,12 +149,10 @@ jobs:
release: release:
name: Create GitHub release name: Create GitHub release
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
needs: needs: setup
- setup
- deploy
steps: steps:
- name: Download latest release Docker stubs - 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 uses: bitwarden/gh-actions/download-artifacts@main
with: with:
workflow: build.yml workflow: build.yml
@ -286,7 +165,7 @@ jobs:
swagger.json" swagger.json"
- name: Dry Run - Download latest release Docker stubs - 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 uses: bitwarden/gh-actions/download-artifacts@main
with: with:
workflow: build.yml workflow: build.yml
@ -299,8 +178,8 @@ jobs:
swagger.json" swagger.json"
- name: Create release - name: Create release
if: ${{ github.event.inputs.release_type != 'Dry Run' }} if: ${{ inputs.release_type != 'Dry Run' }}
uses: ncipollo/release-action@6c75be85e571768fa31b40abf38de58ba0397db5 # v1.13.0 uses: ncipollo/release-action@2c591bcc8ecdcd2db72b97d6147f871fcd833ba5 # v1.14.0
with: with:
artifacts: "docker-stub-US.zip, artifacts: "docker-stub-US.zip,
docker-stub-US-sha256.txt, docker-stub-US-sha256.txt,

View File

@ -26,12 +26,12 @@ jobs:
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
with: with:
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}
- name: Scan with Checkmarx - name: Scan with Checkmarx
uses: checkmarx/ast-github-action@749fec53e0db0f6404a97e2e0807c3e80e3583a7 #2.0.23 uses: checkmarx/ast-github-action@1fe318de2993222574e6249750ba9000a4e2a6cd # 2.0.33
env: env:
INCREMENTAL: "${{ contains(github.event_name, 'pull_request') && '--sast-incremental' || '' }}" INCREMENTAL: "${{ contains(github.event_name, 'pull_request') && '--sast-incremental' || '' }}"
with: with:
@ -46,7 +46,7 @@ jobs:
--output-path . ${{ env.INCREMENTAL }} --output-path . ${{ env.INCREMENTAL }}
- name: Upload Checkmarx results to GitHub - name: Upload Checkmarx results to GitHub
uses: github/codeql-action/upload-sarif@1b1aada464948af03b950897e5eb522f92603cc2 # v3.24.9 uses: github/codeql-action/upload-sarif@2c779ab0d087cd7fe7b826087247c2c81f27bfa6 # v3.26.5
with: with:
sarif_file: cx_result.sarif sarif_file: cx_result.sarif
@ -59,19 +59,33 @@ jobs:
pull-requests: write pull-requests: write
steps: steps:
- name: Set up JDK 17
uses: actions/setup-java@6a0805fcefea3d4657a47ac4c165951e33482018 # v4.2.2
with:
java-version: 17
distribution: "zulu"
- name: Check out repo - name: Check out repo
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
with: with:
fetch-depth: 0 fetch-depth: 0
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}
- name: Set up .NET
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
- name: Install SonarCloud scanner
run: dotnet tool install dotnet-sonarscanner -g
- name: Scan with SonarCloud - name: Scan with SonarCloud
uses: sonarsource/sonarcloud-github-action@49e6cd3b187936a73b8280d59ffd9da69df63ec9 # v2.1.1
env: env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: run: |
args: > dotnet-sonarscanner begin /k:"${{ github.repository_owner }}_${{ github.event.repository.name }}" \
-Dsonar.organization=${{ github.repository_owner }} /d:sonar.test.inclusions=test/,bitwarden_license/test/ \
-Dsonar.projectKey=${{ github.repository_owner }}_${{ github.event.repository.name }} /d:sonar.exclusions=test/,bitwarden_license/test/ \
-Dsonar.tests=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 runs-on: ubuntu-22.04
steps: steps:
- name: Check - name: Check
uses: actions/stale@1160a2240286f5da8ec72b1c0816ce2481aabf84 # v8.0.0 uses: actions/stale@28ca1036281a5e5922ead5184a1bbf96e5fc984e # v9.0.0
with: with:
stale-issue-label: "needs-reply" stale-issue-label: "needs-reply"
stale-pr-label: "needs-changes" 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" - "rc"
- "hotfix-rc" - "hotfix-rc"
paths: paths:
- ".github/workflows/infrastructure-tests.yml" # This file - ".github/workflows/test-database.yml" # This file
- "src/Sql/**" # SQL Server Database Changes - "src/Sql/**" # SQL Server Database Changes
- "util/Migrator/**" # New SQL Server Migrations - "util/Migrator/**" # New SQL Server Migrations
- "util/MySqlMigrations/**" # Changes to MySQL - "util/MySqlMigrations/**" # Changes to MySQL
@ -20,7 +20,7 @@ on:
- "test/Infrastructure.IntegrationTest/**" # Any changes to the tests - "test/Infrastructure.IntegrationTest/**" # Any changes to the tests
pull_request: pull_request:
paths: paths:
- ".github/workflows/infrastructure-tests.yml" # This file - ".github/workflows/test-database.yml" # This file
- "src/Sql/**" # SQL Server Database Changes - "src/Sql/**" # SQL Server Database Changes
- "util/Migrator/**" # New SQL Server Migrations - "util/Migrator/**" # New SQL Server Migrations
- "util/MySqlMigrations/**" # Changes to MySQL - "util/MySqlMigrations/**" # Changes to MySQL
@ -36,10 +36,10 @@ jobs:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # v3.2.0 uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
- name: Restore tools - name: Restore tools
run: dotnet tool restore run: dotnet tool restore
@ -56,6 +56,24 @@ jobs:
- name: Sleep - name: Sleep
run: sleep 15s 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 - name: Migrate SQL Server
run: 'dotnet run --project util/MsSqlMigratorUtility/ "$CONN_STR"' run: 'dotnet run --project util/MsSqlMigratorUtility/ "$CONN_STR"'
env: env:
@ -98,7 +116,7 @@ jobs:
shell: pwsh shell: pwsh
- name: Report test results - name: Report test results
uses: dorny/test-reporter@c9b3d0e2bd2a4e96aaf424dbaa31c46b42318226 # v1.6.0 uses: dorny/test-reporter@31a54ee7ebcacc03a09ea97a7e5465a47b84aea5 # v1.9.1
if: always() if: always()
with: with:
name: Test Results name: Test Results
@ -117,10 +135,10 @@ jobs:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # v3.2.0 uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
- name: Print environment - name: Print environment
run: | run: |
@ -134,7 +152,7 @@ jobs:
shell: pwsh shell: pwsh
- name: Upload DACPAC - name: Upload DACPAC
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3 uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
with: with:
name: sql.dacpac name: sql.dacpac
path: Sql.dacpac path: Sql.dacpac
@ -160,7 +178,7 @@ jobs:
shell: pwsh shell: pwsh
- name: Report validation results - name: Report validation results
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3 uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
with: with:
name: report.xml name: report.xml
path: | path: |

View File

@ -1,4 +1,3 @@
---
name: Testing name: Testing
on: on:
@ -14,18 +13,43 @@ env:
_AZ_REGISTRY: "bitwardenprod.azurecr.io" _AZ_REGISTRY: "bitwardenprod.azurecr.io"
jobs: jobs:
check-test-secrets:
name: Check for test secrets
runs-on: ubuntu-22.04
outputs:
available: ${{ steps.check-test-secrets.outputs.available }}
permissions:
contents: read
steps:
- name: Check
id: check-test-secrets
run: |
if [ "${{ secrets.CODECOV_TOKEN }}" != '' ]; then
echo "available=true" >> $GITHUB_OUTPUT;
else
echo "available=false" >> $GITHUB_OUTPUT;
fi
testing: testing:
name: Run tests name: Run tests
if: ${{ startsWith(github.head_ref, 'version_bump_') == false }} if: ${{ startsWith(github.head_ref, 'version_bump_') == false }}
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
needs: check-test-secrets
permissions:
checks: write
contents: read
pull-requests: write
env: env:
NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # v3.2.0 uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
- name: Print environment - name: Print environment
run: | 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" 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 - name: Report test results
uses: dorny/test-reporter@c9b3d0e2bd2a4e96aaf424dbaa31c46b42318226 # v1.6.0 uses: dorny/test-reporter@31a54ee7ebcacc03a09ea97a7e5465a47b84aea5 # v1.9.1
if: always() if: ${{ needs.check-test-secrets.outputs.available == 'true' && !cancelled() }}
with: with:
name: Test Results name: Test Results
path: "**/*-test-results.trx" path: "**/*-test-results.trx"
@ -53,6 +77,7 @@ jobs:
fail-on-error: true fail-on-error: true
- name: Upload to codecov.io - 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: env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}

View File

@ -12,6 +12,10 @@ on:
description: "Cut RC branch?" description: "Cut RC branch?"
default: true default: true
type: boolean type: boolean
enable_slack_notification:
description: "Enable Slack notifications for upcoming release?"
default: false
type: boolean
jobs: jobs:
bump_version: bump_version:
@ -26,10 +30,16 @@ jobs:
with: with:
version: ${{ inputs.version_number_override }} 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 - name: Check out branch
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
with:
ref: main
- name: Check if RC branch exists - name: Check if RC branch exists
if: ${{ inputs.cut_rc_branch == true }} if: ${{ inputs.cut_rc_branch == true }}
@ -42,7 +52,7 @@ jobs:
fi fi
- name: Log in to Azure - CI subscription - name: Log in to Azure - CI subscription
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7 uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
with: with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
@ -56,7 +66,7 @@ jobs:
github-pat-bitwarden-devops-bot-repo-scope" github-pat-bitwarden-devops-bot-repo-scope"
- name: Import GPG key - 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: with:
gpg_private_key: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key }} gpg_private_key: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key }}
passphrase: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key-passphrase }} passphrase: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key-passphrase }}
@ -198,6 +208,13 @@ jobs:
PR_NUMBER: ${{ steps.create-pr.outputs.pr_number }} PR_NUMBER: ${{ steps.create-pr.outputs.pr_number }}
run: gh pr merge $PR_NUMBER --squash --auto --delete-branch 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: cut_rc:
name: Cut RC branch name: Cut RC branch
@ -206,7 +223,7 @@ jobs:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- name: Check out branch - name: Check out branch
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
with: with:
ref: main ref: main

7
.gitignore vendored
View File

@ -205,16 +205,15 @@ mail_dist/
src/Core/Properties/launchSettings.json src/Core/Properties/launchSettings.json
*.override.env *.override.env
**/*.DS_Store **/*.DS_Store
src/Admin/wwwroot/lib src/Admin/wwwroot/assets
src/Admin/wwwroot/css
.vscode/* .vscode/*
**/.vscode/* **/.vscode/*
bitwarden_license/src/Sso/wwwroot/lib bitwarden_license/src/Sso/wwwroot/assets
bitwarden_license/src/Sso/wwwroot/css
.github/test/build.secrets .github/test/build.secrets
**/CoverageOutput/ **/CoverageOutput/
.idea/* .idea/*
**/**.swp **/**.swp
.mono
src/Admin/Admin.zip src/Admin/Admin.zip
src/Api/Api.zip src/Api/Api.zip

33
.vscode/tasks.json vendored
View File

@ -3,6 +3,7 @@
"tasks": [ "tasks": [
{ {
"label": "buildIdentityApi", "label": "buildIdentityApi",
"hide": true,
"dependsOrder": "sequence", "dependsOrder": "sequence",
"dependsOn": [ "dependsOn": [
"buildIdentity", "buildIdentity",
@ -14,6 +15,7 @@
}, },
{ {
"label": "buildIdentityApiAdmin", "label": "buildIdentityApiAdmin",
"hide": true,
"dependsOrder": "sequence", "dependsOrder": "sequence",
"dependsOn": [ "dependsOn": [
"buildIdentity", "buildIdentity",
@ -26,6 +28,7 @@
}, },
{ {
"label": "buildFullServer", "label": "buildFullServer",
"hide": true,
"dependsOrder": "sequence", "dependsOrder": "sequence",
"dependsOn": [ "dependsOn": [
"buildAdmin", "buildAdmin",
@ -40,6 +43,7 @@
}, },
{ {
"label": "buildSelfHostBit", "label": "buildSelfHostBit",
"hide": true,
"dependsOrder": "sequence", "dependsOrder": "sequence",
"dependsOn": [ "dependsOn": [
"buildAdmin", "buildAdmin",
@ -52,6 +56,7 @@
}, },
{ {
"label": "buildSelfHostOss", "label": "buildSelfHostOss",
"hide": true,
"dependsOrder": "sequence", "dependsOrder": "sequence",
"dependsOn": [ "dependsOn": [
"buildAdmin", "buildAdmin",
@ -62,6 +67,7 @@
}, },
{ {
"label": "buildIcons", "label": "buildIcons",
"hide": true,
"command": "dotnet", "command": "dotnet",
"type": "process", "type": "process",
"args": [ "args": [
@ -74,6 +80,7 @@
}, },
{ {
"label": "buildPortal", "label": "buildPortal",
"hide": true,
"command": "dotnet", "command": "dotnet",
"type": "process", "type": "process",
"args": [ "args": [
@ -86,6 +93,7 @@
}, },
{ {
"label": "buildSso", "label": "buildSso",
"hide": true,
"command": "dotnet", "command": "dotnet",
"type": "process", "type": "process",
"args": [ "args": [
@ -98,6 +106,7 @@
}, },
{ {
"label": "buildEvents", "label": "buildEvents",
"hide": true,
"command": "dotnet", "command": "dotnet",
"type": "process", "type": "process",
"args": [ "args": [
@ -110,6 +119,7 @@
}, },
{ {
"label": "buildEventsProcessor", "label": "buildEventsProcessor",
"hide": true,
"command": "dotnet", "command": "dotnet",
"type": "process", "type": "process",
"args": [ "args": [
@ -122,6 +132,7 @@
}, },
{ {
"label": "buildAdmin", "label": "buildAdmin",
"hide": true,
"command": "dotnet", "command": "dotnet",
"type": "process", "type": "process",
"args": [ "args": [
@ -134,6 +145,7 @@
}, },
{ {
"label": "buildIdentity", "label": "buildIdentity",
"hide": true,
"command": "dotnet", "command": "dotnet",
"type": "process", "type": "process",
"args": [ "args": [
@ -146,6 +158,7 @@
}, },
{ {
"label": "buildAPI", "label": "buildAPI",
"hide": true,
"command": "dotnet", "command": "dotnet",
"type": "process", "type": "process",
"args": [ "args": [
@ -162,6 +175,7 @@
}, },
{ {
"label": "buildNotifications", "label": "buildNotifications",
"hide": true,
"command": "dotnet", "command": "dotnet",
"type": "process", "type": "process",
"args": [ "args": [
@ -178,6 +192,7 @@
}, },
{ {
"label": "buildBilling", "label": "buildBilling",
"hide": true,
"command": "dotnet", "command": "dotnet",
"type": "process", "type": "process",
"args": [ "args": [
@ -192,20 +207,6 @@
"isDefault": true "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", "label": "test",
"type": "shell", "type": "shell",
@ -225,13 +226,15 @@
"problemMatcher": "$msCompile" "problemMatcher": "$msCompile"
}, },
{ {
"label": "Setup Secrets", "label": "Set Up Secrets",
"detail": "A task to run setup_secrets.ps1",
"type": "shell", "type": "shell",
"command": "pwsh -WorkingDirectory ${workspaceFolder}/dev -Command '${workspaceFolder}/dev/setup_secrets.ps1 -clear:$${input:setupSecretsClear}'", "command": "pwsh -WorkingDirectory ${workspaceFolder}/dev -Command '${workspaceFolder}/dev/setup_secrets.ps1 -clear:$${input:setupSecretsClear}'",
"problemMatcher": [] "problemMatcher": []
}, },
{ {
"label": "Install Dev Cert", "label": "Install Dev Cert",
"detail": "A task to install the Bitwarden developer cert to run your local install as an admin.",
"type": "shell", "type": "shell",
"command": "dotnet tool install -g dotnet-certificate-tool -g && certificate-tool add --file ${workspaceFolder}/dev/dev.pfx --password '${input:certPassword}'", "command": "dotnet tool install -g dotnet-certificate-tool -g && certificate-tool add --file ${workspaceFolder}/dev/dev.pfx --password '${input:certPassword}'",
"problemMatcher": [] "problemMatcher": []

View File

@ -3,7 +3,7 @@
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<Version>2024.5.0</Version> <Version>2024.8.1</Version>
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace> <RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>

View File

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

View File

@ -5,6 +5,7 @@ using Bit.Core.AdminConsole.Providers.Interfaces;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Entities; using Bit.Core.Billing.Entities;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Repositories; using Bit.Core.Billing.Repositories;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; 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.Enums.Provider;
using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Providers.Interfaces;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Constants; using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Services;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Microsoft.Extensions.Logging;
using Stripe; using Stripe;
namespace Bit.Commercial.Core.AdminConsole.Providers; namespace Bit.Commercial.Core.AdminConsole.Providers;
@ -20,35 +19,35 @@ namespace Bit.Commercial.Core.AdminConsole.Providers;
public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProviderCommand public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProviderCommand
{ {
private readonly IEventService _eventService; private readonly IEventService _eventService;
private readonly ILogger<RemoveOrganizationFromProviderCommand> _logger;
private readonly IMailService _mailService; private readonly IMailService _mailService;
private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationRepository _organizationRepository;
private readonly IOrganizationService _organizationService; private readonly IOrganizationService _organizationService;
private readonly IProviderOrganizationRepository _providerOrganizationRepository; private readonly IProviderOrganizationRepository _providerOrganizationRepository;
private readonly IStripeAdapter _stripeAdapter; private readonly IStripeAdapter _stripeAdapter;
private readonly IScaleSeatsCommand _scaleSeatsCommand;
private readonly IFeatureService _featureService; private readonly IFeatureService _featureService;
private readonly IProviderBillingService _providerBillingService;
private readonly ISubscriberService _subscriberService;
public RemoveOrganizationFromProviderCommand( public RemoveOrganizationFromProviderCommand(
IEventService eventService, IEventService eventService,
ILogger<RemoveOrganizationFromProviderCommand> logger,
IMailService mailService, IMailService mailService,
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
IOrganizationService organizationService, IOrganizationService organizationService,
IProviderOrganizationRepository providerOrganizationRepository, IProviderOrganizationRepository providerOrganizationRepository,
IStripeAdapter stripeAdapter, IStripeAdapter stripeAdapter,
IScaleSeatsCommand scaleSeatsCommand, IFeatureService featureService,
IFeatureService featureService) IProviderBillingService providerBillingService,
ISubscriberService subscriberService)
{ {
_eventService = eventService; _eventService = eventService;
_logger = logger;
_mailService = mailService; _mailService = mailService;
_organizationRepository = organizationRepository; _organizationRepository = organizationRepository;
_organizationService = organizationService; _organizationService = organizationService;
_providerOrganizationRepository = providerOrganizationRepository; _providerOrganizationRepository = providerOrganizationRepository;
_stripeAdapter = stripeAdapter; _stripeAdapter = stripeAdapter;
_scaleSeatsCommand = scaleSeatsCommand;
_featureService = featureService; _featureService = featureService;
_providerBillingService = providerBillingService;
_subscriberService = subscriberService;
} }
public async Task RemoveOrganizationFromProvider( public async Task RemoveOrganizationFromProvider(
@ -99,23 +98,19 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
Provider provider, Provider provider,
IEnumerable<string> organizationOwnerEmails) IEnumerable<string> organizationOwnerEmails)
{ {
if (!organization.IsStripeEnabled())
{
return;
}
var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling); 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, await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, new CustomerUpdateOptions
{
Description = string.Empty,
Email = organization.BillingEmail Email = organization.BillingEmail
}; });
await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, customerUpdateOptions);
if (isConsolidatedBillingEnabled && provider.Status == ProviderStatusType.Billable)
{
var plan = StaticStore.GetPlan(organization.PlanType).PasswordManager; var plan = StaticStore.GetPlan(organization.PlanType).PasswordManager;
var subscriptionCreateOptions = new SubscriptionCreateOptions var subscriptionCreateOptions = new SubscriptionCreateOptions
@ -136,19 +131,31 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
var subscription = await _stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions); var subscription = await _stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
organization.GatewaySubscriptionId = subscription.Id; organization.GatewaySubscriptionId = subscription.Id;
organization.Status = OrganizationStatusType.Created;
await _scaleSeatsCommand.ScalePasswordManagerSeats(provider, organization.PlanType, await _providerBillingService.ScaleSeats(provider, organization.PlanType, -organization.Seats ?? 0);
-(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", return;
DaysUntilDue = 30 }
};
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( 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.Models.Business.Tokenables;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Services;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
@ -44,6 +46,7 @@ public class ProviderService : IProviderService
private readonly IFeatureService _featureService; private readonly IFeatureService _featureService;
private readonly IDataProtectorTokenFactory<ProviderDeleteTokenable> _providerDeleteTokenDataFactory; private readonly IDataProtectorTokenFactory<ProviderDeleteTokenable> _providerDeleteTokenDataFactory;
private readonly IApplicationCacheService _applicationCacheService; private readonly IApplicationCacheService _applicationCacheService;
private readonly IProviderBillingService _providerBillingService;
public ProviderService(IProviderRepository providerRepository, IProviderUserRepository providerUserRepository, public ProviderService(IProviderRepository providerRepository, IProviderUserRepository providerUserRepository,
IProviderOrganizationRepository providerOrganizationRepository, IUserRepository userRepository, IProviderOrganizationRepository providerOrganizationRepository, IUserRepository userRepository,
@ -52,7 +55,7 @@ public class ProviderService : IProviderService
IOrganizationRepository organizationRepository, GlobalSettings globalSettings, IOrganizationRepository organizationRepository, GlobalSettings globalSettings,
ICurrentContext currentContext, IStripeAdapter stripeAdapter, IFeatureService featureService, ICurrentContext currentContext, IStripeAdapter stripeAdapter, IFeatureService featureService,
IDataProtectorTokenFactory<ProviderDeleteTokenable> providerDeleteTokenDataFactory, IDataProtectorTokenFactory<ProviderDeleteTokenable> providerDeleteTokenDataFactory,
IApplicationCacheService applicationCacheService) IApplicationCacheService applicationCacheService, IProviderBillingService providerBillingService)
{ {
_providerRepository = providerRepository; _providerRepository = providerRepository;
_providerUserRepository = providerUserRepository; _providerUserRepository = providerUserRepository;
@ -70,9 +73,10 @@ public class ProviderService : IProviderService
_featureService = featureService; _featureService = featureService;
_providerDeleteTokenDataFactory = providerDeleteTokenDataFactory; _providerDeleteTokenDataFactory = providerDeleteTokenDataFactory;
_applicationCacheService = applicationCacheService; _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); var owner = await _userService.GetUserByIdAsync(ownerUserId);
if (owner == null) if (owner == null)
@ -97,8 +101,24 @@ public class ProviderService : IProviderService
throw new BadRequestException("Invalid owner."); throw new BadRequestException("Invalid owner.");
} }
if (!_featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling))
{
provider.Status = ProviderStatusType.Created; provider.Status = ProviderStatusType.Created;
await _providerRepository.UpsertAsync(provider); 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; providerUser.Key = key;
await _providerUserRepository.ReplaceAsync(providerUser); await _providerUserRepository.ReplaceAsync(providerUser);
@ -435,12 +455,16 @@ public class ProviderService : IProviderService
return; return;
} }
var subscriptionItem = await GetSubscriptionItemAsync(organization.GatewaySubscriptionId, GetStripeSeatPlanId(organization.PlanType)); if (!string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId))
{
var subscriptionItem = await GetSubscriptionItemAsync(organization.GatewaySubscriptionId,
GetStripeSeatPlanId(organization.PlanType));
var extractedPlanType = PlanTypeMappings(organization); var extractedPlanType = PlanTypeMappings(organization);
if (subscriptionItem != null) if (subscriptionItem != null)
{ {
await UpdateSubscriptionAsync(subscriptionItem, GetStripeSeatPlanId(extractedPlanType), organization); await UpdateSubscriptionAsync(subscriptionItem, GetStripeSeatPlanId(extractedPlanType), organization);
} }
}
await _organizationRepository.UpsertAsync(organization); await _organizationRepository.UpsertAsync(organization);
} }
@ -539,9 +563,9 @@ public class ProviderService : IProviderService
await _providerOrganizationRepository.CreateAsync(providerOrganization); await _providerOrganizationRepository.CreateAsync(providerOrganization);
await _eventService.LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_Created); 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 // 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 new CollectionAccessSelection
@ -554,17 +578,13 @@ public class ProviderService : IProviderService
] ]
: Array.Empty<CollectionAccessSelection>(); : Array.Empty<CollectionAccessSelection>();
await _organizationService.InviteUsersAsync(organization.Id, user.Id, await _organizationService.InviteUsersAsync(organization.Id, user.Id, systemUser: null,
new (OrganizationUserInvite, string)[] new (OrganizationUserInvite, string)[]
{ {
( (
new OrganizationUserInvite new OrganizationUserInvite
{ {
Emails = new[] { clientOwnerEmail }, 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, Type = OrganizationUserType.Owner,
Permissions = null, Permissions = null,
Collections = defaultOwnerAccess, 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" /> <ProjectReference Include="..\..\..\src\Core\Core.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<PackageReference Include="CsvHelper" Version="32.0.3" />
</ItemGroup>
</Project> </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: case not null when requirement == SecretOperations.Delete:
await CanDeleteSecretAsync(context, requirement, resource); await CanDeleteSecretAsync(context, requirement, resource);
break; break;
case not null when requirement == SecretOperations.ReadAccessPolicies:
await CanReadAccessPoliciesAsync(context, requirement, resource);
break;
default: default:
throw new ArgumentException("Unsupported operation requirement type provided.", nameof(requirement)); 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); 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 if (resource.Projects != null
&& resource.Projects.Any() && resource.Projects.Count != 0
&& !await _projectRepository.ProjectsAreInOrganization(resource.Projects.Select(p => p.Id).ToList(), && !await _projectRepository.ProjectsAreInOrganization(resource.Projects.Select(p => p.Id).ToList(),
resource.OrganizationId)) 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) 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; 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 && var accessToNew = newProject != null &&
(await _projectRepository.AccessToProjectAsync(newProject.Id, userId, accessClient)) (await _projectRepository.AccessToProjectAsync(newProject.Id, userId, accessClient))
.Write; .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.Entities;
using Bit.Core.SecretsManager.Models.Data.AccessPolicyUpdates;
using Bit.Core.SecretsManager.Repositories; using Bit.Core.SecretsManager.Repositories;
namespace Bit.Commercial.Core.SecretsManager.Commands.Secrets; namespace Bit.Commercial.Core.SecretsManager.Commands.Secrets;
@ -13,8 +15,8 @@ public class CreateSecretCommand : ICreateSecretCommand
_secretRepository = secretRepository; _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.Commands.Secrets.Interfaces;
using Bit.Core.SecretsManager.Entities; using Bit.Core.SecretsManager.Entities;
using Bit.Core.SecretsManager.Models.Data.AccessPolicyUpdates;
using Bit.Core.SecretsManager.Repositories; using Bit.Core.SecretsManager.Repositories;
namespace Bit.Commercial.Core.SecretsManager.Commands.Secrets; namespace Bit.Commercial.Core.SecretsManager.Commands.Secrets;
@ -14,21 +15,8 @@ public class UpdateSecretCommand : IUpdateSecretCommand
_secretRepository = secretRepository; _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); return await _secretRepository.UpdateAsync(secret, accessPolicyUpdates);
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;
} }
} }

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.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.SecretsManager.Queries.Projects.Interfaces; 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.AccessTokens;
using Bit.Commercial.Core.SecretsManager.Commands.Porting; using Bit.Commercial.Core.SecretsManager.Commands.Porting;
using Bit.Commercial.Core.SecretsManager.Commands.Projects; 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.Secrets;
using Bit.Commercial.Core.SecretsManager.Commands.ServiceAccounts; using Bit.Commercial.Core.SecretsManager.Commands.ServiceAccounts;
using Bit.Commercial.Core.SecretsManager.Commands.Trash; 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.AccessTokens.Interfaces;
using Bit.Core.SecretsManager.Commands.Porting.Interfaces; using Bit.Core.SecretsManager.Commands.Porting.Interfaces;
using Bit.Core.SecretsManager.Commands.Projects.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.Secrets.Interfaces;
using Bit.Core.SecretsManager.Commands.ServiceAccounts.Interfaces; using Bit.Core.SecretsManager.Commands.ServiceAccounts.Interfaces;
using Bit.Core.SecretsManager.Commands.Trash.Interfaces; using Bit.Core.SecretsManager.Commands.Trash.Interfaces;
@ -42,17 +44,21 @@ public static class SecretsManagerCollectionExtensions
services.AddScoped<IAuthorizationHandler, ServiceAccountPeopleAccessPoliciesAuthorizationHandler>(); services.AddScoped<IAuthorizationHandler, ServiceAccountPeopleAccessPoliciesAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, ServiceAccountGrantedPoliciesAuthorizationHandler>(); services.AddScoped<IAuthorizationHandler, ServiceAccountGrantedPoliciesAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, ProjectServiceAccountsAccessPoliciesAuthorizationHandler>(); services.AddScoped<IAuthorizationHandler, ProjectServiceAccountsAccessPoliciesAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, SecretAccessPoliciesUpdatesAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, BulkSecretAuthorizationHandler>();
services.AddScoped<IAccessClientQuery, AccessClientQuery>(); services.AddScoped<IAccessClientQuery, AccessClientQuery>();
services.AddScoped<IMaxProjectsQuery, MaxProjectsQuery>(); services.AddScoped<IMaxProjectsQuery, MaxProjectsQuery>();
services.AddScoped<ISameOrganizationQuery, SameOrganizationQuery>(); services.AddScoped<ISameOrganizationQuery, SameOrganizationQuery>();
services.AddScoped<IServiceAccountSecretsDetailsQuery, ServiceAccountSecretsDetailsQuery>(); services.AddScoped<IServiceAccountSecretsDetailsQuery, ServiceAccountSecretsDetailsQuery>();
services.AddScoped<IServiceAccountGrantedPolicyUpdatesQuery, ServiceAccountGrantedPolicyUpdatesQuery>(); services.AddScoped<IServiceAccountGrantedPolicyUpdatesQuery, ServiceAccountGrantedPolicyUpdatesQuery>();
services.AddScoped<ISecretAccessPoliciesUpdatesQuery, SecretAccessPoliciesUpdatesQuery>();
services.AddScoped<ISecretsSyncQuery, SecretsSyncQuery>(); services.AddScoped<ISecretsSyncQuery, SecretsSyncQuery>();
services.AddScoped<IProjectServiceAccountsAccessPoliciesUpdatesQuery, ProjectServiceAccountsAccessPoliciesUpdatesQuery>(); services.AddScoped<IProjectServiceAccountsAccessPoliciesUpdatesQuery, ProjectServiceAccountsAccessPoliciesUpdatesQuery>();
services.AddScoped<ICreateSecretCommand, CreateSecretCommand>(); services.AddScoped<ICreateSecretCommand, CreateSecretCommand>();
services.AddScoped<IUpdateSecretCommand, UpdateSecretCommand>(); services.AddScoped<IUpdateSecretCommand, UpdateSecretCommand>();
services.AddScoped<IDeleteSecretCommand, DeleteSecretCommand>(); services.AddScoped<IDeleteSecretCommand, DeleteSecretCommand>();
services.AddScoped<ICreateProjectCommand, CreateProjectCommand>(); services.AddScoped<ICreateProjectCommand, CreateProjectCommand>();
services.AddScoped<IRequestSMAccessCommand, RequestSMAccessCommand>();
services.AddScoped<IUpdateProjectCommand, UpdateProjectCommand>(); services.AddScoped<IUpdateProjectCommand, UpdateProjectCommand>();
services.AddScoped<IDeleteProjectCommand, DeleteProjectCommand>(); services.AddScoped<IDeleteProjectCommand, DeleteProjectCommand>();
services.AddScoped<ICreateServiceAccountCommand, CreateServiceAccountCommand>(); services.AddScoped<ICreateServiceAccountCommand, CreateServiceAccountCommand>();

View File

@ -1,7 +1,9 @@
using Bit.Commercial.Core.AdminConsole.Providers; using Bit.Commercial.Core.AdminConsole.Providers;
using Bit.Commercial.Core.AdminConsole.Services; using Bit.Commercial.Core.AdminConsole.Services;
using Bit.Commercial.Core.Billing;
using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Providers.Interfaces;
using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Services;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
namespace Bit.Commercial.Core.Utilities; namespace Bit.Commercial.Core.Utilities;
@ -13,5 +15,6 @@ public static class ServiceCollectionExtensions
services.AddScoped<IProviderService, ProviderService>(); services.AddScoped<IProviderService, ProviderService>();
services.AddScoped<ICreateProviderCommand, CreateProviderCommand>(); services.AddScoped<ICreateProviderCommand, CreateProviderCommand>();
services.AddScoped<IRemoveOrganizationFromProviderCommand, RemoveOrganizationFromProviderCommand>(); 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(); await using var scope = ServiceScopeFactory.CreateAsyncScope();
var dbContext = GetDatabaseContext(scope); var dbContext = GetDatabaseContext(scope);
@ -39,6 +40,13 @@ public class AccessPolicyRepository : BaseEntityFrameworkRepository, IAccessPoli
await dbContext.AddAsync(entity); await dbContext.AddAsync(entity);
break; break;
} }
case Core.SecretsManager.Entities.UserSecretAccessPolicy accessPolicy:
{
var entity =
Mapper.Map<UserSecretAccessPolicy>(accessPolicy);
await dbContext.AddAsync(entity);
break;
}
case Core.SecretsManager.Entities.UserServiceAccountAccessPolicy accessPolicy: case Core.SecretsManager.Entities.UserServiceAccountAccessPolicy accessPolicy:
{ {
var entity = var entity =
@ -52,6 +60,12 @@ public class AccessPolicyRepository : BaseEntityFrameworkRepository, IAccessPoli
await dbContext.AddAsync(entity); await dbContext.AddAsync(entity);
break; break;
} }
case Core.SecretsManager.Entities.GroupSecretAccessPolicy accessPolicy:
{
var entity = Mapper.Map<GroupSecretAccessPolicy>(accessPolicy);
await dbContext.AddAsync(entity);
break;
}
case Core.SecretsManager.Entities.GroupServiceAccountAccessPolicy accessPolicy: case Core.SecretsManager.Entities.GroupServiceAccountAccessPolicy accessPolicy:
{ {
var entity = Mapper.Map<GroupServiceAccountAccessPolicy>(accessPolicy); var entity = Mapper.Map<GroupServiceAccountAccessPolicy>(accessPolicy);
@ -65,6 +79,13 @@ public class AccessPolicyRepository : BaseEntityFrameworkRepository, IAccessPoli
serviceAccountIds.Add(entity.ServiceAccountId!.Value); serviceAccountIds.Add(entity.ServiceAccountId!.Value);
break; 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(); 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, private static async Task UpsertPeoplePoliciesAsync(DatabaseContext dbContext,
List<BaseAccessPolicy> policies, IReadOnlyCollection<AccessPolicy> userPolicyEntities, List<BaseAccessPolicy> policies, IReadOnlyCollection<AccessPolicy> userPolicyEntities,
IReadOnlyCollection<AccessPolicy> groupPolicyEntities) IReadOnlyCollection<AccessPolicy> groupPolicyEntities)
@ -466,13 +523,17 @@ public class AccessPolicyRepository : BaseEntityFrameworkRepository, IAccessPoli
baseAccessPolicyEntity switch baseAccessPolicyEntity switch
{ {
UserProjectAccessPolicy ap => Mapper.Map<Core.SecretsManager.Entities.UserProjectAccessPolicy>(ap), UserProjectAccessPolicy ap => Mapper.Map<Core.SecretsManager.Entities.UserProjectAccessPolicy>(ap),
GroupProjectAccessPolicy ap => Mapper.Map<Core.SecretsManager.Entities.GroupProjectAccessPolicy>(ap), UserSecretAccessPolicy ap => Mapper.Map<Core.SecretsManager.Entities.UserSecretAccessPolicy>(ap),
ServiceAccountProjectAccessPolicy ap => Mapper
.Map<Core.SecretsManager.Entities.ServiceAccountProjectAccessPolicy>(ap),
UserServiceAccountAccessPolicy ap => UserServiceAccountAccessPolicy ap =>
Mapper.Map<Core.SecretsManager.Entities.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 GroupServiceAccountAccessPolicy ap => Mapper
.Map<Core.SecretsManager.Entities.GroupServiceAccountAccessPolicy>(ap), .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") _ => throw new ArgumentException("Unsupported access policy type")
}; };
@ -482,14 +543,20 @@ public class AccessPolicyRepository : BaseEntityFrameworkRepository, IAccessPoli
{ {
Core.SecretsManager.Entities.UserProjectAccessPolicy accessPolicy => Mapper.Map<UserProjectAccessPolicy>( Core.SecretsManager.Entities.UserProjectAccessPolicy accessPolicy => Mapper.Map<UserProjectAccessPolicy>(
accessPolicy), accessPolicy),
Core.SecretsManager.Entities.UserSecretAccessPolicy accessPolicy => Mapper.Map<UserSecretAccessPolicy>(
accessPolicy),
Core.SecretsManager.Entities.UserServiceAccountAccessPolicy accessPolicy => Mapper Core.SecretsManager.Entities.UserServiceAccountAccessPolicy accessPolicy => Mapper
.Map<UserServiceAccountAccessPolicy>(accessPolicy), .Map<UserServiceAccountAccessPolicy>(accessPolicy),
Core.SecretsManager.Entities.GroupProjectAccessPolicy accessPolicy => Mapper.Map<GroupProjectAccessPolicy>( Core.SecretsManager.Entities.GroupProjectAccessPolicy accessPolicy => Mapper.Map<GroupProjectAccessPolicy>(
accessPolicy), accessPolicy),
Core.SecretsManager.Entities.GroupSecretAccessPolicy accessPolicy => Mapper.Map<GroupSecretAccessPolicy>(
accessPolicy),
Core.SecretsManager.Entities.GroupServiceAccountAccessPolicy accessPolicy => Mapper Core.SecretsManager.Entities.GroupServiceAccountAccessPolicy accessPolicy => Mapper
.Map<GroupServiceAccountAccessPolicy>(accessPolicy), .Map<GroupServiceAccountAccessPolicy>(accessPolicy),
Core.SecretsManager.Entities.ServiceAccountProjectAccessPolicy accessPolicy => Mapper Core.SecretsManager.Entities.ServiceAccountProjectAccessPolicy accessPolicy => Mapper
.Map<ServiceAccountProjectAccessPolicy>(accessPolicy), .Map<ServiceAccountProjectAccessPolicy>(accessPolicy),
Core.SecretsManager.Entities.ServiceAccountSecretAccessPolicy accessPolicy => Mapper
.Map<ServiceAccountSecretAccessPolicy>(accessPolicy),
_ => throw new ArgumentException("Unsupported access policy type") _ => throw new ArgumentException("Unsupported access policy type")
}; };
} }
@ -505,6 +572,12 @@ public class AccessPolicyRepository : BaseEntityFrameworkRepository, IAccessPoli
mapped.CurrentUserInGroup = currentUserInGroup; mapped.CurrentUserInGroup = currentUserInGroup;
return mapped; return mapped;
} }
case GroupSecretAccessPolicy ap:
{
var mapped = Mapper.Map<Core.SecretsManager.Entities.GroupSecretAccessPolicy>(ap);
mapped.CurrentUserInGroup = currentUserInGroup;
return mapped;
}
case GroupServiceAccountAccessPolicy ap: case GroupServiceAccountAccessPolicy ap:
{ {
var mapped = Mapper.Map<Core.SecretsManager.Entities.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) : 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()) 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)); 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 record ProjectAccess(Guid Id, bool Read, bool Write);
private static IQueryable<ProjectAccess> BuildProjectAccessQuery(IQueryable<Project> projectQuery, Guid userId, private static IQueryable<ProjectAccess> BuildProjectAccessQuery(IQueryable<Project> projectQuery, Guid userId,

View File

@ -1,7 +1,9 @@
using System.Linq.Expressions; using System.Linq.Expressions;
using AutoMapper; using AutoMapper;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.SecretsManager.Enums.AccessPolicies;
using Bit.Core.SecretsManager.Models.Data; using Bit.Core.SecretsManager.Models.Data;
using Bit.Core.SecretsManager.Models.Data.AccessPolicyUpdates;
using Bit.Core.SecretsManager.Repositories; using Bit.Core.SecretsManager.Repositories;
using Bit.Infrastructure.EntityFramework; using Bit.Infrastructure.EntityFramework;
using Bit.Infrastructure.EntityFramework.Repositories; using Bit.Infrastructure.EntityFramework.Repositories;
@ -17,7 +19,7 @@ public class SecretRepository : Repository<Core.SecretsManager.Entities.Secret,
: base(serviceScopeFactory, mapper, db => db.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()) using (var scope = ServiceScopeFactory.CreateScope())
{ {
@ -136,8 +138,8 @@ public class SecretRepository : Repository<Core.SecretsManager.Entities.Secret,
return await secrets.ToListAsync(); return await secrets.ToListAsync();
} }
public override async Task<Core.SecretsManager.Entities.Secret> CreateAsync( public async Task<Core.SecretsManager.Entities.Secret> CreateAsync(
Core.SecretsManager.Entities.Secret secret) Core.SecretsManager.Entities.Secret secret, SecretAccessPoliciesUpdates? accessPoliciesUpdates = null)
{ {
await using var scope = ServiceScopeFactory.CreateAsyncScope(); await using var scope = ServiceScopeFactory.CreateAsyncScope();
var dbContext = GetDatabaseContext(scope); var dbContext = GetDatabaseContext(scope);
@ -158,13 +160,14 @@ public class SecretRepository : Repository<Core.SecretsManager.Entities.Secret,
} }
await dbContext.AddAsync(entity); await dbContext.AddAsync(entity);
await UpdateSecretAccessPoliciesAsync(dbContext, entity, accessPoliciesUpdates);
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
await transaction.CommitAsync(); await transaction.CommitAsync();
secret.Id = entity.Id;
return secret; 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(); await using var scope = ServiceScopeFactory.CreateAsyncScope();
var dbContext = GetDatabaseContext(scope); var dbContext = GetDatabaseContext(scope);
@ -173,36 +176,30 @@ public class SecretRepository : Repository<Core.SecretsManager.Entities.Secret,
var entity = await dbContext.Secret var entity = await dbContext.Secret
.Include(s => s.Projects) .Include(s => s.Projects)
.Include(s => s.UserAccessPolicies)
.Include(s => s.GroupAccessPolicies)
.Include(s => s.ServiceAccountAccessPolicies)
.FirstAsync(s => s.Id == secret.Id); .FirstAsync(s => s.Id == secret.Id);
var projectsToRemove = entity.Projects.Where(p => mappedEntity.Projects.All(mp => mp.Id != p.Id)).ToList(); dbContext.Entry(entity).CurrentValues.SetValues(mappedEntity);
var projectsToAdd = mappedEntity.Projects.Where(p => entity.Projects.All(ep => ep.Id != p.Id)).ToList();
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); await UpdateSecretAccessPoliciesAsync(dbContext, entity, accessPoliciesUpdates);
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 UpdateServiceAccountRevisionsBySecretIdsAsync(dbContext, [entity.Id]); await UpdateServiceAccountRevisionsBySecretIdsAsync(dbContext, [entity.Id]);
dbContext.Entry(entity).CurrentValues.SetValues(mappedEntity);
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
await transaction.CommitAsync(); await transaction.CommitAsync();
return Mapper.Map<Core.SecretsManager.Entities.Secret>(entity);
return secret;
} }
public async Task SoftDeleteManyByIdAsync(IEnumerable<Guid> ids) public async Task SoftDeleteManyByIdAsync(IEnumerable<Guid> ids)
{ {
await using var scope = ServiceScopeFactory.CreateAsyncScope(); 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) 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 dbContext = GetDatabaseContext(scope);
var secret = dbContext.Secret var secret = dbContext.Secret
.Where(s => s.Id == id); .Where(s => s.Id == id);
var query = accessType switch var query = BuildSecretAccessQuery(secret, userId, accessType);
{
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 policy = await query.FirstOrDefaultAsync(); var policy = await query.FirstOrDefaultAsync();
return policy == null ? (false, false) : (policy.Read, policy.Write); 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) public async Task EmptyTrash(DateTime currentDate, uint deleteAfterThisNumberOfDays)
{ {
using var scope = ServiceScopeFactory.CreateScope(); using var scope = ServiceScopeFactory.CreateScope();
@ -334,6 +325,23 @@ public class SecretRepository : Repository<Core.SecretsManager.Entities.Secret,
await dbContext.SaveChangesAsync(); 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) private IQueryable<SecretPermissionDetails> SecretToPermissionDetails(IQueryable<Secret> query, Guid userId, AccessClientType accessType)
{ {
var secrets = accessType switch 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) => private Expression<Func<Secret, SecretPermissionDetails>> SecretToPermissionsUser(Guid userId, bool read) =>
s => new SecretPermissionDetails s => new SecretPermissionDetails
{ {
Secret = Mapper.Map<Bit.Core.SecretsManager.Entities.Secret>(s), Secret = Mapper.Map<Core.SecretsManager.Entities.Secret>(s),
Read = read, Read = read,
Write = s.Projects.Any(p => 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.UserAccessPolicies.Any(ap => ap.OrganizationUser.User.Id == userId && ap.Write) ||
p.GroupAccessPolicies.Any(ap => p.GroupAccessPolicies.Any(ap =>
ap.Group.GroupUsers.Any(gu => gu.OrganizationUser.User.Id == userId && ap.Write))), ap.Group.GroupUsers.Any(gu => gu.OrganizationUser.User.Id == userId && ap.Write)))
}; };
private static Expression<Func<Secret, bool>> ServiceAccountHasReadAccessToSecret(Guid serviceAccountId) => s => private static Expression<Func<Secret, bool>> ServiceAccountHasReadAccessToSecret(Guid serviceAccountId) => s =>
s.ServiceAccountAccessPolicies.Any(ap => ap.ServiceAccountId == serviceAccountId && ap.Read) ||
s.Projects.Any(p => s.Projects.Any(p =>
p.ServiceAccountAccessPolicies.Any(ap => ap.ServiceAccount.Id == serviceAccountId && ap.Read)); p.ServiceAccountAccessPolicies.Any(ap => ap.ServiceAccount.Id == serviceAccountId && ap.Read));
private static Expression<Func<Secret, bool>> UserHasReadAccessToSecret(Guid userId) => s => 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 => s.Projects.Any(p =>
p.UserAccessPolicies.Any(ap => ap.OrganizationUser.UserId == userId && ap.Read) || p.UserAccessPolicies.Any(ap => ap.OrganizationUser.UserId == userId && ap.Read) ||
p.GroupAccessPolicies.Any(ap => p.GroupAccessPolicies.Any(ap =>
@ -434,4 +450,197 @@ public class SecretRepository : Repository<Core.SecretsManager.Entities.Secret,
.ExecuteUpdateAsync(setters => setters.SetProperty(b => b.RevisionDate, utcNow)); .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) public async Task<bool> ServiceAccountsAreInOrganizationAsync(List<Guid> serviceAccountIds, Guid organizationId)
{ {
await using var scope = ServiceScopeFactory.CreateAsyncScope(); await using var scope = ServiceScopeFactory.CreateAsyncScope();
@ -137,36 +179,35 @@ public class ServiceAccountRepository : Repository<Core.SecretsManager.Entities.
public async Task<IEnumerable<ServiceAccountSecretsDetails>> GetManyByOrganizationIdWithSecretsDetailsAsync( 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 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.NoAccessCheck => serviceAccountQuery,
AccessClientType.User => query.Where(c => AccessClientType.User => serviceAccountQuery.Where(c =>
c.ServiceAccount.UserAccessPolicies.Any(ap => ap.OrganizationUser.User.Id == userId && ap.Read) || c.UserAccessPolicies.Any(ap => ap.OrganizationUser.User.Id == userId && ap.Read) ||
c.ServiceAccount.GroupAccessPolicies.Any(ap => c.GroupAccessPolicies.Any(ap =>
ap.Group.GroupUsers.Any(gu => gu.OrganizationUser.User.Id == userId && ap.Read))), ap.Group.GroupUsers.Any(gu => gu.OrganizationUser.User.Id == userId && ap.Read))),
_ => throw new ArgumentOutOfRangeException(nameof(accessType), accessType, null), _ => 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) .GroupBy(g => g.ServiceAccount)
.Select(g => .Select(g =>
new ServiceAccountSecretsDetails new ServiceAccountSecretsDetails
{ {
ServiceAccount = Mapper.Map<Core.SecretsManager.Entities.ServiceAccount>(g.Key), 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(); }).OrderBy(c => c.ServiceAccount.RevisionDate).ToList();
return results; return results;
@ -200,4 +241,46 @@ public class ServiceAccountRepository : Repository<Core.SecretsManager.Entities.
private static Expression<Func<ServiceAccount, bool>> UserHasWriteAccessToServiceAccount(Guid userId) => sa => private static Expression<Func<ServiceAccount, bool>> UserHasWriteAccessToServiceAccount(Guid userId) => sa =>
sa.UserAccessPolicies.Any(ap => ap.OrganizationUser.User.Id == userId && ap.Write) || 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)); 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 class ScimUserRequestModel : BaseScimUserModel
{ {
public ScimUserRequestModel() public ScimUserRequestModel()
: base(false) : 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.Exceptions;
using Bit.Core.Models.Data;
using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Utilities;
using Bit.Scim.Context; using Bit.Scim.Context;
using Bit.Scim.Models; using Bit.Scim.Models;
using Bit.Scim.Users.Interfaces; using Bit.Scim.Users.Interfaces;
@ -14,39 +11,33 @@ namespace Bit.Scim.Users;
public class PostUserCommand : IPostUserCommand public class PostUserCommand : IPostUserCommand
{ {
private readonly IOrganizationRepository _organizationRepository;
private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IOrganizationService _organizationService; private readonly IOrganizationService _organizationService;
private readonly IPaymentService _paymentService;
private readonly IScimContext _scimContext; private readonly IScimContext _scimContext;
public PostUserCommand( public PostUserCommand(
IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository, IOrganizationUserRepository organizationUserRepository,
IOrganizationService organizationService, IOrganizationService organizationService,
IPaymentService paymentService,
IScimContext scimContext) IScimContext scimContext)
{ {
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository; _organizationUserRepository = organizationUserRepository;
_organizationService = organizationService; _organizationService = organizationService;
_paymentService = paymentService;
_scimContext = scimContext; _scimContext = scimContext;
} }
public async Task<OrganizationUserUserDetails> PostUserAsync(Guid organizationId, ScimUserRequestModel model) public async Task<OrganizationUserUserDetails> PostUserAsync(Guid organizationId, ScimUserRequestModel model)
{ {
var email = model.PrimaryEmail?.ToLowerInvariant(); var scimProvider = _scimContext.RequestScimProvider;
if (string.IsNullOrWhiteSpace(email)) var invite = model.ToOrganizationUserInvite(scimProvider);
{
switch (_scimContext.RequestScimProvider) var email = invite.Emails.Single();
{ var externalId = model.ExternalIdForInvite();
case ScimProviderType.AzureAd:
email = model.UserName?.ToLowerInvariant();
break;
default:
email = model.WorkEmail?.ToLowerInvariant();
if (string.IsNullOrWhiteSpace(email))
{
email = model.Emails?.FirstOrDefault()?.Value?.ToLowerInvariant();
}
break;
}
}
if (string.IsNullOrWhiteSpace(email) || !model.Active) if (string.IsNullOrWhiteSpace(email) || !model.Active)
{ {
@ -60,28 +51,18 @@ public class PostUserCommand : IPostUserCommand
throw new ConflictException(); 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); var orgUserByExternalId = orgUsers.FirstOrDefault(ou => ou.ExternalId == externalId);
if (orgUserByExternalId != null) if (orgUserByExternalId != null)
{ {
throw new ConflictException(); throw new ConflictException();
} }
var invitedOrgUser = await _organizationService.InviteUserAsync(organizationId, EventSystemUser.SCIM, email, var organization = await _organizationRepository.GetByIdAsync(organizationId);
OrganizationUserType.User, false, externalId, new List<CollectionAccessSelection>(), new List<Guid>()); 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); var orgUser = await _organizationUserRepository.GetDetailsByIdAsync(invitedOrgUser.Id);
return orgUser; 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.Business.Tokenables;
using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Models.Data;
using Bit.Core.Auth.Repositories; using Bit.Core.Auth.Repositories;
using Bit.Core.Auth.UserFeatures.Registration;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Models.Api; using Bit.Core.Models.Api;
@ -51,6 +52,7 @@ public class AccountController : Controller
private readonly Core.Services.IEventService _eventService; private readonly Core.Services.IEventService _eventService;
private readonly IDataProtectorTokenFactory<SsoTokenable> _dataProtector; private readonly IDataProtectorTokenFactory<SsoTokenable> _dataProtector;
private readonly IOrganizationDomainRepository _organizationDomainRepository; private readonly IOrganizationDomainRepository _organizationDomainRepository;
private readonly IRegisterUserCommand _registerUserCommand;
public AccountController( public AccountController(
IAuthenticationSchemeProvider schemeProvider, IAuthenticationSchemeProvider schemeProvider,
@ -70,7 +72,8 @@ public class AccountController : Controller
IGlobalSettings globalSettings, IGlobalSettings globalSettings,
Core.Services.IEventService eventService, Core.Services.IEventService eventService,
IDataProtectorTokenFactory<SsoTokenable> dataProtector, IDataProtectorTokenFactory<SsoTokenable> dataProtector,
IOrganizationDomainRepository organizationDomainRepository) IOrganizationDomainRepository organizationDomainRepository,
IRegisterUserCommand registerUserCommand)
{ {
_schemeProvider = schemeProvider; _schemeProvider = schemeProvider;
_clientStore = clientStore; _clientStore = clientStore;
@ -90,6 +93,7 @@ public class AccountController : Controller
_globalSettings = globalSettings; _globalSettings = globalSettings;
_dataProtector = dataProtector; _dataProtector = dataProtector;
_organizationDomainRepository = organizationDomainRepository; _organizationDomainRepository = organizationDomainRepository;
_registerUserCommand = registerUserCommand;
} }
[HttpGet] [HttpGet]
@ -483,7 +487,8 @@ public class AccountController : Controller
if (orgUser.Status == OrganizationUserStatusType.Invited) if (orgUser.Status == OrganizationUserStatusType.Invited)
{ {
// Org User is invited - they must manually accept the invite via email and authenticate with MP // 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; // Accepted or Confirmed - create SSO link and return;
@ -497,7 +502,6 @@ public class AccountController : Controller
var occupiedSeats = await _organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); var occupiedSeats = await _organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
var initialSeatCount = organization.Seats.Value; var initialSeatCount = organization.Seats.Value;
var availableSeats = initialSeatCount - occupiedSeats; var availableSeats = initialSeatCount - occupiedSeats;
var prorationDate = DateTime.UtcNow;
if (availableSeats < 1) if (availableSeats < 1)
{ {
try try
@ -507,13 +511,13 @@ public class AccountController : Controller
throw new Exception("Cannot autoscale on self-hosted instance."); throw new Exception("Cannot autoscale on self-hosted instance.");
} }
await _organizationService.AutoAddSeatsAsync(organization, 1, prorationDate); await _organizationService.AutoAddSeatsAsync(organization, 1);
} }
catch (Exception e) catch (Exception e)
{ {
if (organization.Seats.Value != initialSeatCount) 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"); _logger.LogInformation(e, "SSO auto provisioning failed");
throw new Exception(_i18nService.T("NoSeatsAvailable", organization.DisplayName())); throw new Exception(_i18nService.T("NoSeatsAvailable", organization.DisplayName()));
@ -538,7 +542,7 @@ public class AccountController : Controller
EmailVerified = emailVerified, EmailVerified = emailVerified,
ApiKey = CoreHelpers.SecureRandomString(30) 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 // If the organization has 2fa policy enabled, make sure to default jit user 2fa to email
var twoFactorPolicy = var twoFactorPolicy =

View File

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

View File

@ -7,15 +7,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@ViewData["Title"] - SSO</title> <title>@ViewData["Title"] - SSO</title>
<link rel="stylesheet" href="~/css/webfonts.css" /> <link rel="stylesheet" href="~/assets/site.css" asp-append-version="true" />
<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>
@RenderSection("Head", required: false) @RenderSection("Head", required: false)
</head> </head>
<body> <body>
@ -43,18 +35,7 @@
</div> </div>
</div> </div>
<environment include="Development"> <script src="~/assets/site.js" asp-append-version="true"></script>
<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>
@RenderSection("Scripts", required: false) @RenderSection("Scripts", required: false)
</body> </body>
</html> </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", "repository": "https://github.com/bitwarden/enterprise",
"license": "-", "license": "-",
"scripts": { "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": { "devDependencies": {
"bootstrap": "4.6.2", "css-loader": "7.1.2",
"del": "6.1.1", "expose-loader": "5.0.0",
"font-awesome": "4.7.0", "mini-css-extract-plugin": "2.9.0",
"gulp": "4.0.2", "sass": "1.77.8",
"gulp-sass": "5.1.0", "sass-loader": "16.0.0",
"jquery": "3.7.1", "webpack": "5.94.0",
"merge-stream": "2.0.0", "webpack-cli": "5.1.4"
"popper.js": "1.16.1",
"sass": "1.75.0"
} }
} }

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.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Commands; using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Services;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Utilities;
using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes; using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute; using NSubstitute;
using Stripe; using Stripe;
using Xunit; using Xunit;
using IMailService = Bit.Core.Services.IMailService;
namespace Bit.Commercial.Core.Test.AdminConsole.ProviderFeatures; namespace Bit.Commercial.Core.Test.AdminConsole.ProviderFeatures;
@ -75,7 +77,7 @@ public class RemoveOrganizationFromProviderCommandTests
sutProvider.GetDependency<IOrganizationService>().HasConfirmedOwnersExceptAsync( sutProvider.GetDependency<IOrganizationService>().HasConfirmedOwnersExceptAsync(
providerOrganization.OrganizationId, providerOrganization.OrganizationId,
Array.Empty<Guid>(), [],
includeProvider: false) includeProvider: false)
.Returns(false); .Returns(false);
@ -85,56 +87,53 @@ public class RemoveOrganizationFromProviderCommandTests
} }
[Theory, BitAutoData] [Theory, BitAutoData]
public async Task RemoveOrganizationFromProvider_NoStripeObjects_MakesCorrectInvocations( public async Task RemoveOrganizationFromProvider_OrganizationNotStripeEnabled_MakesCorrectInvocations(
Provider provider, Provider provider,
ProviderOrganization providerOrganization, ProviderOrganization providerOrganization,
Organization organization, Organization organization,
SutProvider<RemoveOrganizationFromProviderCommand> sutProvider) SutProvider<RemoveOrganizationFromProviderCommand> sutProvider)
{ {
providerOrganization.ProviderId = provider.Id;
organization.GatewayCustomerId = null; organization.GatewayCustomerId = null;
organization.GatewaySubscriptionId = null; organization.GatewaySubscriptionId = null;
providerOrganization.ProviderId = provider.Id;
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
sutProvider.GetDependency<IOrganizationService>().HasConfirmedOwnersExceptAsync( sutProvider.GetDependency<IOrganizationService>().HasConfirmedOwnersExceptAsync(
providerOrganization.OrganizationId, providerOrganization.OrganizationId,
Array.Empty<Guid>(), [],
includeProvider: false) includeProvider: false)
.Returns(true); .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 sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization);
await organizationRepository.Received(1).ReplaceAsync(Arg.Is<Organization>( await organizationRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(org => org.BillingEmail == "a@example.com"));
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 sutProvider.GetDependency<IProviderOrganizationRepository>().Received(1) await sutProvider.GetDependency<IProviderOrganizationRepository>().Received(1)
.DeleteAsync(providerOrganization); .DeleteAsync(providerOrganization);
await sutProvider.GetDependency<IEventService>().Received(1).LogProviderOrganizationEventAsync( await sutProvider.GetDependency<IEventService>().Received(1)
providerOrganization, .LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_Removed);
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] [Theory, BitAutoData]
public async Task RemoveOrganizationFromProvider_MakesCorrectInvocations__FeatureFlagOff( public async Task RemoveOrganizationFromProvider_OrganizationStripeEnabled_NonConsolidatedBilling_MakesCorrectInvocations(
Provider provider, Provider provider,
ProviderOrganization providerOrganization, ProviderOrganization providerOrganization,
Organization organization, Organization organization,
@ -142,104 +141,150 @@ public class RemoveOrganizationFromProviderCommandTests
{ {
providerOrganization.ProviderId = provider.Id; providerOrganization.ProviderId = provider.Id;
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
sutProvider.GetDependency<IOrganizationService>().HasConfirmedOwnersExceptAsync( sutProvider.GetDependency<IOrganizationService>().HasConfirmedOwnersExceptAsync(
providerOrganization.OrganizationId, providerOrganization.OrganizationId,
Array.Empty<Guid>(), [],
includeProvider: false) includeProvider: false)
.Returns(true); .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([
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>(); "a@example.com",
stripeAdapter.SubscriptionCreateAsync(default).ReturnsForAnyArgs(new Stripe.Subscription "b@example.com"
{ ]);
Id = "S-1",
CurrentPeriodEnd = DateTime.Today.AddDays(10), 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 sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization);
await organizationRepository.Received(1).ReplaceAsync(Arg.Is<Organization>( var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
org => org.Id == organization.Id && org.BillingEmail == "a@example.com"));
await stripeAdapter.Received(1).CustomerUpdateAsync( await stripeAdapter.Received(1).CustomerUpdateAsync(organization.GatewayCustomerId,
organization.GatewayCustomerId, Arg.Is<CustomerUpdateOptions>( Arg.Is<CustomerUpdateOptions>(options =>
options => options.Coupon == string.Empty && options.Email == "a@example.com")); options.Coupon == string.Empty && options.Email == "a@example.com"));
await sutProvider.GetDependency<IMailService>().Received(1).SendProviderUpdatePaymentMethod( await stripeAdapter.Received(1).SubscriptionUpdateAsync(organization.GatewaySubscriptionId,
organization.Id, Arg.Is<SubscriptionUpdateOptions>(options =>
organization.Name, options.CollectionMethod == StripeConstants.CollectionMethod.SendInvoice &&
provider.Name, options.DaysUntilDue == 30));
Arg.Is<IEnumerable<string>>(emails => emails.Contains("a@example.com") && emails.Contains("b@example.com")));
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) await sutProvider.GetDependency<IProviderOrganizationRepository>().Received(1)
.DeleteAsync(providerOrganization); .DeleteAsync(providerOrganization);
await sutProvider.GetDependency<IEventService>().Received(1).LogProviderOrganizationEventAsync( await sutProvider.GetDependency<IEventService>().Received(1)
providerOrganization, .LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_Removed);
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] [Theory, BitAutoData]
public async Task RemoveOrganizationFromProvider_CreatesSubscriptionAndScalesSeats_FeatureFlagON(Provider provider, public async Task RemoveOrganizationFromProvider_OrganizationStripeEnabled_ConsolidatedBilling_MakesCorrectInvocations(
Provider provider,
ProviderOrganization providerOrganization, ProviderOrganization providerOrganization,
Organization organization, Organization organization,
SutProvider<RemoveOrganizationFromProviderCommand> sutProvider) SutProvider<RemoveOrganizationFromProviderCommand> sutProvider)
{ {
providerOrganization.ProviderId = provider.Id;
provider.Status = ProviderStatusType.Billable; 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( sutProvider.GetDependency<IOrganizationService>().HasConfirmedOwnersExceptAsync(
providerOrganization.OrganizationId, providerOrganization.OrganizationId,
Array.Empty<Guid>(), [],
includeProvider: false) includeProvider: false)
.Returns(true); .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>(); var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
stripeAdapter.SubscriptionCreateAsync(default).ReturnsForAnyArgs(new Stripe.Subscription
stripeAdapter.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(new Subscription
{ {
Id = "S-1", Id = "subscription_id"
CurrentPeriodEnd = DateTime.Today.AddDays(10),
}); });
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling).Returns(true);
await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization); 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 => await stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(options =>
c.Customer == organization.GatewayCustomerId && options.Customer == organization.GatewayCustomerId &&
c.CollectionMethod == "send_invoice" && options.CollectionMethod == StripeConstants.CollectionMethod.SendInvoice &&
c.DaysUntilDue == 30 && options.DaysUntilDue == 30 &&
c.Items.Count == 1 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) await sutProvider.GetDependency<IProviderBillingService>().Received(1)
.ScalePasswordManagerSeats(provider, organization.PlanType, -(int)organization.Seats); .ScaleSeats(provider, organization.PlanType, -organization.Seats ?? 0);
await organizationRepository.Received(1).ReplaceAsync(Arg.Is<Organization>( await organizationRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(
org => org.Id == organization.Id && org.BillingEmail == "a@example.com" && org =>
org.GatewaySubscriptionId == "S-1")); org.BillingEmail == "a@example.com" &&
org.GatewaySubscriptionId == "subscription_id" &&
await sutProvider.GetDependency<IMailService>().Received(1).SendProviderUpdatePaymentMethod( org.Status == OrganizationStatusType.Created));
organization.Id,
organization.Name,
provider.Name,
Arg.Is<IEnumerable<string>>(emails =>
emails.Contains("a@example.com") && emails.Contains("b@example.com")));
await sutProvider.GetDependency<IProviderOrganizationRepository>().Received(1) await sutProvider.GetDependency<IProviderOrganizationRepository>().Received(1)
.DeleteAsync(providerOrganization); .DeleteAsync(providerOrganization);
await sutProvider.GetDependency<IEventService>().Received(1).LogProviderOrganizationEventAsync( await sutProvider.GetDependency<IEventService>().Received(1)
providerOrganization, .LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_Removed);
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.Provider;
using Bit.Core.AdminConsole.Models.Business.Tokenables; using Bit.Core.AdminConsole.Models.Business.Tokenables;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Services;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; 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)); .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] [Theory, BitAutoData]
public async Task UpdateAsync_ProviderIdIsInvalid_Throws(Provider provider, SutProvider<ProviderService> sutProvider) 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); await sutProvider.GetDependency<IEventService>().DidNotReceiveWithAnyArgs().LogProviderOrganizationEventsAsync(default);
} }
[Theory, OrganizationCustomize(FlexibleCollections = false), BitAutoData] [Theory, OrganizationCustomize, BitAutoData]
public async Task CreateOrganizationAsync_Success(Provider provider, OrganizationSignup organizationSignup, public async Task CreateOrganizationAsync_Success(Provider provider, OrganizationSignup organizationSignup,
Organization organization, string clientOwnerEmail, User user, SutProvider<ProviderService> sutProvider) Organization organization, string clientOwnerEmail, User user, SutProvider<ProviderService> sutProvider)
{ {
@ -631,17 +678,16 @@ public class ProviderServiceTests
.Received().LogProviderOrganizationEventAsync(providerOrganization, .Received().LogProviderOrganizationEventAsync(providerOrganization,
EventType.ProviderOrganization_Created); EventType.ProviderOrganization_Created);
await sutProvider.GetDependency<IOrganizationService>() 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 => t.Count() == 1 &&
t.First().Item1.Emails.Count() == 1 && t.First().Item1.Emails.Count() == 1 &&
t.First().Item1.Emails.First() == clientOwnerEmail && t.First().Item1.Emails.First() == clientOwnerEmail &&
t.First().Item1.Type == OrganizationUserType.Owner && t.First().Item1.Type == OrganizationUserType.Owner &&
t.First().Item1.AccessAll && t.First().Item1.Collections.Count() == 1 &&
!t.First().Item1.Collections.Any() &&
t.First().Item2 == null)); t.First().Item2 == null));
} }
[Theory, OrganizationCustomize(FlexibleCollections = false), BitAutoData] [Theory, OrganizationCustomize, BitAutoData]
public async Task CreateOrganizationAsync_ConsolidatedBillingEnabled_InvalidPlanType_ThrowsBadRequestException( public async Task CreateOrganizationAsync_ConsolidatedBillingEnabled_InvalidPlanType_ThrowsBadRequestException(
Provider provider, Provider provider,
OrganizationSignup organizationSignup, OrganizationSignup organizationSignup,
@ -670,7 +716,7 @@ public class ProviderServiceTests
await providerOrganizationRepository.DidNotReceiveWithAnyArgs().CreateAsync(default); await providerOrganizationRepository.DidNotReceiveWithAnyArgs().CreateAsync(default);
} }
[Theory, OrganizationCustomize(FlexibleCollections = false), BitAutoData] [Theory, OrganizationCustomize, BitAutoData]
public async Task CreateOrganizationAsync_ConsolidatedBillingEnabled_InvokeSignupClientAsync( public async Task CreateOrganizationAsync_ConsolidatedBillingEnabled_InvokeSignupClientAsync(
Provider provider, Provider provider,
OrganizationSignup organizationSignup, OrganizationSignup organizationSignup,
@ -709,19 +755,19 @@ public class ProviderServiceTests
.InviteUsersAsync( .InviteUsersAsync(
organization.Id, organization.Id,
user.Id, user.Id,
systemUser: null,
Arg.Is<IEnumerable<(OrganizationUserInvite, string)>>( Arg.Is<IEnumerable<(OrganizationUserInvite, string)>>(
t => t =>
t.Count() == 1 && t.Count() == 1 &&
t.First().Item1.Emails.Count() == 1 && t.First().Item1.Emails.Count() == 1 &&
t.First().Item1.Emails.First() == clientOwnerEmail && t.First().Item1.Emails.First() == clientOwnerEmail &&
t.First().Item1.Type == OrganizationUserType.Owner && t.First().Item1.Type == OrganizationUserType.Owner &&
t.First().Item1.AccessAll && t.First().Item1.Collections.Count() == 1 &&
!t.First().Item1.Collections.Any() &&
t.First().Item2 == null)); t.First().Item2 == null));
} }
[Theory, OrganizationCustomize(FlexibleCollections = true), BitAutoData] [Theory, OrganizationCustomize, BitAutoData]
public async Task CreateOrganizationAsync_WithFlexibleCollections_SetsAccessAllToFalse public async Task CreateOrganizationAsync_SetsAccessAllToFalse
(Provider provider, OrganizationSignup organizationSignup, Organization organization, string clientOwnerEmail, (Provider provider, OrganizationSignup organizationSignup, Organization organization, string clientOwnerEmail,
User user, SutProvider<ProviderService> sutProvider, Collection defaultCollection) User user, SutProvider<ProviderService> sutProvider, Collection defaultCollection)
{ {
@ -740,12 +786,11 @@ public class ProviderServiceTests
.Received().LogProviderOrganizationEventAsync(providerOrganization, .Received().LogProviderOrganizationEventAsync(providerOrganization,
EventType.ProviderOrganization_Created); EventType.ProviderOrganization_Created);
await sutProvider.GetDependency<IOrganizationService>() 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 => t.Count() == 1 &&
t.First().Item1.Emails.Count() == 1 && t.First().Item1.Emails.Count() == 1 &&
t.First().Item1.Emails.First() == clientOwnerEmail && t.First().Item1.Emails.First() == clientOwnerEmail &&
t.First().Item1.Type == OrganizationUserType.Owner && t.First().Item1.Type == OrganizationUserType.Owner &&
t.First().Item1.AccessAll == false &&
t.First().Item1.Collections.Single().Id == defaultCollection.Id && t.First().Item1.Collections.Single().Id == defaultCollection.Id &&
!t.First().Item1.Collections.Single().HidePasswords && !t.First().Item1.Collections.Single().HidePasswords &&
!t.First().Item1.Collections.Single().ReadOnly && !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] [Theory]
[BitAutoData] [BitAutoData]
public async Task CanUpdateSecret_WithoutProjectUser_DoesNotSucceed( public async Task CanUpdateSecret_ClearProjectsUser_DoesNotSucceed(
SutProvider<SecretAuthorizationHandler> sutProvider, Secret secret, SutProvider<SecretAuthorizationHandler> sutProvider, Secret secret,
Guid userId, Guid userId,
ClaimsPrincipal claimsPrincipal) ClaimsPrincipal claimsPrincipal)
{ {
secret.Projects = null; secret.Projects = [];
var requirement = SecretOperations.Update; var requirement = SecretOperations.Update;
SetupPermission(sutProvider, PermissionType.RunAsUserWithPermission, secret.OrganizationId, userId); 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 }, var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
claimsPrincipal, secret); claimsPrincipal, secret);
@ -370,12 +372,12 @@ public class SecretAuthorizationHandlerTests
[Theory] [Theory]
[BitAutoData] [BitAutoData]
public async Task CanUpdateSecret_WithoutProjectAdmin_Success(SutProvider<SecretAuthorizationHandler> sutProvider, public async Task CanUpdateSecret_ClearProjectsAdmin_Success(SutProvider<SecretAuthorizationHandler> sutProvider,
Secret secret, Secret secret,
Guid userId, Guid userId,
ClaimsPrincipal claimsPrincipal) ClaimsPrincipal claimsPrincipal)
{ {
secret.Projects = null; secret.Projects = [];
var requirement = SecretOperations.Update; var requirement = SecretOperations.Update;
SetupPermission(sutProvider, PermissionType.RunAsAdmin, secret.OrganizationId, userId); SetupPermission(sutProvider, PermissionType.RunAsAdmin, secret.OrganizationId, userId);
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement }, var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
@ -386,6 +388,35 @@ public class SecretAuthorizationHandlerTests
Assert.True(authzContext.HasSucceeded); 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] [Theory]
[BitAutoData(PermissionType.RunAsUserWithPermission, true, true, true, false)] [BitAutoData(PermissionType.RunAsUserWithPermission, true, true, true, false)]
[BitAutoData(PermissionType.RunAsUserWithPermission, true, true, false, false)] [BitAutoData(PermissionType.RunAsUserWithPermission, true, true, false, false)]
@ -517,4 +548,85 @@ public class SecretAuthorizationHandlerTests
Assert.Equal(expected, authzContext.HasSucceeded); 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 }; data.Projects = new List<Project>() { mockProject };
await sutProvider.Sut.CreateAsync(data); await sutProvider.Sut.CreateAsync(data, null);
await sutProvider.GetDependency<ISecretRepository>().Received(1) await sutProvider.GetDependency<ISecretRepository>().Received(1)
.CreateAsync(data); .CreateAsync(data, null);
} }
} }

View File

@ -1,12 +1,11 @@
using Bit.Commercial.Core.SecretsManager.Commands.Secrets; #nullable enable
using Bit.Core.Exceptions; using Bit.Commercial.Core.SecretsManager.Commands.Secrets;
using Bit.Core.SecretsManager.Entities; using Bit.Core.SecretsManager.Entities;
using Bit.Core.SecretsManager.Repositories; using Bit.Core.SecretsManager.Repositories;
using Bit.Core.Test.SecretsManager.AutoFixture.ProjectsFixture; using Bit.Core.Test.SecretsManager.AutoFixture.ProjectsFixture;
using Bit.Core.Test.SecretsManager.AutoFixture.SecretsFixture; using Bit.Core.Test.SecretsManager.AutoFixture.SecretsFixture;
using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes; using Bit.Test.Common.AutoFixture.Attributes;
using Bit.Test.Common.Helpers;
using NSubstitute; using NSubstitute;
using Xunit; using Xunit;
@ -19,109 +18,13 @@ public class UpdateSecretCommandTests
{ {
[Theory] [Theory]
[BitAutoData] [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); await sutProvider.Sut.UpdateAsync(data, null);
}
[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.GetDependency<ISecretRepository>().Received(1) await sutProvider.GetDependency<ISecretRepository>().Received(1)
.UpdateAsync(data); .UpdateAsync(data, null);
}
[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);
} }
} }

View File

@ -18,13 +18,21 @@ public class RevokeAccessTokenCommandTests
var apiKey1 = new ApiKey var apiKey1 = new ApiKey
{ {
Id = Guid.NewGuid(), 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 var apiKey2 = new ApiKey
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
ServiceAccountId = serviceAccount.Id ServiceAccountId = serviceAccount.Id,
Name = "Test Name",
Scope = "Test Scope",
EncryptedPayload = "Test EncryptedPayload",
Key = "Test Key",
}; };
sutProvider.GetDependency<IApiKeyRepository>() 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.Commercial.Core.SecretsManager.Queries.Projects;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.Enums; using Bit.Core.Billing.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.SecretsManager.Repositories; using Bit.Core.SecretsManager.Repositories;

View File

@ -201,7 +201,14 @@ public class ScimApplicationFactory : WebApplicationFactoryBase<Startup>
{ {
return new List<Organization>() 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 var expectedResult = new Group
{ {
Id = group.Id, Id = group.Id,
AccessAll = group.AccessAll,
ExternalId = group.ExternalId, ExternalId = group.ExternalId,
Name = displayName, Name = displayName,
OrganizationId = group.OrganizationId OrganizationId = group.OrganizationId
@ -77,7 +76,6 @@ public class PutGroupCommandTests
var expectedResult = new Group var expectedResult = new Group
{ {
Id = group.Id, Id = group.Id,
AccessAll = group.AccessAll,
ExternalId = group.ExternalId, ExternalId = group.ExternalId,
Name = displayName, Name = displayName,
OrganizationId = group.OrganizationId 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.Exceptions;
using Bit.Core.Models.Data; using Bit.Core.Models.Business;
using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
@ -19,7 +20,7 @@ public class PostUserCommandTests
{ {
[Theory] [Theory]
[BitAutoData] [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 var scimUserRequestModel = new ScimUserRequestModel
{ {
@ -33,16 +34,30 @@ public class PostUserCommandTests
.GetManyDetailsByOrganizationAsync(organizationId) .GetManyDetailsByOrganizationAsync(organizationId)
.Returns(organizationUsers); .Returns(organizationUsers);
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId).Returns(organization);
sutProvider.GetDependency<IPaymentService>().HasSecretsManagerStandalone(organization).Returns(true);
sutProvider.GetDependency<IOrganizationService>() sutProvider.GetDependency<IOrganizationService>()
.InviteUserAsync(organizationId, EventSystemUser.SCIM, scimUserRequestModel.PrimaryEmail.ToLowerInvariant(), .InviteUserAsync(organizationId, invitingUserId: null, EventSystemUser.SCIM,
OrganizationUserType.User, false, externalId, Arg.Any<List<CollectionAccessSelection>>(), Arg.Is<OrganizationUserInvite>(i =>
Arg.Any<List<Guid>>()) i.Emails.Single().Equals(scimUserRequestModel.PrimaryEmail.ToLowerInvariant()) &&
i.Type == OrganizationUserType.User &&
!i.Collections.Any() &&
!i.Groups.Any() &&
i.AccessSecretsManager), externalId)
.Returns(newUser); .Returns(newUser);
var user = await sutProvider.Sut.PostUserAsync(organizationId, scimUserRequestModel); var user = await sutProvider.Sut.PostUserAsync(organizationId, scimUserRequestModel);
await sutProvider.GetDependency<IOrganizationService>().Received(1).InviteUserAsync(organizationId, EventSystemUser.SCIM, scimUserRequestModel.PrimaryEmail.ToLowerInvariant(), await sutProvider.GetDependency<IOrganizationService>().Received(1).InviteUserAsync(organizationId,
OrganizationUserType.User, false, scimUserRequestModel.ExternalId, Arg.Any<List<CollectionAccessSelection>>(), Arg.Any<List<Guid>>()); 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); 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 ---" Write-Output "--- Attempting to start $service service ---"
# 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 docker-compose --profile $service up -d --no-recreate
}
dotnet tool restore 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 #!/usr/bin/env pwsh
# Creates the vault_dev database, and runs all the migrations. # 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( param(
[switch]$all, [switch]$all,
[switch]$postgres, [switch]$postgres,
@ -30,21 +27,17 @@ if ($all -or $postgres -or $mysql -or $sqlite) {
if ($all -or $mssql) { if ($all -or $mssql) {
function Get-UserSecrets { 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) { if ($selfhost) {
$msSqlConnectionString = $(Get-UserSecrets).'dev:selfHostOverride:globalSettings:sqlServer:connectionString' $msSqlConnectionString = $(Get-UserSecrets).'dev:selfHostOverride:globalSettings:sqlServer:connectionString'
$envName = "self-host" $envName = "self-host"
Write-Output "Migrating your migrations to use MsSqlMigratorUtility (if needed)"
./migrate_migration_record.ps1 -s
} else { } else {
$msSqlConnectionString = $(Get-UserSecrets).'globalSettings:sqlServer:connectionString' $msSqlConnectionString = $(Get-UserSecrets).'globalSettings:sqlServer:connectionString'
$envName = "cloud" $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" 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": { "sdk": {
"version": "8.0.100", "version": "8.0.100",
"rollForward": "latestFeature" "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", "")] [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] [Benchmark]
public Client? TryGetValue() public Client? TryGetValue()

View File

@ -7,8 +7,8 @@ using Bit.Core;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Providers.Interfaces;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Services;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
@ -54,9 +54,8 @@ public class OrganizationsController : Controller
private readonly IServiceAccountRepository _serviceAccountRepository; private readonly IServiceAccountRepository _serviceAccountRepository;
private readonly IProviderOrganizationRepository _providerOrganizationRepository; private readonly IProviderOrganizationRepository _providerOrganizationRepository;
private readonly IRemoveOrganizationFromProviderCommand _removeOrganizationFromProviderCommand; private readonly IRemoveOrganizationFromProviderCommand _removeOrganizationFromProviderCommand;
private readonly IRemovePaymentMethodCommand _removePaymentMethodCommand;
private readonly IFeatureService _featureService; private readonly IFeatureService _featureService;
private readonly IScaleSeatsCommand _scaleSeatsCommand; private readonly IProviderBillingService _providerBillingService;
public OrganizationsController( public OrganizationsController(
IOrganizationService organizationService, IOrganizationService organizationService,
@ -82,9 +81,8 @@ public class OrganizationsController : Controller
IServiceAccountRepository serviceAccountRepository, IServiceAccountRepository serviceAccountRepository,
IProviderOrganizationRepository providerOrganizationRepository, IProviderOrganizationRepository providerOrganizationRepository,
IRemoveOrganizationFromProviderCommand removeOrganizationFromProviderCommand, IRemoveOrganizationFromProviderCommand removeOrganizationFromProviderCommand,
IRemovePaymentMethodCommand removePaymentMethodCommand,
IFeatureService featureService, IFeatureService featureService,
IScaleSeatsCommand scaleSeatsCommand) IProviderBillingService providerBillingService)
{ {
_organizationService = organizationService; _organizationService = organizationService;
_organizationRepository = organizationRepository; _organizationRepository = organizationRepository;
@ -109,9 +107,8 @@ public class OrganizationsController : Controller
_serviceAccountRepository = serviceAccountRepository; _serviceAccountRepository = serviceAccountRepository;
_providerOrganizationRepository = providerOrganizationRepository; _providerOrganizationRepository = providerOrganizationRepository;
_removeOrganizationFromProviderCommand = removeOrganizationFromProviderCommand; _removeOrganizationFromProviderCommand = removeOrganizationFromProviderCommand;
_removePaymentMethodCommand = removePaymentMethodCommand;
_featureService = featureService; _featureService = featureService;
_scaleSeatsCommand = scaleSeatsCommand; _providerBillingService = providerBillingService;
} }
[RequirePermission(Permission.Org_List_View)] [RequirePermission(Permission.Org_List_View)]
@ -201,15 +198,32 @@ public class OrganizationsController : Controller
} }
var users = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(id); var users = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(id);
var billingInfo = await _paymentService.GetBillingAsync(organization); var billingInfo = await _paymentService.GetBillingAsync(organization);
var billingHistoryInfo = await _paymentService.GetBillingHistoryAsync(organization);
var billingSyncConnection = _globalSettings.EnableCloudCommunication ? await _organizationConnectionRepository.GetByOrganizationIdTypeAsync(id, OrganizationConnectionType.CloudBillingSync) : null; var billingSyncConnection = _globalSettings.EnableCloudCommunication ? await _organizationConnectionRepository.GetByOrganizationIdTypeAsync(id, OrganizationConnectionType.CloudBillingSync) : null;
var secrets = organization.UseSecretsManager ? await _secretRepository.GetSecretsCountByOrganizationIdAsync(id) : -1; var secrets = organization.UseSecretsManager ? await _secretRepository.GetSecretsCountByOrganizationIdAsync(id) : -1;
var projects = organization.UseSecretsManager ? await _projectRepository.GetProjectCountByOrganizationIdAsync(id) : -1; var projects = organization.UseSecretsManager ? await _projectRepository.GetProjectCountByOrganizationIdAsync(id) : -1;
var serviceAccounts = organization.UseSecretsManager ? await _serviceAccountRepository.GetServiceAccountCountByOrganizationIdAsync(id) : -1; var serviceAccounts = organization.UseSecretsManager ? await _serviceAccountRepository.GetServiceAccountCountByOrganizationIdAsync(id) : -1;
var smSeats = organization.UseSecretsManager var smSeats = organization.UseSecretsManager
? await _organizationUserRepository.GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id) ? await _organizationUserRepository.GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id)
: -1; : -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] [HttpPost]
@ -256,7 +270,7 @@ public class OrganizationsController : Controller
if (provider.IsBillable()) if (provider.IsBillable())
{ {
await _scaleSeatsCommand.ScalePasswordManagerSeats( await _providerBillingService.ScaleSeats(
provider, provider,
organization.PlanType, organization.PlanType,
-organization.Seats ?? 0); -organization.Seats ?? 0);
@ -269,6 +283,35 @@ public class OrganizationsController : Controller
return RedirectToAction("Index"); 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) public async Task<IActionResult> TriggerBillingSync(Guid id)
{ {
var organization = await _organizationRepository.GetByIdAsync(id); var organization = await _organizationRepository.GetByIdAsync(id);
@ -349,11 +392,6 @@ public class OrganizationsController : Controller
providerOrganization, providerOrganization,
organization); organization);
if (organization.IsStripeEnabled())
{
await _removePaymentMethodCommand.RemovePaymentMethod(organization);
}
return Json(null); return Json(null);
} }
private async Task<Organization> GetOrganization(Guid id, OrganizationEditModel model) private async Task<Organization> GetOrganization(Guid id, OrganizationEditModel model)
@ -414,5 +452,4 @@ public class OrganizationsController : Controller
return organization; return organization;
} }
} }

View File

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

View File

@ -10,8 +10,10 @@ using Bit.Core.AdminConsole.Providers.Interfaces;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Entities; using Bit.Core.Billing.Entities;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Repositories; using Bit.Core.Billing.Repositories;
using Bit.Core.Billing.Services;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
@ -39,6 +41,10 @@ public class ProvidersController : Controller
private readonly ICreateProviderCommand _createProviderCommand; private readonly ICreateProviderCommand _createProviderCommand;
private readonly IFeatureService _featureService; private readonly IFeatureService _featureService;
private readonly IProviderPlanRepository _providerPlanRepository; private readonly IProviderPlanRepository _providerPlanRepository;
private readonly IProviderBillingService _providerBillingService;
private readonly string _stripeUrl;
private readonly string _braintreeMerchantUrl;
private readonly string _braintreeMerchantId;
public ProvidersController( public ProvidersController(
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
@ -52,7 +58,9 @@ public class ProvidersController : Controller
IUserService userService, IUserService userService,
ICreateProviderCommand createProviderCommand, ICreateProviderCommand createProviderCommand,
IFeatureService featureService, IFeatureService featureService,
IProviderPlanRepository providerPlanRepository) IProviderPlanRepository providerPlanRepository,
IProviderBillingService providerBillingService,
IWebHostEnvironment webHostEnvironment)
{ {
_organizationRepository = organizationRepository; _organizationRepository = organizationRepository;
_organizationService = organizationService; _organizationService = organizationService;
@ -66,6 +74,10 @@ public class ProvidersController : Controller
_createProviderCommand = createProviderCommand; _createProviderCommand = createProviderCommand;
_featureService = featureService; _featureService = featureService;
_providerPlanRepository = providerPlanRepository; _providerPlanRepository = providerPlanRepository;
_providerBillingService = providerBillingService;
_stripeUrl = webHostEnvironment.GetStripeUrl();
_braintreeMerchantUrl = webHostEnvironment.GetBraintreeMerchantUrl();
_braintreeMerchantId = globalSettings.Braintree.MerchantId;
} }
[RequirePermission(Permission.Provider_List_View)] [RequirePermission(Permission.Provider_List_View)]
@ -168,7 +180,9 @@ public class ProvidersController : Controller
var providerPlans = await _providerPlanRepository.GetByProviderId(id); 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] [HttpPost]
@ -213,19 +227,10 @@ public class ProvidersController : Controller
} }
else else
{ {
foreach (var providerPlan in providerPlans) await _providerBillingService.UpdateSeatMinimums(
{ provider,
if (providerPlan.PlanType == PlanType.EnterpriseMonthly) model.EnterpriseMonthlySeatMinimum,
{ model.TeamsMonthlySeatMinimum);
providerPlan.SeatMinimum = model.EnterpriseMonthlySeatMinimum;
}
else if (providerPlan.PlanType == PlanType.TeamsMonthly)
{
providerPlan.SeatMinimum = model.TeamsMonthlySeatMinimum;
}
await _providerPlanRepository.ReplaceAsync(providerPlan);
}
} }
return RedirectToAction("Edit", new { id }); return RedirectToAction("Edit", new { id });
@ -305,9 +310,7 @@ public class ProvidersController : Controller
return RedirectToAction("Index"); return RedirectToAction("Index");
} }
var flexibleCollectionsSignupEnabled = _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsSignup); var organization = model.CreateOrganization(provider);
var flexibleCollectionsV1Enabled = _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1);
var organization = model.CreateOrganization(provider, flexibleCollectionsSignupEnabled, flexibleCollectionsV1Enabled);
await _organizationService.CreatePendingOrganization(organization, model.Owners, User, _userService, model.SalesAssistedTrialStarted); await _organizationService.CreatePendingOrganization(organization, model.Owners, User, _userService, model.SalesAssistedTrialStarted);
await _providerService.AddOrganization(providerId, organization.Id, null); await _providerService.AddOrganization(providerId, organization.Id, null);
@ -373,4 +376,34 @@ public class ProvidersController : Controller
return NoContent(); 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;
using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Models;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Models.Business;
using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Settings; using Bit.Core.Settings;
using Bit.Core.Utilities; using Bit.Core.Utilities;
@ -22,19 +23,43 @@ public class OrganizationEditModel : OrganizationViewModel
{ {
Provider = provider; Provider = provider;
BillingEmail = provider.Type == ProviderType.Reseller ? provider.BillingEmail : string.Empty; BillingEmail = provider.Type == ProviderType.Reseller ? provider.BillingEmail : string.Empty;
PlanType = Core.Enums.PlanType.TeamsMonthly; PlanType = Core.Billing.Enums.PlanType.TeamsMonthly;
Plan = Core.Enums.PlanType.TeamsMonthly.GetDisplayAttribute()?.GetName(); Plan = Core.Billing.Enums.PlanType.TeamsMonthly.GetDisplayAttribute()?.GetName();
LicenseKey = RandomLicenseKey; LicenseKey = RandomLicenseKey;
} }
public OrganizationEditModel(Organization org, Provider provider, IEnumerable<OrganizationUserUserDetails> orgUsers, public OrganizationEditModel(
IEnumerable<Cipher> ciphers, IEnumerable<Collection> collections, IEnumerable<Group> groups, Organization org,
IEnumerable<Policy> policies, BillingInfo billingInfo, IEnumerable<OrganizationConnection> connections, Provider provider,
GlobalSettings globalSettings, int secrets, int projects, int serviceAccounts, int occupiedSmSeats) IEnumerable<OrganizationUserUserDetails> orgUsers,
: base(org, provider, connections, orgUsers, ciphers, collections, groups, policies, secrets, projects, IEnumerable<Cipher> ciphers,
serviceAccounts, occupiedSmSeats) 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; BillingInfo = billingInfo;
BillingHistoryInfo = billingHistoryInfo;
BraintreeMerchantId = globalSettings.Braintree.MerchantId; BraintreeMerchantId = globalSettings.Braintree.MerchantId;
Name = org.DisplayName(); Name = org.DisplayName();
@ -73,6 +98,7 @@ public class OrganizationEditModel : OrganizationViewModel
} }
public BillingInfo BillingInfo { get; set; } public BillingInfo BillingInfo { get; set; }
public BillingHistoryInfo BillingHistoryInfo { get; set; }
public string RandomLicenseKey => CoreHelpers.SecureRandomString(20); public string RandomLicenseKey => CoreHelpers.SecureRandomString(20);
public string FourteenDayExpirationDate => DateTime.Now.AddDays(14).ToString("yyyy-MM-ddTHH:mm"); public string FourteenDayExpirationDate => DateTime.Now.AddDays(14).ToString("yyyy-MM-ddTHH:mm");
public string BraintreeMerchantId { get; set; } 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 * 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 * Add mappings for individual properties as you need them
*/ */
public IEnumerable<Dictionary<string, object>> GetPlansHelper() => public object GetPlansHelper() =>
StaticStore.Plans StaticStore.Plans
.Where(p => p.SupportsSecretsManager) .Where(p => p.SupportsSecretsManager)
.Select(p => new Dictionary<string, object> .Select(p =>
{ {
{ "type", p.Type }, var plan = new
{ "baseServiceAccount", p.SecretsManager.BaseServiceAccount } {
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; BillingEmail = provider.BillingEmail;
return ToOrganization(new Organization());
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);
} }
public Organization ToOrganization(Organization existingOrganization) 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 ServiceAccountsCount { get; set; }
public int OccupiedSmSeatsCount { get; set; } public int OccupiedSmSeatsCount { get; set; }
public bool UseSecretsManager => Organization.UseSecretsManager; 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.Entities.Provider;
using Bit.Core.AdminConsole.Models.Data.Provider; using Bit.Core.AdminConsole.Models.Data.Provider;
using Bit.Core.Billing.Entities; using Bit.Core.Billing.Entities;
using Bit.Core.Billing.Enums;
using Bit.Core.Enums; using Bit.Core.Enums;
namespace Bit.Admin.AdminConsole.Models; namespace Bit.Admin.AdminConsole.Models;
@ -14,7 +15,9 @@ public class ProviderEditModel : ProviderViewModel
Provider provider, Provider provider,
IEnumerable<ProviderUserUserDetails> providerUsers, IEnumerable<ProviderUserUserDetails> providerUsers,
IEnumerable<ProviderOrganizationOrganizationDetails> organizations, 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(); Name = provider.DisplayName();
BusinessName = provider.DisplayBusinessName(); BusinessName = provider.DisplayBusinessName();
@ -25,6 +28,8 @@ public class ProviderEditModel : ProviderViewModel
Gateway = provider.Gateway; Gateway = provider.Gateway;
GatewayCustomerId = provider.GatewayCustomerId; GatewayCustomerId = provider.GatewayCustomerId;
GatewaySubscriptionId = provider.GatewaySubscriptionId; GatewaySubscriptionId = provider.GatewaySubscriptionId;
GatewayCustomerUrl = gatewayCustomerUrl;
GatewaySubscriptionUrl = gatewaySubscriptionUrl;
} }
[Display(Name = "Billing Email")] [Display(Name = "Billing Email")]
@ -45,6 +50,8 @@ public class ProviderEditModel : ProviderViewModel
public string GatewayCustomerId { get; set; } public string GatewayCustomerId { get; set; }
[Display(Name = "Gateway Subscription Id")] [Display(Name = "Gateway Subscription Id")]
public string GatewaySubscriptionId { get; set; } public string GatewaySubscriptionId { get; set; }
public string GatewayCustomerUrl { get; }
public string GatewaySubscriptionUrl { get; }
public virtual Provider ToProvider(Provider existingProvider) 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.Admin.Models
@using Bit.Core.Billing.Enums
@using Bit.Core.Enums @using Bit.Core.Enums
@inject Bit.Admin.Services.IAccessControlService AccessControlService @inject Bit.Admin.Services.IAccessControlService AccessControlService
@model OrganizationEditModel @model OrganizationEditModel
@ -18,7 +19,9 @@
<script> <script>
(() => { (() => {
document.getElementById('teams-trial').addEventListener('click', () => { const treamsTrialButton = document.getElementById('teams-trial');
if (treamsTrialButton != null) {
treamsTrialButton.addEventListener('click', () => {
if (document.getElementById('@(nameof(Model.PlanType))').value !== '@((byte)PlanType.Free)') { if (document.getElementById('@(nameof(Model.PlanType))').value !== '@((byte)PlanType.Free)') {
alert('Organization is not on a free plan.'); alert('Organization is not on a free plan.');
return; return;
@ -27,7 +30,11 @@
togglePlanFeatures('@((byte)PlanType.TeamsAnnually)'); togglePlanFeatures('@((byte)PlanType.TeamsAnnually)');
document.getElementById('@(nameof(Model.Plan))').value = 'Teams (Trial)'; document.getElementById('@(nameof(Model.Plan))').value = 'Teams (Trial)';
}); });
document.getElementById('enterprise-trial').addEventListener('click', () => { }
const entTrialButton = document.getElementById('enterprise-trial');
if (entTrialButton != null) {
entTrialButton.addEventListener('click', () => {
if (document.getElementById('@(nameof(Model.PlanType))').value !== '@((byte)PlanType.Free)') { if (document.getElementById('@(nameof(Model.PlanType))').value !== '@((byte)PlanType.Free)') {
alert('Organization is not on a free plan.'); alert('Organization is not on a free plan.');
return; return;
@ -36,6 +43,19 @@
togglePlanFeatures('@((byte)PlanType.EnterpriseAnnually)'); togglePlanFeatures('@((byte)PlanType.EnterpriseAnnually)');
document.getElementById('@(nameof(Model.Plan))').value = 'Enterprise (Trial)'; 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) { function setTrialDefaults(planType) {
// Plan // Plan
@ -76,7 +96,7 @@
{ {
<h2>Billing Information</h2> <h2>Billing Information</h2>
@await Html.PartialAsync("_BillingInformation", @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) @await Html.PartialAsync("~/AdminConsole/Views/Shared/_OrganizationForm.cshtml", Model)
@ -95,18 +115,20 @@
} }
@if (canUnlinkFromProvider && Model.Provider is not null) @if (canUnlinkFromProvider && Model.Provider is not null)
{ {
<button <button class="btn btn-outline-danger mr-2"
class="btn btn-outline-danger mr-2" onclick="return unlinkProvider('@Model.Organization.Id');">
onclick="return unlinkProvider('@Model.Organization.Id');"
>
Unlink provider Unlink provider
</button> </button>
} }
@if (canDelete) @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" <form asp-action="Delete" asp-route-id="@Model.Organization.Id"
onsubmit="return confirm('Are you sure you want to delete this organization?')"> onsubmit="return confirm('Are you sure you want to hard delete this organization?')">
<button class="btn btn-danger" type="submit">Delete</button> <button class="btn btn-outline-danger" type="submit">Delete</button>
</form> </form>
} }
</div> </div>

View File

@ -50,14 +50,11 @@
<dt class="col-sm-4 col-lg-3">Collections</dt> <dt class="col-sm-4 col-lg-3">Collections</dt>
<dd class="col-sm-8 col-lg-9">@Model.CollectionCount</dd> <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> <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> <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> </dl>
<h2>Secrets Manager</h2> <h2>Secrets Manager</h2>

View File

@ -78,9 +78,9 @@
<div class="input-group"> <div class="input-group">
<input type="text" class="form-control" asp-for="GatewayCustomerId"> <input type="text" class="form-control" asp-for="GatewayCustomerId">
<div class="input-group-append"> <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> <i class="fa fa-external-link"></i>
</button> </a>
</div> </div>
</div> </div>
</div> </div>
@ -91,9 +91,9 @@
<div class="input-group"> <div class="input-group">
<input type="text" class="form-control" asp-for="GatewaySubscriptionId"> <input type="text" class="form-control" asp-for="GatewaySubscriptionId">
<div class="input-group-append"> <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> <i class="fa fa-external-link"></i>
</button> </a>
</div> </div>
</div> </div>
</div> </div>

View File

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

View File

@ -1,6 +1,8 @@
@inject IWebHostEnvironment HostingEnvironment @inject IWebHostEnvironment HostingEnvironment
@using Bit.Admin.Utilities @using Bit.Admin.Utilities
@using Bit.Core.Billing.Enums
@using Bit.Core.Enums @using Bit.Core.Enums
@using Bit.Core.Utilities
@model OrganizationEditModel @model OrganizationEditModel
<script> <script>
@ -52,55 +54,38 @@
})(); })();
function togglePlanFeatures(planType) { function togglePlanFeatures(planType) {
switch(planType) { const plan = getPlan(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;
case '@((byte)PlanType.EnterpriseMonthly2019)': if (!plan) {
case '@((byte)PlanType.EnterpriseAnnually2019)': return;
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;
} }
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) { function unlinkProvider(id) {
@ -133,7 +118,7 @@
document.getElementById('@(nameof(Model.SmSeats))').value = Math.max(@Model.OccupiedSmSeatsCount, 1); document.getElementById('@(nameof(Model.SmSeats))').value = Math.max(@Model.OccupiedSmSeatsCount, 1);
// Service accounts // Service accounts
const baseServiceAccounts = getPlan(planType)?.baseServiceAccount ?? 0; const baseServiceAccounts = getPlan(planType)?.secretsManager?.baseServiceAccount ?? 0;
if (planType !== '@((byte)PlanType.Free)' && @Model.ServiceAccountsCount > baseServiceAccounts) { if (planType !== '@((byte)PlanType.Free)' && @Model.ServiceAccountsCount > baseServiceAccounts) {
document.getElementById('@(nameof(Model.SmServiceAccounts))').value = @Model.ServiceAccountsCount; document.getElementById('@(nameof(Model.SmServiceAccounts))').value = @Model.ServiceAccountsCount;
} else { } else {

View File

@ -13,6 +13,7 @@ using Bit.Core.Settings;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using TaxRate = Bit.Core.Entities.TaxRate;
namespace Bit.Admin.Controllers; namespace Bit.Admin.Controllers;
@ -518,8 +519,17 @@ public class ToolsController : Controller
{ {
model.Filter.StartingAfter = null; model.Filter.StartingAfter = null;
} }
if (model.Action == StripeSubscriptionsAction.NextPage || model.Action == StripeSubscriptionsAction.Search) 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; model.Filter.EndingBefore = null;
} }
} }

View File

@ -3,6 +3,7 @@ using Bit.Admin.Models;
using Bit.Admin.Services; using Bit.Admin.Services;
using Bit.Admin.Utilities; using Bit.Admin.Utilities;
using Bit.Core; using Bit.Core;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Repositories; using Bit.Core.Repositories;
@ -25,9 +26,7 @@ public class UsersController : Controller
private readonly IAccessControlService _accessControlService; private readonly IAccessControlService _accessControlService;
private readonly ICurrentContext _currentContext; private readonly ICurrentContext _currentContext;
private readonly IFeatureService _featureService; private readonly IFeatureService _featureService;
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
private bool UseFlexibleCollections =>
_featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections);
public UsersController( public UsersController(
IUserRepository userRepository, IUserRepository userRepository,
@ -36,7 +35,8 @@ public class UsersController : Controller
GlobalSettings globalSettings, GlobalSettings globalSettings,
IAccessControlService accessControlService, IAccessControlService accessControlService,
ICurrentContext currentContext, ICurrentContext currentContext,
IFeatureService featureService) IFeatureService featureService,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery)
{ {
_userRepository = userRepository; _userRepository = userRepository;
_cipherRepository = cipherRepository; _cipherRepository = cipherRepository;
@ -45,6 +45,7 @@ public class UsersController : Controller
_accessControlService = accessControlService; _accessControlService = accessControlService;
_currentContext = currentContext; _currentContext = currentContext;
_featureService = featureService; _featureService = featureService;
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
} }
[RequirePermission(Permission.User_List_View)] [RequirePermission(Permission.User_List_View)]
@ -62,6 +63,12 @@ public class UsersController : Controller
var skip = (page - 1) * count; var skip = (page - 1) * count;
var users = await _userRepository.SearchAsync(email, skip, count); var users = await _userRepository.SearchAsync(email, skip, count);
if (_featureService.IsEnabled(FeatureFlagKeys.MembersTwoFAQueryOptimization))
{
TempData["UsersTwoFactorIsEnabled"] = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(users.Select(u => u.Id));
}
return View(new UsersModel return View(new UsersModel
{ {
Items = users as List<User>, Items = users as List<User>,
@ -80,7 +87,7 @@ public class UsersController : Controller
return RedirectToAction("Index"); return RedirectToAction("Index");
} }
var ciphers = await _cipherRepository.GetManyByUserIdAsync(id, useFlexibleCollections: UseFlexibleCollections); var ciphers = await _cipherRepository.GetManyByUserIdAsync(id);
return View(new UserViewModel(user, ciphers)); return View(new UserViewModel(user, ciphers));
} }
@ -93,9 +100,10 @@ public class UsersController : Controller
return RedirectToAction("Index"); return RedirectToAction("Index");
} }
var ciphers = await _cipherRepository.GetManyByUserIdAsync(id, useFlexibleCollections: UseFlexibleCollections); var ciphers = await _cipherRepository.GetManyByUserIdAsync(id);
var billingInfo = await _paymentService.GetBillingAsync(user); 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] [HttpPost]

View File

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

View File

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

View File

@ -1,4 +1,4 @@
@import "webfonts.css"; @import "webfonts.scss";
$primary: #175DDC; $primary: #175DDC;
$primary-accent: #1252A3; $primary-accent: #1252A3;
@ -17,7 +17,7 @@ $h4-font-size: 1rem;
$h5-font-size: 1rem; $h5-font-size: 1rem;
$h6-font-size: 1rem; $h6-font-size: 1rem;
@import "../node_modules/bootstrap/scss/bootstrap.scss"; @import "bootstrap/scss/bootstrap.scss";
h1 { h1 {
border-bottom: 1px solid $border-color; border-bottom: 1px solid $border-color;

View File

@ -16,11 +16,11 @@
<dt class="col-sm-4 col-lg-3">Invoices</dt> <dt class="col-sm-4 col-lg-3">Invoices</dt>
<dd class="col-sm-8 col-lg-9"> <dd class="col-sm-8 col-lg-9">
@if(Model.BillingInfo.Invoices?.Any() ?? false) @if(Model.BillingHistoryInfo.Invoices?.Any() ?? false)
{ {
<table class="table"> <table class="table">
<tbody> <tbody>
@foreach(var invoice in Model.BillingInfo.Invoices) @foreach(var invoice in Model.BillingHistoryInfo.Invoices)
{ {
<tr> <tr>
<td>@invoice.Date</td> <td>@invoice.Date</td>
@ -49,11 +49,11 @@
<dt class="col-sm-4 col-lg-3">Transactions</dt> <dt class="col-sm-4 col-lg-3">Transactions</dt>
<dd class="col-sm-8 col-lg-9"> <dd class="col-sm-8 col-lg-9">
@if(Model.BillingInfo.Transactions?.Any() ?? false) @if(Model.BillingHistoryInfo.Transactions?.Any() ?? false)
{ {
<table class="table"> <table class="table">
<tbody> <tbody>
@foreach(var transaction in Model.BillingInfo.Transactions) @foreach(var transaction in Model.BillingHistoryInfo.Transactions)
{ {
<tr> <tr>
<td>@transaction.CreatedDate</td> <td>@transaction.CreatedDate</td>

View File

@ -27,17 +27,7 @@
<meta name="robots" content="noindex,nofollow" /> <meta name="robots" content="noindex,nofollow" />
<title>@ViewData["Title"] - Bitwarden Admin Portal</title> <title>@ViewData["Title"] - Bitwarden Admin Portal</title>
<link rel="stylesheet" href="~/css/webfonts.css" /> <link rel="stylesheet" href="~/assets/site.css" asp-append-version="true" />
<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>
</head> </head>
<body> <body>
<nav class="navbar navbar-expand-md navbar-dark bg-dark mb-4"> <nav class="navbar navbar-expand-md navbar-dark bg-dark mb-4">
@ -153,18 +143,7 @@
&copy; @DateTime.Now.Year, Bitwarden Inc. &copy; @DateTime.Now.Year, Bitwarden Inc.
</footer> </footer>
<environment include="Development"> <script src="~/assets/site.js" asp-append-version="true"></script>
<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>
@if (TempData["Error"] != null) @if (TempData["Error"] != null)
{ {
@ -174,6 +153,14 @@
}); });
</script> </script>
} }
@if (TempData["Success"] != null)
{
<script>
$(document).ready(function () {
toastr.success("@TempData["Success"]")
});
</script>
}
@RenderSection("Scripts", required: false) @RenderSection("Scripts", required: false)
</body> </body>

View File

@ -109,7 +109,7 @@
<option asp-selected="Model.Filter.Price == null" value="@null">All</option> <option asp-selected="Model.Filter.Price == null" value="@null">All</option>
@foreach (var price in Model.Prices) @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> </select>
</div> </div>
@ -119,7 +119,7 @@
<option asp-selected="Model.Filter.TestClock == null" value="@null">All</option> <option asp-selected="Model.Filter.TestClock == null" value="@null">All</option>
@foreach (var clock in Model.TestClocks) @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> </select>
</div> </div>
@ -149,7 +149,7 @@
<th>Id</th> <th>Id</th>
<th>Customer Email</th> <th>Customer Email</th>
<th>Status</th> <th>Status</th>
<th>Product</th> <th>Product Tier</th>
<th>Current Period End</th> <th>Current Period End</th>
</tr> </tr>
</thead> </thead>

View File

@ -92,7 +92,7 @@
{ {
<h2>Billing Information</h2> <h2>Billing Information</h2>
@await Html.PartialAsync("_BillingInformation", @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) @if (canViewGeneral)
{ {

View File

@ -1,5 +1,6 @@
@model UsersModel @model UsersModel
@inject Bit.Core.Services.IUserService userService @inject Bit.Core.Services.IUserService userService
@inject Bit.Core.Services.IFeatureService featureService
@{ @{
ViewData["Title"] = "Users"; ViewData["Title"] = "Users";
} }
@ -69,6 +70,20 @@
{ {
<i class="fa fa-times-circle-o fa-lg fa-fw text-muted" title="Email Not Verified"></i> <i class="fa fa-times-circle-o fa-lg fa-fw text-muted" title="Email Not Verified"></i>
} }
@if (featureService.IsEnabled(Bit.Core.FeatureFlagKeys.MembersTwoFAQueryOptimization))
{
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
{
@if(await userService.TwoFactorIsEnabledAsync(user)) @if(await userService.TwoFactorIsEnabledAsync(user))
{ {
<i class="fa fa-lock fa-lg fa-fw" title="2FA Enabled"></i> <i class="fa fa-lock fa-lg fa-fw" title="2FA Enabled"></i>
@ -77,6 +92,7 @@
{ {
<i class="fa fa-unlock fa-lg fa-fw text-muted" title="2FA Not Enabled"></i> <i class="fa fa-unlock fa-lg fa-fw text-muted" title="2FA Not Enabled"></i>
} }
}
</td> </td>
</tr> </tr>
} }

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