mirror of
https://github.com/bitwarden/server.git
synced 2024-11-24 12:35:25 +01:00
Merge branch 'main' into auth/pm-6631/handle-webauthn-creation-exception
This commit is contained in:
commit
dd73045ee4
@ -3,11 +3,11 @@
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"swashbuckle.aspnetcore.cli": {
|
||||
"version": "6.5.0",
|
||||
"version": "6.7.3",
|
||||
"commands": ["swagger"]
|
||||
},
|
||||
"dotnet-ef": {
|
||||
"version": "8.0.2",
|
||||
"version": "8.0.8",
|
||||
"commands": ["dotnet-ef"]
|
||||
}
|
||||
}
|
||||
|
@ -3,6 +3,13 @@
|
||||
"dockerComposeFile": "../../.devcontainer/bitwarden_common/docker-compose.yml",
|
||||
"service": "bitwarden_server",
|
||||
"workspaceFolder": "/workspace",
|
||||
"mounts": [
|
||||
{
|
||||
"source": "../../dev/.data/keys",
|
||||
"target": "/home/vscode/.aspnet/DataProtection-Keys",
|
||||
"type": "bind"
|
||||
}
|
||||
],
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"settings": {},
|
||||
|
@ -3,8 +3,16 @@
|
||||
"dockerComposeFile": [
|
||||
"../../.devcontainer/bitwarden_common/docker-compose.yml",
|
||||
"../../.devcontainer/internal_dev/docker-compose.override.yml"
|
||||
], "service": "bitwarden_server",
|
||||
],
|
||||
"service": "bitwarden_server",
|
||||
"workspaceFolder": "/workspace",
|
||||
"mounts": [
|
||||
{
|
||||
"source": "../../dev/.data/keys",
|
||||
"target": "/home/vscode/.aspnet/DataProtection-Keys",
|
||||
"type": "bind"
|
||||
}
|
||||
],
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"settings": {},
|
||||
|
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
@ -38,6 +38,8 @@ src/Identity @bitwarden/team-auth-dev
|
||||
bitwarden_license/src/Scim @bitwarden/team-admin-console-dev
|
||||
bitwarden_license/src/test/Scim.IntegrationTest @bitwarden/team-admin-console-dev
|
||||
bitwarden_license/src/test/Scim.ScimTest @bitwarden/team-admin-console-dev
|
||||
src/Events @bitwarden/team-admin-console-dev
|
||||
src/EventsProcessor @bitwarden/team-admin-console-dev
|
||||
|
||||
# Billing team
|
||||
**/*billing* @bitwarden/team-billing-dev
|
||||
|
47
.github/PULL_REQUEST_TEMPLATE.md
vendored
47
.github/PULL_REQUEST_TEMPLATE.md
vendored
@ -1,30 +1,35 @@
|
||||
## Type of change
|
||||
## 🎟️ Tracking
|
||||
|
||||
<!-- (mark with an `X`) -->
|
||||
<!-- Paste the link to the Jira or GitHub issue or otherwise describe / point to where this change is coming from. -->
|
||||
|
||||
```
|
||||
- [ ] Bug fix
|
||||
- [ ] New feature development
|
||||
- [ ] Tech debt (refactoring, code cleanup, dependency upgrades, etc)
|
||||
- [ ] Build/deploy pipeline (DevOps)
|
||||
- [ ] Other
|
||||
```
|
||||
## 📔 Objective
|
||||
|
||||
## Objective
|
||||
<!--Describe what the purpose of this PR is. For example: what bug you're fixing or what new feature you're adding-->
|
||||
<!-- Describe what the purpose of this PR is, for example what bug you're fixing or new feature you're adding. -->
|
||||
|
||||
## 📸 Screenshots
|
||||
|
||||
<!-- Required for any UI changes; delete if not applicable. Use fixed width images for better display. -->
|
||||
|
||||
## Code changes
|
||||
<!--Explain the changes you've made to each file or major component. This should help the reviewer understand your changes-->
|
||||
<!--Also refer to any related changes or PRs in other repositories-->
|
||||
## ⏰ Reminders before review
|
||||
|
||||
* **file.ext:** Description of what was changed and why
|
||||
- Contributor guidelines followed
|
||||
- All formatters and local linters executed and passed
|
||||
- Written new unit and / or integration tests where applicable
|
||||
- Protected functional changes with optionality (feature flags)
|
||||
- Used internationalization (i18n) for all UI strings
|
||||
- CI builds passed
|
||||
- Communicated to DevOps any deployment requirements
|
||||
- Updated any necessary documentation (Confluence, contributing docs) or informed the documentation team
|
||||
|
||||
## Before you submit
|
||||
## 🦮 Reviewer guidelines
|
||||
|
||||
- Please check for formatting errors (`dotnet format --verify-no-changes`) (required)
|
||||
- If making database changes - make sure you also update Entity Framework queries and/or migrations
|
||||
- Please add **unit tests** where it makes sense to do so (encouraged but not required)
|
||||
- If this change requires a **documentation update** - notify the documentation team
|
||||
- If this change has particular **deployment requirements** - notify the DevOps team
|
||||
<!-- Suggested interactions but feel free to use (or not) as you desire! -->
|
||||
|
||||
- 👍 (`:+1:`) or similar for great changes
|
||||
- 📝 (`:memo:`) or ℹ️ (`:information_source:`) for notes or general info
|
||||
- ❓ (`:question:`) for questions
|
||||
- 🤔 (`:thinking:`) or 💭 (`:thought_balloon:`) for more open inquiry that's not quite a confirmed issue and could potentially benefit from discussion
|
||||
- 🎨 (`:art:`) for suggestions / improvements
|
||||
- ❌ (`:x:`) or ⚠️ (`:warning:`) for more significant problems or concerns needing attention
|
||||
- 🌱 (`:seedling:`) or ♻️ (`:recycle:`) for future improvements or indications of technical debt
|
||||
- ⛏ (`:pick:`) for minor or nitpick changes
|
||||
|
11
.github/renovate.json
vendored
11
.github/renovate.json
vendored
@ -40,12 +40,6 @@
|
||||
"commitMessagePrefix": "[deps] Auth:",
|
||||
"reviewers": ["team:team-auth-dev"]
|
||||
},
|
||||
{
|
||||
"matchPackageNames": ["bootstrap", "del", "gulp"],
|
||||
"matchUpdateTypes": ["major"],
|
||||
"description": "Lock bootstrap, del, and gulp major versions due to ASP.NET conflicts",
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"matchPackageNames": [
|
||||
"AspNetCoreRateLimit",
|
||||
@ -59,8 +53,6 @@
|
||||
"DuoUniversal",
|
||||
"Fido2.AspNet",
|
||||
"Duende.IdentityServer",
|
||||
"Microsoft.Azure.Cosmos",
|
||||
"Microsoft.Extensions.Caching.StackExchangeRedis",
|
||||
"Microsoft.Extensions.Identity.Stores",
|
||||
"Otp.NET",
|
||||
"Sustainsys.Saml2.AspNetCore2",
|
||||
@ -112,12 +104,15 @@
|
||||
"dbup-sqlserver",
|
||||
"dotnet-ef",
|
||||
"linq2db.EntityFrameworkCore",
|
||||
"Microsoft.Azure.Cosmos",
|
||||
"Microsoft.Data.SqlClient",
|
||||
"Microsoft.EntityFrameworkCore.Design",
|
||||
"Microsoft.EntityFrameworkCore.InMemory",
|
||||
"Microsoft.EntityFrameworkCore.Relational",
|
||||
"Microsoft.EntityFrameworkCore.Sqlite",
|
||||
"Microsoft.EntityFrameworkCore.SqlServer",
|
||||
"Microsoft.Extensions.Caching.SqlServer",
|
||||
"Microsoft.Extensions.Caching.StackExchangeRedis",
|
||||
"Npgsql.EntityFrameworkCore.PostgreSQL",
|
||||
"Pomelo.EntityFrameworkCore.MySql"
|
||||
],
|
||||
|
@ -18,7 +18,7 @@ jobs:
|
||||
copy_finalization_scripts: ${{ steps.check-finalization-scripts-existence.outputs.copy_finalization_scripts }}
|
||||
steps:
|
||||
- name: Log in to Azure
|
||||
uses: Azure/login@de95379fe4dadc2defb305917eaa7e5dde727294 # v1.5.1
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
|
||||
|
||||
@ -30,7 +30,7 @@ jobs:
|
||||
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
|
||||
|
||||
- name: Check out branch
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
with:
|
||||
token: ${{ steps.retrieve-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
|
||||
|
||||
@ -54,7 +54,7 @@ jobs:
|
||||
if: ${{ needs.setup.outputs.copy_finalization_scripts == 'true' }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@ -94,7 +94,7 @@ jobs:
|
||||
echo "moved_files=$moved_files" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Log in to Azure - production subscription
|
||||
uses: Azure/login@de95379fe4dadc2defb305917eaa7e5dde727294 # v1.5.1
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
|
||||
|
||||
@ -108,7 +108,7 @@ jobs:
|
||||
devops-alerts-slack-webhook-url"
|
||||
|
||||
- name: Import GPG keys
|
||||
uses: crazy-max/ghaction-import-gpg@82a020f1f7f605c65dd2449b392a52c3fcfef7ef # v6.0.0
|
||||
uses: crazy-max/ghaction-import-gpg@01dd5d3ca463c7f10f7f4f7b4f177225ac661ee4 # v6.1.0
|
||||
with:
|
||||
gpg_private_key: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key }}
|
||||
passphrase: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key-passphrase }}
|
||||
@ -154,7 +154,7 @@ jobs:
|
||||
|
||||
- name: Notify Slack about creation of PR
|
||||
if: ${{ steps.commit.outputs.pr_needed == 'true' }}
|
||||
uses: act10ns/slack@ed1309ab9862e57e9e583e51c7889486b9a00b0f # v2.0.0
|
||||
uses: act10ns/slack@44541246747a30eb3102d87f7a4cc5471b0ffb7d # v2.1.0
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ steps.retrieve-secrets.outputs.devops-alerts-slack-webhook-url }}
|
||||
with:
|
||||
|
10
.github/workflows/automatic-issue-responses.yml
vendored
10
.github/workflows/automatic-issue-responses.yml
vendored
@ -14,7 +14,7 @@ jobs:
|
||||
# Feature request
|
||||
- if: github.event.label.name == 'feature-request'
|
||||
name: Feature request
|
||||
uses: peter-evans/close-issue@1373cadf1f0c96c1420bc000cfba2273ea307fd1 # v2.2.0
|
||||
uses: peter-evans/close-issue@276d7966e389d888f011539a86c8920025ea0626 # v3.0.1
|
||||
with:
|
||||
comment: |
|
||||
We use GitHub issues as a place to track bugs and other development related issues. The [Bitwarden Community Forums](https://community.bitwarden.com/) has a [Feature Requests](https://community.bitwarden.com/c/feature-requests) section for submitting, voting for, and discussing requests like this one.
|
||||
@ -25,7 +25,7 @@ jobs:
|
||||
# Intended behavior
|
||||
- if: github.event.label.name == 'intended-behavior'
|
||||
name: Intended behavior
|
||||
uses: peter-evans/close-issue@1373cadf1f0c96c1420bc000cfba2273ea307fd1 # v2.2.0
|
||||
uses: peter-evans/close-issue@276d7966e389d888f011539a86c8920025ea0626 # v3.0.1
|
||||
with:
|
||||
comment: |
|
||||
Your issue appears to be describing the intended behavior of the software. If you want this to be changed, it would be a feature request.
|
||||
@ -38,7 +38,7 @@ jobs:
|
||||
# Customer support request
|
||||
- if: github.event.label.name == 'customer-support'
|
||||
name: Customer Support request
|
||||
uses: peter-evans/close-issue@1373cadf1f0c96c1420bc000cfba2273ea307fd1 # v2.2.0
|
||||
uses: peter-evans/close-issue@276d7966e389d888f011539a86c8920025ea0626 # v3.0.1
|
||||
with:
|
||||
comment: |
|
||||
We use GitHub issues as a place to track bugs and other development related issues. Your issue appears to be a support request, or would otherwise be better handled by our dedicated Customer Success team.
|
||||
@ -49,14 +49,14 @@ jobs:
|
||||
# Resolved
|
||||
- if: github.event.label.name == 'resolved'
|
||||
name: Resolved
|
||||
uses: peter-evans/close-issue@1373cadf1f0c96c1420bc000cfba2273ea307fd1 # v2.2.0
|
||||
uses: peter-evans/close-issue@276d7966e389d888f011539a86c8920025ea0626 # v3.0.1
|
||||
with:
|
||||
comment: |
|
||||
We’ve closed this issue, as it appears the original problem has been resolved. If this happens again or continues to be an problem, please respond to this issue with any additional detail to assist with reproduction and root cause analysis.
|
||||
# Stale
|
||||
- if: github.event.label.name == 'stale'
|
||||
name: Stale
|
||||
uses: peter-evans/close-issue@1373cadf1f0c96c1420bc000cfba2273ea307fd1 # v2.2.0
|
||||
uses: peter-evans/close-issue@276d7966e389d888f011539a86c8920025ea0626 # v3.0.1
|
||||
with:
|
||||
comment: |
|
||||
As we haven’t heard from you about this problem in some time, this issue will now be closed.
|
||||
|
107
.github/workflows/build.yml
vendored
107
.github/workflows/build.yml
vendored
@ -19,10 +19,10 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # v3.2.0
|
||||
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
|
||||
|
||||
- name: Verify format
|
||||
run: dotnet format --verify-no-changes
|
||||
@ -68,13 +68,13 @@ jobs:
|
||||
node: true
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # v3.2.0
|
||||
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@5e21ff4d9bc1a8cf6de233a3057d20ec6b3fb69d # v3.8.1
|
||||
uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3
|
||||
with:
|
||||
cache: "npm"
|
||||
cache-dependency-path: "**/package-lock.json"
|
||||
@ -110,7 +110,7 @@ jobs:
|
||||
ls -atlh ../../../
|
||||
|
||||
- name: Upload project artifact
|
||||
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3
|
||||
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
|
||||
with:
|
||||
name: ${{ matrix.project_name }}.zip
|
||||
path: ${{ matrix.base_path }}/${{ matrix.project_name }}/${{ matrix.project_name }}.zip
|
||||
@ -173,7 +173,7 @@ jobs:
|
||||
dotnet: true
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
|
||||
- name: Check branch to publish
|
||||
env:
|
||||
@ -190,7 +190,7 @@ jobs:
|
||||
|
||||
########## ACRs ##########
|
||||
- name: Log in to Azure - production subscription
|
||||
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
|
||||
|
||||
@ -198,7 +198,7 @@ jobs:
|
||||
run: az acr login -n bitwardenprod
|
||||
|
||||
- name: Log in to Azure - CI subscription
|
||||
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
|
||||
|
||||
@ -251,7 +251,7 @@ jobs:
|
||||
|
||||
- name: Get build artifact
|
||||
if: ${{ matrix.dotnet }}
|
||||
uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3.0.2
|
||||
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
|
||||
with:
|
||||
name: ${{ matrix.project_name }}.zip
|
||||
|
||||
@ -263,7 +263,7 @@ jobs:
|
||||
-d ${{ matrix.base_path }}/${{ matrix.project_name }}/obj/build-output/publish
|
||||
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@1104d471370f9806843c095c1db02b5a90c5f8b6 # v3.3.1
|
||||
uses: docker/build-push-action@5cd11c3a4ced054e52742c5fd54dca954e0edd85 # v6.7.0
|
||||
with:
|
||||
context: ${{ matrix.base_path }}/${{ matrix.project_name }}
|
||||
file: ${{ matrix.base_path }}/${{ matrix.project_name }}/Dockerfile
|
||||
@ -275,14 +275,14 @@ jobs:
|
||||
|
||||
- name: Scan Docker image
|
||||
id: container-scan
|
||||
uses: anchore/scan-action@3343887d815d7b07465f6fdcd395bd66508d486a # v3.6.4
|
||||
uses: anchore/scan-action@64a33b277ea7a1215a3c142735a1091341939ff5 # v4.1.2
|
||||
with:
|
||||
image: ${{ steps.image-tags.outputs.primary_tag }}
|
||||
fail-build: false
|
||||
output-format: sarif
|
||||
|
||||
- name: Upload Grype results to GitHub
|
||||
uses: github/codeql-action/upload-sarif@b7bf0a3ed3ecfa44160715d7c442788f65f0f923 # v3.23.2
|
||||
uses: github/codeql-action/upload-sarif@2c779ab0d087cd7fe7b826087247c2c81f27bfa6 # v3.26.5
|
||||
with:
|
||||
sarif_file: ${{ steps.container-scan.outputs.sarif }}
|
||||
|
||||
@ -292,13 +292,13 @@ jobs:
|
||||
needs: build-docker
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # v3.2.0
|
||||
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
|
||||
|
||||
- name: Log in to Azure - production subscription
|
||||
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
|
||||
|
||||
@ -355,7 +355,7 @@ jobs:
|
||||
|
||||
- name: Upload Docker stub US artifact
|
||||
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc'
|
||||
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3
|
||||
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
|
||||
with:
|
||||
name: docker-stub-US.zip
|
||||
path: docker-stub-US.zip
|
||||
@ -363,7 +363,7 @@ jobs:
|
||||
|
||||
- name: Upload Docker stub EU artifact
|
||||
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc'
|
||||
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3
|
||||
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
|
||||
with:
|
||||
name: docker-stub-EU.zip
|
||||
path: docker-stub-EU.zip
|
||||
@ -371,7 +371,7 @@ jobs:
|
||||
|
||||
- name: Upload Docker stub US checksum artifact
|
||||
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc'
|
||||
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3
|
||||
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
|
||||
with:
|
||||
name: docker-stub-US-sha256.txt
|
||||
path: docker-stub-US-sha256.txt
|
||||
@ -379,13 +379,13 @@ jobs:
|
||||
|
||||
- name: Upload Docker stub EU checksum artifact
|
||||
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc'
|
||||
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3
|
||||
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
|
||||
with:
|
||||
name: docker-stub-EU-sha256.txt
|
||||
path: docker-stub-EU-sha256.txt
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Build Swagger
|
||||
- name: Build Public API Swagger
|
||||
run: |
|
||||
cd ./src/Api
|
||||
echo "Restore tools"
|
||||
@ -402,12 +402,53 @@ jobs:
|
||||
DOTNET_ROLL_FORWARD_ON_NO_CANDIDATE_FX: 2
|
||||
GLOBALSETTINGS__SQLSERVER__CONNECTIONSTRING: "placeholder"
|
||||
|
||||
- name: Upload Swagger artifact
|
||||
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3
|
||||
- name: Upload Public API Swagger artifact
|
||||
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
|
||||
with:
|
||||
name: swagger.json
|
||||
path: swagger.json
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Build Internal API Swagger
|
||||
run: |
|
||||
cd ./src/Api
|
||||
echo "Restore API tools"
|
||||
dotnet tool restore
|
||||
echo "Publish API"
|
||||
dotnet publish -c "Release" -o obj/build-output/publish
|
||||
|
||||
dotnet swagger tofile --output ../../internal.json --host https://api.bitwarden.com \
|
||||
./obj/build-output/publish/Api.dll internal
|
||||
|
||||
cd ../Identity
|
||||
|
||||
echo "Restore Identity tools"
|
||||
dotnet tool restore
|
||||
echo "Publish Identity"
|
||||
dotnet publish -c "Release" -o obj/build-output/publish
|
||||
|
||||
dotnet swagger tofile --output ../../identity.json --host https://identity.bitwarden.com \
|
||||
./obj/build-output/publish/Identity.dll v1
|
||||
cd ../..
|
||||
env:
|
||||
ASPNETCORE_ENVIRONMENT: Development
|
||||
swaggerGen: "True"
|
||||
DOTNET_ROLL_FORWARD_ON_NO_CANDIDATE_FX: 2
|
||||
GLOBALSETTINGS__SQLSERVER__CONNECTIONSTRING: "placeholder"
|
||||
|
||||
- name: Upload Internal API Swagger artifact
|
||||
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
|
||||
with:
|
||||
name: internal.json
|
||||
path: internal.json
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload Identity Swagger artifact
|
||||
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
|
||||
with:
|
||||
name: identity.json
|
||||
path: identity.json
|
||||
if-no-files-found: error
|
||||
|
||||
build-mssqlmigratorutility:
|
||||
name: Build MSSQL migrator utility
|
||||
@ -426,10 +467,10 @@ jobs:
|
||||
- win-x64
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # v3.2.0
|
||||
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
|
||||
|
||||
- name: Print environment
|
||||
run: |
|
||||
@ -445,7 +486,7 @@ jobs:
|
||||
|
||||
- name: Upload project artifact for Windows
|
||||
if: ${{ contains(matrix.target, 'win') == true }}
|
||||
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3
|
||||
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
|
||||
with:
|
||||
name: MsSqlMigratorUtility-${{ matrix.target }}
|
||||
path: util/MsSqlMigratorUtility/obj/build-output/publish/MsSqlMigratorUtility.exe
|
||||
@ -453,7 +494,7 @@ jobs:
|
||||
|
||||
- name: Upload project artifact
|
||||
if: ${{ contains(matrix.target, 'win') == false }}
|
||||
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3
|
||||
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
|
||||
with:
|
||||
name: MsSqlMigratorUtility-${{ matrix.target }}
|
||||
path: util/MsSqlMigratorUtility/obj/build-output/publish/MsSqlMigratorUtility
|
||||
@ -465,7 +506,7 @@ jobs:
|
||||
needs: build-docker
|
||||
steps:
|
||||
- name: Log in to Azure - CI subscription
|
||||
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
|
||||
|
||||
@ -477,7 +518,7 @@ jobs:
|
||||
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
|
||||
|
||||
- name: Trigger self-host build
|
||||
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6.4.1
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
|
||||
script: |
|
||||
@ -498,7 +539,7 @@ jobs:
|
||||
needs: build-docker
|
||||
steps:
|
||||
- name: Log in to Azure - CI subscription
|
||||
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
|
||||
|
||||
@ -510,7 +551,7 @@ jobs:
|
||||
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
|
||||
|
||||
- name: Trigger k8s deploy
|
||||
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6.4.1
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
|
||||
script: |
|
||||
@ -547,7 +588,7 @@ jobs:
|
||||
run: exit 1
|
||||
|
||||
- name: Log in to Azure - CI subscription
|
||||
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
if: failure()
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
|
||||
@ -561,7 +602,7 @@ jobs:
|
||||
secrets: "devops-alerts-slack-webhook-url"
|
||||
|
||||
- name: Notify Slack on failure
|
||||
uses: act10ns/slack@ed1309ab9862e57e9e583e51c7889486b9a00b0f # v2.0.0
|
||||
uses: act10ns/slack@44541246747a30eb3102d87f7a4cc5471b0ffb7d # v2.1.0
|
||||
if: failure()
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ steps.retrieve-secrets.outputs.devops-alerts-slack-webhook-url }}
|
||||
|
2
.github/workflows/cleanup-after-pr.yml
vendored
2
.github/workflows/cleanup-after-pr.yml
vendored
@ -14,7 +14,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Log in to Azure - production subscription
|
||||
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
|
||||
|
||||
|
2
.github/workflows/cleanup-rc-branch.yml
vendored
2
.github/workflows/cleanup-rc-branch.yml
vendored
@ -24,7 +24,7 @@ jobs:
|
||||
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
|
||||
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
with:
|
||||
ref: main
|
||||
token: ${{ steps.retrieve-bot-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
|
||||
|
35
.github/workflows/code-references.yml
vendored
35
.github/workflows/code-references.yml
vendored
@ -1,26 +1,43 @@
|
||||
---
|
||||
name: Collect code references
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches-ignore:
|
||||
- "renovate/**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
check-ld-secret:
|
||||
name: Check for LD secret
|
||||
runs-on: ubuntu-22.04
|
||||
outputs:
|
||||
available: ${{ steps.check-ld-secret.outputs.available }}
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Check
|
||||
id: check-ld-secret
|
||||
run: |
|
||||
if [ "${{ secrets.LD_ACCESS_TOKEN }}" != '' ]; then
|
||||
echo "available=true" >> $GITHUB_OUTPUT;
|
||||
else
|
||||
echo "available=false" >> $GITHUB_OUTPUT;
|
||||
fi
|
||||
|
||||
refs:
|
||||
name: Code reference collection
|
||||
runs-on: ubuntu-22.04
|
||||
needs: check-ld-secret
|
||||
if: ${{ needs.check-ld-secret.outputs.available == 'true' }}
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
|
||||
- name: Collect
|
||||
id: collect
|
||||
uses: launchdarkly/find-code-references-in-pull-request@2e9333c88539377cfbe818c265ba8b9ebced3c91 # v1.1.0
|
||||
uses: launchdarkly/find-code-references-in-pull-request@d008aa4f321d8cd35314d9cb095388dcfde84439 # v2.0.0
|
||||
with:
|
||||
project-key: default
|
||||
environment-key: dev
|
||||
|
@ -13,7 +13,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Log in to Azure
|
||||
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
|
||||
|
||||
@ -80,7 +80,7 @@ jobs:
|
||||
run: exit 1
|
||||
|
||||
- name: Log in to Azure - CI subscription
|
||||
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
if: failure()
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
|
||||
@ -94,7 +94,7 @@ jobs:
|
||||
secrets: "devops-alerts-slack-webhook-url"
|
||||
|
||||
- name: Notify Slack on failure
|
||||
uses: act10ns/slack@ed1309ab9862e57e9e583e51c7889486b9a00b0f # v2.0.0
|
||||
uses: act10ns/slack@44541246747a30eb3102d87f7a4cc5471b0ffb7d # v2.1.0
|
||||
if: failure()
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ steps.retrieve-secrets.outputs.devops-alerts-slack-webhook-url }}
|
||||
|
2
.github/workflows/protect-files.yml
vendored
2
.github/workflows/protect-files.yml
vendored
@ -29,7 +29,7 @@ jobs:
|
||||
label: "DB-migrations-changed"
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
|
149
.github/workflows/release.yml
vendored
149
.github/workflows/release.yml
vendored
@ -27,7 +27,7 @@ jobs:
|
||||
branch-name: ${{ steps.branch.outputs.branch-name }}
|
||||
steps:
|
||||
- name: Branch check
|
||||
if: ${{ github.event.inputs.release_type != 'Dry Run' }}
|
||||
if: ${{ inputs.release_type != 'Dry Run' }}
|
||||
run: |
|
||||
if [[ "$GITHUB_REF" != "refs/heads/rc" ]] && [[ "$GITHUB_REF" != "refs/heads/hotfix-rc" ]]; then
|
||||
echo "==================================="
|
||||
@ -37,13 +37,13 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
|
||||
- name: Check release version
|
||||
id: version
|
||||
uses: bitwarden/gh-actions/release-version-check@main
|
||||
with:
|
||||
release-type: ${{ github.event.inputs.release_type }}
|
||||
release-type: ${{ inputs.release_type }}
|
||||
project-type: dotnet
|
||||
file: Directory.Build.props
|
||||
|
||||
@ -53,125 +53,6 @@ jobs:
|
||||
BRANCH_NAME=$(basename ${{ github.ref }})
|
||||
echo "branch-name=$BRANCH_NAME" >> $GITHUB_OUTPUT
|
||||
|
||||
deploy:
|
||||
name: Deploy
|
||||
runs-on: ubuntu-22.04
|
||||
needs: setup
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: Admin
|
||||
- name: Api
|
||||
- name: Billing
|
||||
- name: Events
|
||||
- name: Identity
|
||||
- name: Sso
|
||||
steps:
|
||||
- name: Setup
|
||||
id: setup
|
||||
run: |
|
||||
NAME_LOWER=$(echo "${{ matrix.name }}" | awk '{print tolower($0)}')
|
||||
echo "Matrix name: ${{ matrix.name }}"
|
||||
echo "NAME_LOWER: $NAME_LOWER"
|
||||
echo "name_lower=$NAME_LOWER" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create GitHub deployment for ${{ matrix.name }}
|
||||
if: ${{ github.event.inputs.release_type != 'Dry Run' }}
|
||||
uses: chrnorm/deployment-action@d42cde7132fcec920de534fffc3be83794335c00 # v2.0.5
|
||||
id: deployment
|
||||
with:
|
||||
token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
initial-status: "in_progress"
|
||||
environment: "Production Cloud"
|
||||
task: "deploy"
|
||||
description: "Deploy from ${{ needs.setup.outputs.branch-name }} branch"
|
||||
|
||||
- name: Download latest release ${{ matrix.name }} asset
|
||||
if: ${{ github.event.inputs.release_type != 'Dry Run' }}
|
||||
uses: bitwarden/gh-actions/download-artifacts@main
|
||||
with:
|
||||
workflow: build.yml
|
||||
workflow_conclusion: success
|
||||
branch: ${{ needs.setup.outputs.branch-name }}
|
||||
artifacts: ${{ matrix.name }}.zip
|
||||
|
||||
- name: Dry run - Download latest release ${{ matrix.name }} asset
|
||||
if: ${{ github.event.inputs.release_type == 'Dry Run' }}
|
||||
uses: bitwarden/gh-actions/download-artifacts@main
|
||||
with:
|
||||
workflow: build.yml
|
||||
workflow_conclusion: success
|
||||
branch: main
|
||||
artifacts: ${{ matrix.name }}.zip
|
||||
|
||||
- name: Log in to Azure - CI subscription
|
||||
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
|
||||
|
||||
- name: Retrieve secrets
|
||||
id: retrieve-secrets
|
||||
env:
|
||||
VAULT_NAME: "bitwarden-ci"
|
||||
run: |
|
||||
webapp_name=$(
|
||||
az keyvault secret show --vault-name $VAULT_NAME \
|
||||
--name appservices-${{ steps.setup.outputs.name_lower }}-webapp-name \
|
||||
--query value --output tsv
|
||||
)
|
||||
publish_profile=$(
|
||||
az keyvault secret show --vault-name $VAULT_NAME \
|
||||
--name appservices-${{ steps.setup.outputs.name_lower }}-webapp-publish-profile \
|
||||
--query value --output tsv
|
||||
)
|
||||
echo "::add-mask::$webapp_name"
|
||||
echo "webapp-name=$webapp_name" >> $GITHUB_OUTPUT
|
||||
echo "::add-mask::$publish_profile"
|
||||
echo "publish-profile=$publish_profile" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Log in to Azure
|
||||
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
|
||||
|
||||
- name: Deploy app
|
||||
uses: azure/webapps-deploy@4bca689e4c7129e55923ea9c45401b22dc6aa96f # v2.2.11
|
||||
with:
|
||||
app-name: ${{ steps.retrieve-secrets.outputs.webapp-name }}
|
||||
publish-profile: ${{ steps.retrieve-secrets.outputs.publish-profile }}
|
||||
package: ./${{ matrix.name }}.zip
|
||||
slot-name: "staging"
|
||||
|
||||
- name: Start staging slot
|
||||
if: ${{ github.event.inputs.release_type != 'Dry Run' }}
|
||||
env:
|
||||
SERVICE: ${{ matrix.name }}
|
||||
WEBAPP_NAME: ${{ steps.retrieve-secrets.outputs.webapp-name }}
|
||||
run: |
|
||||
if [[ "$SERVICE" = "Api" ]] || [[ "$SERVICE" = "Identity" ]]; then
|
||||
RESOURCE_GROUP=bitwardenappservices
|
||||
else
|
||||
RESOURCE_GROUP=bitwarden
|
||||
fi
|
||||
az webapp start -n $WEBAPP_NAME -g $RESOURCE_GROUP -s staging
|
||||
|
||||
- name: Update ${{ matrix.name }} deployment status to success
|
||||
if: ${{ github.event.inputs.release_type != 'Dry Run' && success() }}
|
||||
uses: chrnorm/deployment-status@2afb7d27101260f4a764219439564d954d10b5b0 # v2.0.1
|
||||
with:
|
||||
token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
state: "success"
|
||||
deployment-id: ${{ steps.deployment.outputs.deployment_id }}
|
||||
|
||||
- name: Update ${{ matrix.name }} deployment status to failure
|
||||
if: ${{ github.event.inputs.release_type != 'Dry Run' && failure() }}
|
||||
uses: chrnorm/deployment-status@2afb7d27101260f4a764219439564d954d10b5b0 # v2.0.1
|
||||
with:
|
||||
token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
state: "failure"
|
||||
deployment-id: ${{ steps.deployment.outputs.deployment_id }}
|
||||
|
||||
release-docker:
|
||||
name: Build Docker images
|
||||
runs-on: ubuntu-22.04
|
||||
@ -202,7 +83,7 @@ jobs:
|
||||
steps:
|
||||
- name: Print environment
|
||||
env:
|
||||
RELEASE_OPTION: ${{ github.event.inputs.release_type }}
|
||||
RELEASE_OPTION: ${{ inputs.release_type }}
|
||||
run: |
|
||||
whoami
|
||||
docker --version
|
||||
@ -211,7 +92,7 @@ jobs:
|
||||
echo "Github Release Option: $RELEASE_OPTION"
|
||||
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
|
||||
- name: Set up project name
|
||||
id: setup
|
||||
@ -223,7 +104,7 @@ jobs:
|
||||
|
||||
########## ACR PROD ##########
|
||||
- name: Log in to Azure - production subscription
|
||||
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
|
||||
|
||||
@ -234,7 +115,7 @@ jobs:
|
||||
env:
|
||||
PROJECT_NAME: ${{ steps.setup.outputs.project_name }}
|
||||
run: |
|
||||
if [[ "${{ github.event.inputs.release_type }}" == "Dry Run" ]]; then
|
||||
if [[ "${{ inputs.release_type }}" == "Dry Run" ]]; then
|
||||
docker pull $_AZ_REGISTRY/$PROJECT_NAME:latest
|
||||
else
|
||||
docker pull $_AZ_REGISTRY/$PROJECT_NAME:$_BRANCH_NAME
|
||||
@ -244,7 +125,7 @@ jobs:
|
||||
env:
|
||||
PROJECT_NAME: ${{ steps.setup.outputs.project_name }}
|
||||
run: |
|
||||
if [[ "${{ github.event.inputs.release_type }}" == "Dry Run" ]]; then
|
||||
if [[ "${{ inputs.release_type }}" == "Dry Run" ]]; then
|
||||
docker tag $_AZ_REGISTRY/$PROJECT_NAME:latest $_AZ_REGISTRY/$PROJECT_NAME:dryrun
|
||||
else
|
||||
docker tag $_AZ_REGISTRY/$PROJECT_NAME:$_BRANCH_NAME $_AZ_REGISTRY/$PROJECT_NAME:$_RELEASE_VERSION
|
||||
@ -255,7 +136,7 @@ jobs:
|
||||
env:
|
||||
PROJECT_NAME: ${{ steps.setup.outputs.project_name }}
|
||||
run: |
|
||||
if [[ "${{ github.event.inputs.release_type }}" == "Dry Run" ]]; then
|
||||
if [[ "${{ inputs.release_type }}" == "Dry Run" ]]; then
|
||||
docker push $_AZ_REGISTRY/$PROJECT_NAME:dryrun
|
||||
else
|
||||
docker push $_AZ_REGISTRY/$PROJECT_NAME:$_RELEASE_VERSION
|
||||
@ -268,12 +149,10 @@ jobs:
|
||||
release:
|
||||
name: Create GitHub release
|
||||
runs-on: ubuntu-22.04
|
||||
needs:
|
||||
- setup
|
||||
- deploy
|
||||
needs: setup
|
||||
steps:
|
||||
- name: Download latest release Docker stubs
|
||||
if: ${{ github.event.inputs.release_type != 'Dry Run' }}
|
||||
if: ${{ inputs.release_type != 'Dry Run' }}
|
||||
uses: bitwarden/gh-actions/download-artifacts@main
|
||||
with:
|
||||
workflow: build.yml
|
||||
@ -286,7 +165,7 @@ jobs:
|
||||
swagger.json"
|
||||
|
||||
- name: Dry Run - Download latest release Docker stubs
|
||||
if: ${{ github.event.inputs.release_type == 'Dry Run' }}
|
||||
if: ${{ inputs.release_type == 'Dry Run' }}
|
||||
uses: bitwarden/gh-actions/download-artifacts@main
|
||||
with:
|
||||
workflow: build.yml
|
||||
@ -299,8 +178,8 @@ jobs:
|
||||
swagger.json"
|
||||
|
||||
- name: Create release
|
||||
if: ${{ github.event.inputs.release_type != 'Dry Run' }}
|
||||
uses: ncipollo/release-action@6c75be85e571768fa31b40abf38de58ba0397db5 # v1.13.0
|
||||
if: ${{ inputs.release_type != 'Dry Run' }}
|
||||
uses: ncipollo/release-action@2c591bcc8ecdcd2db72b97d6147f871fcd833ba5 # v1.14.0
|
||||
with:
|
||||
artifacts: "docker-stub-US.zip,
|
||||
docker-stub-US-sha256.txt,
|
||||
|
34
.github/workflows/scan.yml
vendored
34
.github/workflows/scan.yml
vendored
@ -26,12 +26,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Scan with Checkmarx
|
||||
uses: checkmarx/ast-github-action@749fec53e0db0f6404a97e2e0807c3e80e3583a7 #2.0.23
|
||||
uses: checkmarx/ast-github-action@1fe318de2993222574e6249750ba9000a4e2a6cd # 2.0.33
|
||||
env:
|
||||
INCREMENTAL: "${{ contains(github.event_name, 'pull_request') && '--sast-incremental' || '' }}"
|
||||
with:
|
||||
@ -46,7 +46,7 @@ jobs:
|
||||
--output-path . ${{ env.INCREMENTAL }}
|
||||
|
||||
- name: Upload Checkmarx results to GitHub
|
||||
uses: github/codeql-action/upload-sarif@1b1aada464948af03b950897e5eb522f92603cc2 # v3.24.9
|
||||
uses: github/codeql-action/upload-sarif@2c779ab0d087cd7fe7b826087247c2c81f27bfa6 # v3.26.5
|
||||
with:
|
||||
sarif_file: cx_result.sarif
|
||||
|
||||
@ -59,19 +59,33 @@ jobs:
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@6a0805fcefea3d4657a47ac4c165951e33482018 # v4.2.2
|
||||
with:
|
||||
java-version: 17
|
||||
distribution: "zulu"
|
||||
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
|
||||
|
||||
- name: Install SonarCloud scanner
|
||||
run: dotnet tool install dotnet-sonarscanner -g
|
||||
|
||||
- name: Scan with SonarCloud
|
||||
uses: sonarsource/sonarcloud-github-action@49e6cd3b187936a73b8280d59ffd9da69df63ec9 # v2.1.1
|
||||
env:
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
args: >
|
||||
-Dsonar.organization=${{ github.repository_owner }}
|
||||
-Dsonar.projectKey=${{ github.repository_owner }}_${{ github.event.repository.name }}
|
||||
-Dsonar.tests=test/
|
||||
run: |
|
||||
dotnet-sonarscanner begin /k:"${{ github.repository_owner }}_${{ github.event.repository.name }}" \
|
||||
/d:sonar.test.inclusions=test/,bitwarden_license/test/ \
|
||||
/d:sonar.exclusions=test/,bitwarden_license/test/ \
|
||||
/o:"${{ github.repository_owner }}" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" \
|
||||
/d:sonar.host.url="https://sonarcloud.io"
|
||||
dotnet build
|
||||
dotnet-sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}"
|
||||
|
2
.github/workflows/stale-bot.yml
vendored
2
.github/workflows/stale-bot.yml
vendored
@ -11,7 +11,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Check
|
||||
uses: actions/stale@1160a2240286f5da8ec72b1c0816ce2481aabf84 # v8.0.0
|
||||
uses: actions/stale@28ca1036281a5e5922ead5184a1bbf96e5fc984e # v9.0.0
|
||||
with:
|
||||
stale-issue-label: "needs-reply"
|
||||
stale-pr-label: "needs-changes"
|
||||
|
64
.github/workflows/stop-staging-slots.yml
vendored
64
.github/workflows/stop-staging-slots.yml
vendored
@ -1,64 +0,0 @@
|
||||
---
|
||||
name: Stop staging slots
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs: {}
|
||||
|
||||
jobs:
|
||||
stop-slots:
|
||||
name: Stop slots
|
||||
runs-on: ubuntu-22.04
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: Api
|
||||
- name: Admin
|
||||
- name: Billing
|
||||
- name: Events
|
||||
- name: Sso
|
||||
- name: Identity
|
||||
steps:
|
||||
- name: Setup
|
||||
id: setup
|
||||
run: |
|
||||
NAME_LOWER=$(echo "${{ matrix.name }}" | awk '{print tolower($0)}')
|
||||
echo "Matrix name: ${{ matrix.name }}"
|
||||
echo "NAME_LOWER: $NAME_LOWER"
|
||||
echo "name_lower=$NAME_LOWER" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Log in to Azure - CI subscription
|
||||
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
|
||||
|
||||
- name: Retrieve secrets
|
||||
id: retrieve-secrets
|
||||
env:
|
||||
VAULT_NAME: "bitwarden-ci"
|
||||
run: |
|
||||
webapp_name=$(
|
||||
az keyvault secret show --vault-name $VAULT_NAME \
|
||||
--name appservices-${{ steps.setup.outputs.name_lower }}-webapp-name \
|
||||
--query value --output tsv
|
||||
)
|
||||
echo "::add-mask::$webapp_name"
|
||||
echo "webapp-name=$webapp_name" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Log in to Azure
|
||||
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
|
||||
|
||||
- name: Stop staging slot
|
||||
env:
|
||||
SERVICE: ${{ matrix.name }}
|
||||
WEBAPP_NAME: ${{ steps.retrieve-secrets.outputs.webapp-name }}
|
||||
run: |
|
||||
if [[ "$SERVICE" = "Api" ]] || [[ "$SERVICE" = "Identity" ]]; then
|
||||
RESOURCE_GROUP=bitwardenappservices
|
||||
else
|
||||
RESOURCE_GROUP=bitwarden
|
||||
fi
|
||||
az webapp stop -n $WEBAPP_NAME -g $RESOURCE_GROUP -s staging
|
36
.github/workflows/test-database.yml
vendored
36
.github/workflows/test-database.yml
vendored
@ -9,7 +9,7 @@ on:
|
||||
- "rc"
|
||||
- "hotfix-rc"
|
||||
paths:
|
||||
- ".github/workflows/infrastructure-tests.yml" # This file
|
||||
- ".github/workflows/test-database.yml" # This file
|
||||
- "src/Sql/**" # SQL Server Database Changes
|
||||
- "util/Migrator/**" # New SQL Server Migrations
|
||||
- "util/MySqlMigrations/**" # Changes to MySQL
|
||||
@ -20,7 +20,7 @@ on:
|
||||
- "test/Infrastructure.IntegrationTest/**" # Any changes to the tests
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/infrastructure-tests.yml" # This file
|
||||
- ".github/workflows/test-database.yml" # This file
|
||||
- "src/Sql/**" # SQL Server Database Changes
|
||||
- "util/Migrator/**" # New SQL Server Migrations
|
||||
- "util/MySqlMigrations/**" # Changes to MySQL
|
||||
@ -36,10 +36,10 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # v3.2.0
|
||||
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
|
||||
|
||||
- name: Restore tools
|
||||
run: dotnet tool restore
|
||||
@ -55,6 +55,24 @@ jobs:
|
||||
# I've seen the SQL Server container not be ready for commands right after starting up and just needing a bit longer to be ready
|
||||
- name: Sleep
|
||||
run: sleep 15s
|
||||
|
||||
- name: Checking pending model changes (MySQL)
|
||||
working-directory: "util/MySqlMigrations"
|
||||
run: 'dotnet ef migrations has-pending-model-changes -- --GlobalSettings:MySql:ConnectionString="$CONN_STR"'
|
||||
env:
|
||||
CONN_STR: "server=localhost;uid=root;pwd=SET_A_PASSWORD_HERE_123;database=vault_dev;Allow User Variables=true"
|
||||
|
||||
- name: Checking pending model changes (Postgres)
|
||||
working-directory: "util/PostgresMigrations"
|
||||
run: 'dotnet ef migrations has-pending-model-changes -- --GlobalSettings:PostgreSql:ConnectionString="$CONN_STR"'
|
||||
env:
|
||||
CONN_STR: "Host=localhost;Username=postgres;Password=SET_A_PASSWORD_HERE_123;Database=vault_dev"
|
||||
|
||||
- name: Checking pending model changes (SQLite)
|
||||
working-directory: "util/SqliteMigrations"
|
||||
run: 'dotnet ef migrations has-pending-model-changes -- --GlobalSettings:Sqlite:ConnectionString="$CONN_STR"'
|
||||
env:
|
||||
CONN_STR: "Data Source=${{ runner.temp }}/test.db"
|
||||
|
||||
- name: Migrate SQL Server
|
||||
run: 'dotnet run --project util/MsSqlMigratorUtility/ "$CONN_STR"'
|
||||
@ -98,7 +116,7 @@ jobs:
|
||||
shell: pwsh
|
||||
|
||||
- name: Report test results
|
||||
uses: dorny/test-reporter@c9b3d0e2bd2a4e96aaf424dbaa31c46b42318226 # v1.6.0
|
||||
uses: dorny/test-reporter@31a54ee7ebcacc03a09ea97a7e5465a47b84aea5 # v1.9.1
|
||||
if: always()
|
||||
with:
|
||||
name: Test Results
|
||||
@ -117,10 +135,10 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # v3.2.0
|
||||
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
|
||||
|
||||
- name: Print environment
|
||||
run: |
|
||||
@ -134,7 +152,7 @@ jobs:
|
||||
shell: pwsh
|
||||
|
||||
- name: Upload DACPAC
|
||||
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3
|
||||
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
|
||||
with:
|
||||
name: sql.dacpac
|
||||
path: Sql.dacpac
|
||||
@ -160,7 +178,7 @@ jobs:
|
||||
shell: pwsh
|
||||
|
||||
- name: Report validation results
|
||||
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3
|
||||
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
|
||||
with:
|
||||
name: report.xml
|
||||
path: |
|
||||
|
37
.github/workflows/test.yml
vendored
37
.github/workflows/test.yml
vendored
@ -1,4 +1,3 @@
|
||||
---
|
||||
name: Testing
|
||||
|
||||
on:
|
||||
@ -14,18 +13,43 @@ env:
|
||||
_AZ_REGISTRY: "bitwardenprod.azurecr.io"
|
||||
|
||||
jobs:
|
||||
check-test-secrets:
|
||||
name: Check for test secrets
|
||||
runs-on: ubuntu-22.04
|
||||
outputs:
|
||||
available: ${{ steps.check-test-secrets.outputs.available }}
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Check
|
||||
id: check-test-secrets
|
||||
run: |
|
||||
if [ "${{ secrets.CODECOV_TOKEN }}" != '' ]; then
|
||||
echo "available=true" >> $GITHUB_OUTPUT;
|
||||
else
|
||||
echo "available=false" >> $GITHUB_OUTPUT;
|
||||
fi
|
||||
|
||||
testing:
|
||||
name: Run tests
|
||||
if: ${{ startsWith(github.head_ref, 'version_bump_') == false }}
|
||||
runs-on: ubuntu-22.04
|
||||
needs: check-test-secrets
|
||||
permissions:
|
||||
checks: write
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
env:
|
||||
NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # v3.2.0
|
||||
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
|
||||
|
||||
- name: Print environment
|
||||
run: |
|
||||
@ -44,8 +68,8 @@ jobs:
|
||||
run: dotnet test ./bitwarden_license/test --configuration Debug --logger "trx;LogFileName=bw-test-results.trx" /p:CoverletOutputFormatter="cobertura" --collect:"XPlat Code Coverage"
|
||||
|
||||
- name: Report test results
|
||||
uses: dorny/test-reporter@c9b3d0e2bd2a4e96aaf424dbaa31c46b42318226 # v1.6.0
|
||||
if: always()
|
||||
uses: dorny/test-reporter@31a54ee7ebcacc03a09ea97a7e5465a47b84aea5 # v1.9.1
|
||||
if: ${{ needs.check-test-secrets.outputs.available == 'true' && !cancelled() }}
|
||||
with:
|
||||
name: Test Results
|
||||
path: "**/*-test-results.trx"
|
||||
@ -53,6 +77,7 @@ jobs:
|
||||
fail-on-error: true
|
||||
|
||||
- name: Upload to codecov.io
|
||||
uses: codecov/codecov-action@0cfda1dd0a4ad9efc75517f399d859cd1ea4ced1 # v4.0.2
|
||||
uses: codecov/codecov-action@e28ff129e5465c2c0dcc6f003fc735cb6ae0c673 # v4.5.0
|
||||
if: ${{ needs.check-test-secrets.outputs.available == 'true' }}
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
29
.github/workflows/version-bump.yml
vendored
29
.github/workflows/version-bump.yml
vendored
@ -12,6 +12,10 @@ on:
|
||||
description: "Cut RC branch?"
|
||||
default: true
|
||||
type: boolean
|
||||
enable_slack_notification:
|
||||
description: "Enable Slack notifications for upcoming release?"
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
bump_version:
|
||||
@ -26,10 +30,16 @@ jobs:
|
||||
with:
|
||||
version: ${{ inputs.version_number_override }}
|
||||
|
||||
- name: Slack Notification Check
|
||||
run: |
|
||||
if [[ "${{ inputs.enable_slack_notification }}" == true ]]; then
|
||||
echo "Slack notifications enabled."
|
||||
else
|
||||
echo "Slack notifications disabled."
|
||||
fi
|
||||
|
||||
- name: Check out branch
|
||||
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
|
||||
with:
|
||||
ref: main
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
|
||||
- name: Check if RC branch exists
|
||||
if: ${{ inputs.cut_rc_branch == true }}
|
||||
@ -42,7 +52,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Log in to Azure - CI subscription
|
||||
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
|
||||
|
||||
@ -56,7 +66,7 @@ jobs:
|
||||
github-pat-bitwarden-devops-bot-repo-scope"
|
||||
|
||||
- name: Import GPG key
|
||||
uses: crazy-max/ghaction-import-gpg@82a020f1f7f605c65dd2449b392a52c3fcfef7ef # v6.0.0
|
||||
uses: crazy-max/ghaction-import-gpg@01dd5d3ca463c7f10f7f4f7b4f177225ac661ee4 # v6.1.0
|
||||
with:
|
||||
gpg_private_key: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key }}
|
||||
passphrase: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key-passphrase }}
|
||||
@ -198,6 +208,13 @@ jobs:
|
||||
PR_NUMBER: ${{ steps.create-pr.outputs.pr_number }}
|
||||
run: gh pr merge $PR_NUMBER --squash --auto --delete-branch
|
||||
|
||||
- name: Report upcoming release version to Slack
|
||||
if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' && inputs.enable_slack_notification == true }}
|
||||
uses: bitwarden/gh-actions/report-upcoming-release-version@main
|
||||
with:
|
||||
version: ${{ steps.set-final-version-output.outputs.version }}
|
||||
project: ${{ github.repository }}
|
||||
AZURE_KV_CI_SERVICE_PRINCIPAL: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
|
||||
|
||||
cut_rc:
|
||||
name: Cut RC branch
|
||||
@ -206,7 +223,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Check out branch
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
with:
|
||||
ref: main
|
||||
|
||||
|
7
.gitignore
vendored
7
.gitignore
vendored
@ -205,16 +205,15 @@ mail_dist/
|
||||
src/Core/Properties/launchSettings.json
|
||||
*.override.env
|
||||
**/*.DS_Store
|
||||
src/Admin/wwwroot/lib
|
||||
src/Admin/wwwroot/css
|
||||
src/Admin/wwwroot/assets
|
||||
.vscode/*
|
||||
**/.vscode/*
|
||||
bitwarden_license/src/Sso/wwwroot/lib
|
||||
bitwarden_license/src/Sso/wwwroot/css
|
||||
bitwarden_license/src/Sso/wwwroot/assets
|
||||
.github/test/build.secrets
|
||||
**/CoverageOutput/
|
||||
.idea/*
|
||||
**/**.swp
|
||||
.mono
|
||||
|
||||
src/Admin/Admin.zip
|
||||
src/Api/Api.zip
|
||||
|
33
.vscode/tasks.json
vendored
33
.vscode/tasks.json
vendored
@ -3,6 +3,7 @@
|
||||
"tasks": [
|
||||
{
|
||||
"label": "buildIdentityApi",
|
||||
"hide": true,
|
||||
"dependsOrder": "sequence",
|
||||
"dependsOn": [
|
||||
"buildIdentity",
|
||||
@ -14,6 +15,7 @@
|
||||
},
|
||||
{
|
||||
"label": "buildIdentityApiAdmin",
|
||||
"hide": true,
|
||||
"dependsOrder": "sequence",
|
||||
"dependsOn": [
|
||||
"buildIdentity",
|
||||
@ -26,6 +28,7 @@
|
||||
},
|
||||
{
|
||||
"label": "buildFullServer",
|
||||
"hide": true,
|
||||
"dependsOrder": "sequence",
|
||||
"dependsOn": [
|
||||
"buildAdmin",
|
||||
@ -40,6 +43,7 @@
|
||||
},
|
||||
{
|
||||
"label": "buildSelfHostBit",
|
||||
"hide": true,
|
||||
"dependsOrder": "sequence",
|
||||
"dependsOn": [
|
||||
"buildAdmin",
|
||||
@ -52,6 +56,7 @@
|
||||
},
|
||||
{
|
||||
"label": "buildSelfHostOss",
|
||||
"hide": true,
|
||||
"dependsOrder": "sequence",
|
||||
"dependsOn": [
|
||||
"buildAdmin",
|
||||
@ -62,6 +67,7 @@
|
||||
},
|
||||
{
|
||||
"label": "buildIcons",
|
||||
"hide": true,
|
||||
"command": "dotnet",
|
||||
"type": "process",
|
||||
"args": [
|
||||
@ -74,6 +80,7 @@
|
||||
},
|
||||
{
|
||||
"label": "buildPortal",
|
||||
"hide": true,
|
||||
"command": "dotnet",
|
||||
"type": "process",
|
||||
"args": [
|
||||
@ -86,6 +93,7 @@
|
||||
},
|
||||
{
|
||||
"label": "buildSso",
|
||||
"hide": true,
|
||||
"command": "dotnet",
|
||||
"type": "process",
|
||||
"args": [
|
||||
@ -98,6 +106,7 @@
|
||||
},
|
||||
{
|
||||
"label": "buildEvents",
|
||||
"hide": true,
|
||||
"command": "dotnet",
|
||||
"type": "process",
|
||||
"args": [
|
||||
@ -110,6 +119,7 @@
|
||||
},
|
||||
{
|
||||
"label": "buildEventsProcessor",
|
||||
"hide": true,
|
||||
"command": "dotnet",
|
||||
"type": "process",
|
||||
"args": [
|
||||
@ -122,6 +132,7 @@
|
||||
},
|
||||
{
|
||||
"label": "buildAdmin",
|
||||
"hide": true,
|
||||
"command": "dotnet",
|
||||
"type": "process",
|
||||
"args": [
|
||||
@ -134,6 +145,7 @@
|
||||
},
|
||||
{
|
||||
"label": "buildIdentity",
|
||||
"hide": true,
|
||||
"command": "dotnet",
|
||||
"type": "process",
|
||||
"args": [
|
||||
@ -146,6 +158,7 @@
|
||||
},
|
||||
{
|
||||
"label": "buildAPI",
|
||||
"hide": true,
|
||||
"command": "dotnet",
|
||||
"type": "process",
|
||||
"args": [
|
||||
@ -162,6 +175,7 @@
|
||||
},
|
||||
{
|
||||
"label": "buildNotifications",
|
||||
"hide": true,
|
||||
"command": "dotnet",
|
||||
"type": "process",
|
||||
"args": [
|
||||
@ -178,6 +192,7 @@
|
||||
},
|
||||
{
|
||||
"label": "buildBilling",
|
||||
"hide": true,
|
||||
"command": "dotnet",
|
||||
"type": "process",
|
||||
"args": [
|
||||
@ -192,20 +207,6 @@
|
||||
"isDefault": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "clean",
|
||||
"type": "shell",
|
||||
"command": "dotnet clean",
|
||||
"presentation": {
|
||||
"echo": true,
|
||||
"reveal": "always",
|
||||
"focus": false,
|
||||
"panel": "shared",
|
||||
"showReuseMessage": true,
|
||||
"clear": false
|
||||
},
|
||||
"problemMatcher": "$msCompile"
|
||||
},
|
||||
{
|
||||
"label": "test",
|
||||
"type": "shell",
|
||||
@ -225,13 +226,15 @@
|
||||
"problemMatcher": "$msCompile"
|
||||
},
|
||||
{
|
||||
"label": "Setup Secrets",
|
||||
"label": "Set Up Secrets",
|
||||
"detail": "A task to run setup_secrets.ps1",
|
||||
"type": "shell",
|
||||
"command": "pwsh -WorkingDirectory ${workspaceFolder}/dev -Command '${workspaceFolder}/dev/setup_secrets.ps1 -clear:$${input:setupSecretsClear}'",
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Install Dev Cert",
|
||||
"detail": "A task to install the Bitwarden developer cert to run your local install as an admin.",
|
||||
"type": "shell",
|
||||
"command": "dotnet tool install -g dotnet-certificate-tool -g && certificate-tool add --file ${workspaceFolder}/dev/dev.pfx --password '${input:certPassword}'",
|
||||
"problemMatcher": []
|
||||
|
@ -3,7 +3,7 @@
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
|
||||
<Version>2024.5.0</Version>
|
||||
<Version>2024.8.1</Version>
|
||||
|
||||
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
|
@ -44,7 +44,7 @@ _These dependencies are free to use._
|
||||
|
||||
### Linux & macOS
|
||||
|
||||
```
|
||||
```sh
|
||||
curl -s -L -o bitwarden.sh \
|
||||
"https://func.bitwarden.com/api/dl/?app=self-host&platform=linux" \
|
||||
&& chmod +x bitwarden.sh
|
||||
@ -54,7 +54,7 @@ curl -s -L -o bitwarden.sh \
|
||||
|
||||
### Windows
|
||||
|
||||
```
|
||||
```cmd
|
||||
Invoke-RestMethod -OutFile bitwarden.ps1 `
|
||||
-Uri "https://func.bitwarden.com/api/dl/?app=self-host&platform=windows"
|
||||
.\bitwarden.ps1 -install
|
||||
|
@ -5,6 +5,7 @@ using Bit.Core.AdminConsole.Providers.Interfaces;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Billing.Entities;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Repositories;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
|
@ -4,15 +4,14 @@ using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.Providers.Interfaces;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Commands;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Commercial.Core.AdminConsole.Providers;
|
||||
@ -20,35 +19,35 @@ namespace Bit.Commercial.Core.AdminConsole.Providers;
|
||||
public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProviderCommand
|
||||
{
|
||||
private readonly IEventService _eventService;
|
||||
private readonly ILogger<RemoveOrganizationFromProviderCommand> _logger;
|
||||
private readonly IMailService _mailService;
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly IOrganizationService _organizationService;
|
||||
private readonly IProviderOrganizationRepository _providerOrganizationRepository;
|
||||
private readonly IStripeAdapter _stripeAdapter;
|
||||
private readonly IScaleSeatsCommand _scaleSeatsCommand;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IProviderBillingService _providerBillingService;
|
||||
private readonly ISubscriberService _subscriberService;
|
||||
|
||||
public RemoveOrganizationFromProviderCommand(
|
||||
IEventService eventService,
|
||||
ILogger<RemoveOrganizationFromProviderCommand> logger,
|
||||
IMailService mailService,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOrganizationService organizationService,
|
||||
IProviderOrganizationRepository providerOrganizationRepository,
|
||||
IStripeAdapter stripeAdapter,
|
||||
IScaleSeatsCommand scaleSeatsCommand,
|
||||
IFeatureService featureService)
|
||||
IFeatureService featureService,
|
||||
IProviderBillingService providerBillingService,
|
||||
ISubscriberService subscriberService)
|
||||
{
|
||||
_eventService = eventService;
|
||||
_logger = logger;
|
||||
_mailService = mailService;
|
||||
_organizationRepository = organizationRepository;
|
||||
_organizationService = organizationService;
|
||||
_providerOrganizationRepository = providerOrganizationRepository;
|
||||
_stripeAdapter = stripeAdapter;
|
||||
_scaleSeatsCommand = scaleSeatsCommand;
|
||||
_featureService = featureService;
|
||||
_providerBillingService = providerBillingService;
|
||||
_subscriberService = subscriberService;
|
||||
}
|
||||
|
||||
public async Task RemoveOrganizationFromProvider(
|
||||
@ -99,23 +98,19 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
|
||||
Provider provider,
|
||||
IEnumerable<string> organizationOwnerEmails)
|
||||
{
|
||||
if (!organization.IsStripeEnabled())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
|
||||
|
||||
var customerUpdateOptions = new CustomerUpdateOptions
|
||||
if (isConsolidatedBillingEnabled &&
|
||||
provider.Status == ProviderStatusType.Billable &&
|
||||
organization.Status == OrganizationStatusType.Managed &&
|
||||
!string.IsNullOrEmpty(organization.GatewayCustomerId))
|
||||
{
|
||||
Coupon = string.Empty,
|
||||
Email = organization.BillingEmail
|
||||
};
|
||||
await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, new CustomerUpdateOptions
|
||||
{
|
||||
Description = string.Empty,
|
||||
Email = organization.BillingEmail
|
||||
});
|
||||
|
||||
await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, customerUpdateOptions);
|
||||
|
||||
if (isConsolidatedBillingEnabled && provider.Status == ProviderStatusType.Billable)
|
||||
{
|
||||
var plan = StaticStore.GetPlan(organization.PlanType).PasswordManager;
|
||||
|
||||
var subscriptionCreateOptions = new SubscriptionCreateOptions
|
||||
@ -136,19 +131,31 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
|
||||
var subscription = await _stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
|
||||
|
||||
organization.GatewaySubscriptionId = subscription.Id;
|
||||
organization.Status = OrganizationStatusType.Created;
|
||||
|
||||
await _scaleSeatsCommand.ScalePasswordManagerSeats(provider, organization.PlanType,
|
||||
-(organization.Seats ?? 0));
|
||||
await _providerBillingService.ScaleSeats(provider, organization.PlanType, -organization.Seats ?? 0);
|
||||
}
|
||||
else
|
||||
else if (organization.IsStripeEnabled())
|
||||
{
|
||||
var subscriptionUpdateOptions = new SubscriptionUpdateOptions
|
||||
var subscription = await _stripeAdapter.SubscriptionGetAsync(organization.GatewaySubscriptionId);
|
||||
if (subscription.Status is StripeConstants.SubscriptionStatus.Canceled or StripeConstants.SubscriptionStatus.IncompleteExpired)
|
||||
{
|
||||
CollectionMethod = "send_invoice",
|
||||
DaysUntilDue = 30
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
await _stripeAdapter.SubscriptionUpdateAsync(organization.GatewaySubscriptionId, subscriptionUpdateOptions);
|
||||
await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, new CustomerUpdateOptions
|
||||
{
|
||||
Coupon = string.Empty,
|
||||
Email = organization.BillingEmail
|
||||
});
|
||||
|
||||
await _stripeAdapter.SubscriptionUpdateAsync(organization.GatewaySubscriptionId, new SubscriptionUpdateOptions
|
||||
{
|
||||
CollectionMethod = StripeConstants.CollectionMethod.SendInvoice,
|
||||
DaysUntilDue = 30
|
||||
});
|
||||
|
||||
await _subscriberService.RemovePaymentSource(organization);
|
||||
}
|
||||
|
||||
await _mailService.SendProviderUpdatePaymentMethod(
|
||||
|
@ -7,7 +7,9 @@ using Bit.Core.AdminConsole.Models.Business.Provider;
|
||||
using Bit.Core.AdminConsole.Models.Business.Tokenables;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
@ -44,6 +46,7 @@ public class ProviderService : IProviderService
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IDataProtectorTokenFactory<ProviderDeleteTokenable> _providerDeleteTokenDataFactory;
|
||||
private readonly IApplicationCacheService _applicationCacheService;
|
||||
private readonly IProviderBillingService _providerBillingService;
|
||||
|
||||
public ProviderService(IProviderRepository providerRepository, IProviderUserRepository providerUserRepository,
|
||||
IProviderOrganizationRepository providerOrganizationRepository, IUserRepository userRepository,
|
||||
@ -52,7 +55,7 @@ public class ProviderService : IProviderService
|
||||
IOrganizationRepository organizationRepository, GlobalSettings globalSettings,
|
||||
ICurrentContext currentContext, IStripeAdapter stripeAdapter, IFeatureService featureService,
|
||||
IDataProtectorTokenFactory<ProviderDeleteTokenable> providerDeleteTokenDataFactory,
|
||||
IApplicationCacheService applicationCacheService)
|
||||
IApplicationCacheService applicationCacheService, IProviderBillingService providerBillingService)
|
||||
{
|
||||
_providerRepository = providerRepository;
|
||||
_providerUserRepository = providerUserRepository;
|
||||
@ -70,9 +73,10 @@ public class ProviderService : IProviderService
|
||||
_featureService = featureService;
|
||||
_providerDeleteTokenDataFactory = providerDeleteTokenDataFactory;
|
||||
_applicationCacheService = applicationCacheService;
|
||||
_providerBillingService = providerBillingService;
|
||||
}
|
||||
|
||||
public async Task<Provider> CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key)
|
||||
public async Task<Provider> CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TaxInfo taxInfo = null)
|
||||
{
|
||||
var owner = await _userService.GetUserByIdAsync(ownerUserId);
|
||||
if (owner == null)
|
||||
@ -97,8 +101,24 @@ public class ProviderService : IProviderService
|
||||
throw new BadRequestException("Invalid owner.");
|
||||
}
|
||||
|
||||
provider.Status = ProviderStatusType.Created;
|
||||
await _providerRepository.UpsertAsync(provider);
|
||||
if (!_featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling))
|
||||
{
|
||||
provider.Status = ProviderStatusType.Created;
|
||||
await _providerRepository.UpsertAsync(provider);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (taxInfo == null || string.IsNullOrEmpty(taxInfo.BillingAddressCountry) || string.IsNullOrEmpty(taxInfo.BillingAddressPostalCode))
|
||||
{
|
||||
throw new BadRequestException("Both address and postal code are required to set up your provider.");
|
||||
}
|
||||
var customer = await _providerBillingService.SetupCustomer(provider, taxInfo);
|
||||
provider.GatewayCustomerId = customer.Id;
|
||||
var subscription = await _providerBillingService.SetupSubscription(provider);
|
||||
provider.GatewaySubscriptionId = subscription.Id;
|
||||
provider.Status = ProviderStatusType.Billable;
|
||||
await _providerRepository.UpsertAsync(provider);
|
||||
}
|
||||
|
||||
providerUser.Key = key;
|
||||
await _providerUserRepository.ReplaceAsync(providerUser);
|
||||
@ -435,11 +455,15 @@ public class ProviderService : IProviderService
|
||||
return;
|
||||
}
|
||||
|
||||
var subscriptionItem = await GetSubscriptionItemAsync(organization.GatewaySubscriptionId, GetStripeSeatPlanId(organization.PlanType));
|
||||
var extractedPlanType = PlanTypeMappings(organization);
|
||||
if (subscriptionItem != null)
|
||||
if (!string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId))
|
||||
{
|
||||
await UpdateSubscriptionAsync(subscriptionItem, GetStripeSeatPlanId(extractedPlanType), organization);
|
||||
var subscriptionItem = await GetSubscriptionItemAsync(organization.GatewaySubscriptionId,
|
||||
GetStripeSeatPlanId(organization.PlanType));
|
||||
var extractedPlanType = PlanTypeMappings(organization);
|
||||
if (subscriptionItem != null)
|
||||
{
|
||||
await UpdateSubscriptionAsync(subscriptionItem, GetStripeSeatPlanId(extractedPlanType), organization);
|
||||
}
|
||||
}
|
||||
|
||||
await _organizationRepository.UpsertAsync(organization);
|
||||
@ -539,9 +563,9 @@ public class ProviderService : IProviderService
|
||||
await _providerOrganizationRepository.CreateAsync(providerOrganization);
|
||||
await _eventService.LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_Created);
|
||||
|
||||
// If using Flexible Collections, give the owner Can Manage access over the default collection
|
||||
// Give the owner Can Manage access over the default collection
|
||||
// The orgUser is not available when the org is created so we have to do it here as part of the invite
|
||||
var defaultOwnerAccess = organization.FlexibleCollections && defaultCollection != null
|
||||
var defaultOwnerAccess = defaultCollection != null
|
||||
?
|
||||
[
|
||||
new CollectionAccessSelection
|
||||
@ -554,17 +578,13 @@ public class ProviderService : IProviderService
|
||||
]
|
||||
: Array.Empty<CollectionAccessSelection>();
|
||||
|
||||
await _organizationService.InviteUsersAsync(organization.Id, user.Id,
|
||||
await _organizationService.InviteUsersAsync(organization.Id, user.Id, systemUser: null,
|
||||
new (OrganizationUserInvite, string)[]
|
||||
{
|
||||
(
|
||||
new OrganizationUserInvite
|
||||
{
|
||||
Emails = new[] { clientOwnerEmail },
|
||||
|
||||
// If using Flexible Collections, AccessAll is deprecated and set to false.
|
||||
// If not using Flexible Collections, set AccessAll to true (previous behavior)
|
||||
AccessAll = !organization.FlexibleCollections,
|
||||
Type = OrganizationUserType.Owner,
|
||||
Permissions = null,
|
||||
Collections = defaultOwnerAccess,
|
||||
|
@ -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)
|
||||
};
|
||||
}
|
@ -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);
|
||||
};
|
||||
}
|
@ -4,4 +4,8 @@
|
||||
<ProjectReference Include="..\..\..\src\Core\Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CsvHelper" Version="32.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -47,6 +47,9 @@ public class SecretAuthorizationHandler : AuthorizationHandler<SecretOperationRe
|
||||
case not null when requirement == SecretOperations.Delete:
|
||||
await CanDeleteSecretAsync(context, requirement, resource);
|
||||
break;
|
||||
case not null when requirement == SecretOperations.ReadAccessPolicies:
|
||||
await CanReadAccessPoliciesAsync(context, requirement, resource);
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentException("Unsupported operation requirement type provided.", nameof(requirement));
|
||||
}
|
||||
@ -106,9 +109,9 @@ public class SecretAuthorizationHandler : AuthorizationHandler<SecretOperationRe
|
||||
{
|
||||
var (accessClient, userId) = await _accessClientQuery.GetAccessClientAsync(context.User, resource.OrganizationId);
|
||||
|
||||
// All projects should be apart of the same organization
|
||||
// All projects should be in the same organization
|
||||
if (resource.Projects != null
|
||||
&& resource.Projects.Any()
|
||||
&& resource.Projects.Count != 0
|
||||
&& !await _projectRepository.ProjectsAreInOrganization(resource.Projects.Select(p => p.Id).ToList(),
|
||||
resource.OrganizationId))
|
||||
{
|
||||
@ -152,10 +155,42 @@ public class SecretAuthorizationHandler : AuthorizationHandler<SecretOperationRe
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CanReadAccessPoliciesAsync(AuthorizationHandlerContext context,
|
||||
SecretOperationRequirement requirement, Secret resource)
|
||||
{
|
||||
var (accessClient, userId) = await _accessClientQuery.GetAccessClientAsync(context.User, resource.OrganizationId);
|
||||
|
||||
// Only users and admins can read access policies
|
||||
if (accessClient != AccessClientType.User && accessClient != AccessClientType.NoAccessCheck)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var access = await _secretRepository.AccessToSecretAsync(resource.Id, userId, accessClient);
|
||||
|
||||
if (access.Write)
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> GetAccessToUpdateSecretAsync(Secret resource, Guid userId, AccessClientType accessClient)
|
||||
{
|
||||
var newProject = resource.Projects?.FirstOrDefault();
|
||||
// Request was to remove all projects from the secret. This is not allowed for non admin users.
|
||||
if (resource.Projects?.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var access = (await _secretRepository.AccessToSecretAsync(resource.Id, userId, accessClient)).Write;
|
||||
|
||||
// No project mapping changes requested, return secret access.
|
||||
if (resource.Projects == null)
|
||||
{
|
||||
return access;
|
||||
}
|
||||
|
||||
var newProject = resource.Projects?.FirstOrDefault();
|
||||
var accessToNew = newProject != null &&
|
||||
(await _projectRepository.AccessToProjectAsync(newProject.Id, userId, accessClient))
|
||||
.Write;
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
@ -1,5 +1,7 @@
|
||||
using Bit.Core.SecretsManager.Commands.Secrets.Interfaces;
|
||||
#nullable enable
|
||||
using Bit.Core.SecretsManager.Commands.Secrets.Interfaces;
|
||||
using Bit.Core.SecretsManager.Entities;
|
||||
using Bit.Core.SecretsManager.Models.Data.AccessPolicyUpdates;
|
||||
using Bit.Core.SecretsManager.Repositories;
|
||||
|
||||
namespace Bit.Commercial.Core.SecretsManager.Commands.Secrets;
|
||||
@ -13,8 +15,8 @@ public class CreateSecretCommand : ICreateSecretCommand
|
||||
_secretRepository = secretRepository;
|
||||
}
|
||||
|
||||
public async Task<Secret> CreateAsync(Secret secret)
|
||||
public async Task<Secret> CreateAsync(Secret secret, SecretAccessPoliciesUpdates? accessPoliciesUpdates)
|
||||
{
|
||||
return await _secretRepository.CreateAsync(secret);
|
||||
return await _secretRepository.CreateAsync(secret, accessPoliciesUpdates);
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
using Bit.Core.Exceptions;
|
||||
#nullable enable
|
||||
using Bit.Core.SecretsManager.Commands.Secrets.Interfaces;
|
||||
using Bit.Core.SecretsManager.Entities;
|
||||
using Bit.Core.SecretsManager.Models.Data.AccessPolicyUpdates;
|
||||
using Bit.Core.SecretsManager.Repositories;
|
||||
|
||||
namespace Bit.Commercial.Core.SecretsManager.Commands.Secrets;
|
||||
@ -14,21 +15,8 @@ public class UpdateSecretCommand : IUpdateSecretCommand
|
||||
_secretRepository = secretRepository;
|
||||
}
|
||||
|
||||
public async Task<Secret> UpdateAsync(Secret updatedSecret)
|
||||
public async Task<Secret> UpdateAsync(Secret secret, SecretAccessPoliciesUpdates? accessPolicyUpdates)
|
||||
{
|
||||
var secret = await _secretRepository.GetByIdAsync(updatedSecret.Id);
|
||||
if (secret == null)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
secret.Key = updatedSecret.Key;
|
||||
secret.Value = updatedSecret.Value;
|
||||
secret.Note = updatedSecret.Note;
|
||||
secret.Projects = updatedSecret.Projects;
|
||||
secret.RevisionDate = DateTime.UtcNow;
|
||||
|
||||
await _secretRepository.UpdateAsync(secret);
|
||||
return secret;
|
||||
return await _secretRepository.UpdateAsync(secret, accessPolicyUpdates);
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.SecretsManager.Queries.Projects.Interfaces;
|
||||
|
@ -6,6 +6,7 @@ using Bit.Commercial.Core.SecretsManager.Commands.AccessPolicies;
|
||||
using Bit.Commercial.Core.SecretsManager.Commands.AccessTokens;
|
||||
using Bit.Commercial.Core.SecretsManager.Commands.Porting;
|
||||
using Bit.Commercial.Core.SecretsManager.Commands.Projects;
|
||||
using Bit.Commercial.Core.SecretsManager.Commands.Requests;
|
||||
using Bit.Commercial.Core.SecretsManager.Commands.Secrets;
|
||||
using Bit.Commercial.Core.SecretsManager.Commands.ServiceAccounts;
|
||||
using Bit.Commercial.Core.SecretsManager.Commands.Trash;
|
||||
@ -18,6 +19,7 @@ using Bit.Core.SecretsManager.Commands.AccessPolicies.Interfaces;
|
||||
using Bit.Core.SecretsManager.Commands.AccessTokens.Interfaces;
|
||||
using Bit.Core.SecretsManager.Commands.Porting.Interfaces;
|
||||
using Bit.Core.SecretsManager.Commands.Projects.Interfaces;
|
||||
using Bit.Core.SecretsManager.Commands.Requests.Interfaces;
|
||||
using Bit.Core.SecretsManager.Commands.Secrets.Interfaces;
|
||||
using Bit.Core.SecretsManager.Commands.ServiceAccounts.Interfaces;
|
||||
using Bit.Core.SecretsManager.Commands.Trash.Interfaces;
|
||||
@ -42,17 +44,21 @@ public static class SecretsManagerCollectionExtensions
|
||||
services.AddScoped<IAuthorizationHandler, ServiceAccountPeopleAccessPoliciesAuthorizationHandler>();
|
||||
services.AddScoped<IAuthorizationHandler, ServiceAccountGrantedPoliciesAuthorizationHandler>();
|
||||
services.AddScoped<IAuthorizationHandler, ProjectServiceAccountsAccessPoliciesAuthorizationHandler>();
|
||||
services.AddScoped<IAuthorizationHandler, SecretAccessPoliciesUpdatesAuthorizationHandler>();
|
||||
services.AddScoped<IAuthorizationHandler, BulkSecretAuthorizationHandler>();
|
||||
services.AddScoped<IAccessClientQuery, AccessClientQuery>();
|
||||
services.AddScoped<IMaxProjectsQuery, MaxProjectsQuery>();
|
||||
services.AddScoped<ISameOrganizationQuery, SameOrganizationQuery>();
|
||||
services.AddScoped<IServiceAccountSecretsDetailsQuery, ServiceAccountSecretsDetailsQuery>();
|
||||
services.AddScoped<IServiceAccountGrantedPolicyUpdatesQuery, ServiceAccountGrantedPolicyUpdatesQuery>();
|
||||
services.AddScoped<ISecretAccessPoliciesUpdatesQuery, SecretAccessPoliciesUpdatesQuery>();
|
||||
services.AddScoped<ISecretsSyncQuery, SecretsSyncQuery>();
|
||||
services.AddScoped<IProjectServiceAccountsAccessPoliciesUpdatesQuery, ProjectServiceAccountsAccessPoliciesUpdatesQuery>();
|
||||
services.AddScoped<ICreateSecretCommand, CreateSecretCommand>();
|
||||
services.AddScoped<IUpdateSecretCommand, UpdateSecretCommand>();
|
||||
services.AddScoped<IDeleteSecretCommand, DeleteSecretCommand>();
|
||||
services.AddScoped<ICreateProjectCommand, CreateProjectCommand>();
|
||||
services.AddScoped<IRequestSMAccessCommand, RequestSMAccessCommand>();
|
||||
services.AddScoped<IUpdateProjectCommand, UpdateProjectCommand>();
|
||||
services.AddScoped<IDeleteProjectCommand, DeleteProjectCommand>();
|
||||
services.AddScoped<ICreateServiceAccountCommand, CreateServiceAccountCommand>();
|
||||
|
@ -1,7 +1,9 @@
|
||||
using Bit.Commercial.Core.AdminConsole.Providers;
|
||||
using Bit.Commercial.Core.AdminConsole.Services;
|
||||
using Bit.Commercial.Core.Billing;
|
||||
using Bit.Core.AdminConsole.Providers.Interfaces;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Bit.Commercial.Core.Utilities;
|
||||
@ -13,5 +15,6 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<IProviderService, ProviderService>();
|
||||
services.AddScoped<ICreateProviderCommand, CreateProviderCommand>();
|
||||
services.AddScoped<IRemoveOrganizationFromProviderCommand, RemoveOrganizationFromProviderCommand>();
|
||||
services.AddTransient<IProviderBillingService, ProviderBillingService>();
|
||||
}
|
||||
}
|
||||
|
@ -20,7 +20,8 @@ public class AccessPolicyRepository : BaseEntityFrameworkRepository, IAccessPoli
|
||||
{
|
||||
}
|
||||
|
||||
public async Task<List<Core.SecretsManager.Entities.BaseAccessPolicy>> CreateManyAsync(List<Core.SecretsManager.Entities.BaseAccessPolicy> baseAccessPolicies)
|
||||
public async Task<List<Core.SecretsManager.Entities.BaseAccessPolicy>> CreateManyAsync(
|
||||
List<Core.SecretsManager.Entities.BaseAccessPolicy> baseAccessPolicies)
|
||||
{
|
||||
await using var scope = ServiceScopeFactory.CreateAsyncScope();
|
||||
var dbContext = GetDatabaseContext(scope);
|
||||
@ -39,6 +40,13 @@ public class AccessPolicyRepository : BaseEntityFrameworkRepository, IAccessPoli
|
||||
await dbContext.AddAsync(entity);
|
||||
break;
|
||||
}
|
||||
case Core.SecretsManager.Entities.UserSecretAccessPolicy accessPolicy:
|
||||
{
|
||||
var entity =
|
||||
Mapper.Map<UserSecretAccessPolicy>(accessPolicy);
|
||||
await dbContext.AddAsync(entity);
|
||||
break;
|
||||
}
|
||||
case Core.SecretsManager.Entities.UserServiceAccountAccessPolicy accessPolicy:
|
||||
{
|
||||
var entity =
|
||||
@ -52,6 +60,12 @@ public class AccessPolicyRepository : BaseEntityFrameworkRepository, IAccessPoli
|
||||
await dbContext.AddAsync(entity);
|
||||
break;
|
||||
}
|
||||
case Core.SecretsManager.Entities.GroupSecretAccessPolicy accessPolicy:
|
||||
{
|
||||
var entity = Mapper.Map<GroupSecretAccessPolicy>(accessPolicy);
|
||||
await dbContext.AddAsync(entity);
|
||||
break;
|
||||
}
|
||||
case Core.SecretsManager.Entities.GroupServiceAccountAccessPolicy accessPolicy:
|
||||
{
|
||||
var entity = Mapper.Map<GroupServiceAccountAccessPolicy>(accessPolicy);
|
||||
@ -65,6 +79,13 @@ public class AccessPolicyRepository : BaseEntityFrameworkRepository, IAccessPoli
|
||||
serviceAccountIds.Add(entity.ServiceAccountId!.Value);
|
||||
break;
|
||||
}
|
||||
case Core.SecretsManager.Entities.ServiceAccountSecretAccessPolicy accessPolicy:
|
||||
{
|
||||
var entity = Mapper.Map<ServiceAccountSecretAccessPolicy>(accessPolicy);
|
||||
await dbContext.AddAsync(entity);
|
||||
serviceAccountIds.Add(entity.ServiceAccountId!.Value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -395,6 +416,42 @@ public class AccessPolicyRepository : BaseEntityFrameworkRepository, IAccessPoli
|
||||
await transaction.CommitAsync();
|
||||
}
|
||||
|
||||
public async Task<SecretAccessPolicies?> GetSecretAccessPoliciesAsync(
|
||||
Guid secretId,
|
||||
Guid userId)
|
||||
{
|
||||
await using var scope = ServiceScopeFactory.CreateAsyncScope();
|
||||
var dbContext = GetDatabaseContext(scope);
|
||||
|
||||
var entities = await dbContext.AccessPolicies.Where(ap =>
|
||||
((UserSecretAccessPolicy)ap).GrantedSecretId == secretId ||
|
||||
((GroupSecretAccessPolicy)ap).GrantedSecretId == secretId ||
|
||||
((ServiceAccountSecretAccessPolicy)ap).GrantedSecretId == secretId)
|
||||
.Include(ap => ((UserSecretAccessPolicy)ap).OrganizationUser.User)
|
||||
.Include(ap => ((GroupSecretAccessPolicy)ap).Group)
|
||||
.Include(ap => ((ServiceAccountSecretAccessPolicy)ap).ServiceAccount)
|
||||
.Select(ap => new
|
||||
{
|
||||
ap,
|
||||
CurrentUserInGroup = ap is GroupSecretAccessPolicy &&
|
||||
((GroupSecretAccessPolicy)ap).Group.GroupUsers.Any(g =>
|
||||
g.OrganizationUser.UserId == userId)
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
if (entities.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var organizationId = await dbContext.Secret.Where(s => s.Id == secretId)
|
||||
.Select(s => s.OrganizationId)
|
||||
.SingleAsync();
|
||||
|
||||
return new SecretAccessPolicies(secretId, organizationId,
|
||||
entities.Select(e => MapToCore(e.ap, e.CurrentUserInGroup)).ToList());
|
||||
}
|
||||
|
||||
private static async Task UpsertPeoplePoliciesAsync(DatabaseContext dbContext,
|
||||
List<BaseAccessPolicy> policies, IReadOnlyCollection<AccessPolicy> userPolicyEntities,
|
||||
IReadOnlyCollection<AccessPolicy> groupPolicyEntities)
|
||||
@ -466,13 +523,17 @@ public class AccessPolicyRepository : BaseEntityFrameworkRepository, IAccessPoli
|
||||
baseAccessPolicyEntity switch
|
||||
{
|
||||
UserProjectAccessPolicy ap => Mapper.Map<Core.SecretsManager.Entities.UserProjectAccessPolicy>(ap),
|
||||
GroupProjectAccessPolicy ap => Mapper.Map<Core.SecretsManager.Entities.GroupProjectAccessPolicy>(ap),
|
||||
ServiceAccountProjectAccessPolicy ap => Mapper
|
||||
.Map<Core.SecretsManager.Entities.ServiceAccountProjectAccessPolicy>(ap),
|
||||
UserSecretAccessPolicy ap => Mapper.Map<Core.SecretsManager.Entities.UserSecretAccessPolicy>(ap),
|
||||
UserServiceAccountAccessPolicy ap =>
|
||||
Mapper.Map<Core.SecretsManager.Entities.UserServiceAccountAccessPolicy>(ap),
|
||||
GroupProjectAccessPolicy ap => Mapper.Map<Core.SecretsManager.Entities.GroupProjectAccessPolicy>(ap),
|
||||
GroupSecretAccessPolicy ap => Mapper.Map<Core.SecretsManager.Entities.GroupSecretAccessPolicy>(ap),
|
||||
GroupServiceAccountAccessPolicy ap => Mapper
|
||||
.Map<Core.SecretsManager.Entities.GroupServiceAccountAccessPolicy>(ap),
|
||||
ServiceAccountProjectAccessPolicy ap => Mapper
|
||||
.Map<Core.SecretsManager.Entities.ServiceAccountProjectAccessPolicy>(ap),
|
||||
ServiceAccountSecretAccessPolicy ap => Mapper
|
||||
.Map<Core.SecretsManager.Entities.ServiceAccountSecretAccessPolicy>(ap),
|
||||
_ => throw new ArgumentException("Unsupported access policy type")
|
||||
};
|
||||
|
||||
@ -482,20 +543,26 @@ public class AccessPolicyRepository : BaseEntityFrameworkRepository, IAccessPoli
|
||||
{
|
||||
Core.SecretsManager.Entities.UserProjectAccessPolicy accessPolicy => Mapper.Map<UserProjectAccessPolicy>(
|
||||
accessPolicy),
|
||||
Core.SecretsManager.Entities.UserSecretAccessPolicy accessPolicy => Mapper.Map<UserSecretAccessPolicy>(
|
||||
accessPolicy),
|
||||
Core.SecretsManager.Entities.UserServiceAccountAccessPolicy accessPolicy => Mapper
|
||||
.Map<UserServiceAccountAccessPolicy>(accessPolicy),
|
||||
Core.SecretsManager.Entities.GroupProjectAccessPolicy accessPolicy => Mapper.Map<GroupProjectAccessPolicy>(
|
||||
accessPolicy),
|
||||
Core.SecretsManager.Entities.GroupSecretAccessPolicy accessPolicy => Mapper.Map<GroupSecretAccessPolicy>(
|
||||
accessPolicy),
|
||||
Core.SecretsManager.Entities.GroupServiceAccountAccessPolicy accessPolicy => Mapper
|
||||
.Map<GroupServiceAccountAccessPolicy>(accessPolicy),
|
||||
Core.SecretsManager.Entities.ServiceAccountProjectAccessPolicy accessPolicy => Mapper
|
||||
.Map<ServiceAccountProjectAccessPolicy>(accessPolicy),
|
||||
Core.SecretsManager.Entities.ServiceAccountSecretAccessPolicy accessPolicy => Mapper
|
||||
.Map<ServiceAccountSecretAccessPolicy>(accessPolicy),
|
||||
_ => throw new ArgumentException("Unsupported access policy type")
|
||||
};
|
||||
}
|
||||
|
||||
private Core.SecretsManager.Entities.BaseAccessPolicy MapToCore(
|
||||
BaseAccessPolicy baseAccessPolicyEntity, bool currentUserInGroup)
|
||||
BaseAccessPolicy baseAccessPolicyEntity, bool currentUserInGroup)
|
||||
{
|
||||
switch (baseAccessPolicyEntity)
|
||||
{
|
||||
@ -505,6 +572,12 @@ public class AccessPolicyRepository : BaseEntityFrameworkRepository, IAccessPoli
|
||||
mapped.CurrentUserInGroup = currentUserInGroup;
|
||||
return mapped;
|
||||
}
|
||||
case GroupSecretAccessPolicy ap:
|
||||
{
|
||||
var mapped = Mapper.Map<Core.SecretsManager.Entities.GroupSecretAccessPolicy>(ap);
|
||||
mapped.CurrentUserInGroup = currentUserInGroup;
|
||||
return mapped;
|
||||
}
|
||||
case GroupServiceAccountAccessPolicy ap:
|
||||
{
|
||||
var mapped = Mapper.Map<Core.SecretsManager.Entities.GroupServiceAccountAccessPolicy>(ap);
|
||||
|
@ -16,7 +16,7 @@ public class ProjectRepository : Repository<Core.SecretsManager.Entities.Project
|
||||
: base(serviceScopeFactory, mapper, db => db.Project)
|
||||
{ }
|
||||
|
||||
public override async Task<Core.SecretsManager.Entities.Project> GetByIdAsync(Guid id)
|
||||
public override async Task<Core.SecretsManager.Entities.Project?> GetByIdAsync(Guid id)
|
||||
{
|
||||
using (var scope = ServiceScopeFactory.CreateScope())
|
||||
{
|
||||
@ -169,6 +169,58 @@ public class ProjectRepository : Repository<Core.SecretsManager.Entities.Project
|
||||
return await accessQuery.ToDictionaryAsync(pa => pa.Id, pa => (pa.Read, pa.Write));
|
||||
}
|
||||
|
||||
public async Task<int> GetProjectCountByOrganizationIdAsync(Guid organizationId, Guid userId,
|
||||
AccessClientType accessType)
|
||||
{
|
||||
await using var scope = ServiceScopeFactory.CreateAsyncScope();
|
||||
var dbContext = GetDatabaseContext(scope);
|
||||
var query = dbContext.Project.Where(p => p.OrganizationId == organizationId && p.DeletedDate == null);
|
||||
|
||||
query = accessType switch
|
||||
{
|
||||
AccessClientType.NoAccessCheck => query,
|
||||
AccessClientType.User => query.Where(UserHasReadAccessToProject(userId)),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(accessType), accessType, null),
|
||||
};
|
||||
|
||||
return await query.CountAsync();
|
||||
}
|
||||
|
||||
public async Task<ProjectCounts> GetProjectCountsByIdAsync(Guid projectId, Guid userId, AccessClientType accessType)
|
||||
{
|
||||
await using var scope = ServiceScopeFactory.CreateAsyncScope();
|
||||
var dbContext = GetDatabaseContext(scope);
|
||||
var query = dbContext.Project.Where(p => p.Id == projectId && p.DeletedDate == null);
|
||||
|
||||
var queryReadAccess = accessType switch
|
||||
{
|
||||
AccessClientType.NoAccessCheck => query,
|
||||
AccessClientType.User => query.Where(UserHasReadAccessToProject(userId)),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(accessType), accessType, null),
|
||||
};
|
||||
|
||||
var queryWriteAccess = accessType switch
|
||||
{
|
||||
AccessClientType.NoAccessCheck => query,
|
||||
AccessClientType.User => query.Where(UserHasWriteAccessToProject(userId)),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(accessType), accessType, null),
|
||||
};
|
||||
|
||||
var secretsQuery = queryReadAccess.Select(project => project.Secrets.Count(s => s.DeletedDate == null));
|
||||
|
||||
var projectCountsQuery = queryWriteAccess.Select(project => new ProjectCounts
|
||||
{
|
||||
People = project.UserAccessPolicies.Count + project.GroupAccessPolicies.Count,
|
||||
ServiceAccounts = project.ServiceAccountAccessPolicies.Count
|
||||
});
|
||||
|
||||
var secrets = await secretsQuery.FirstOrDefaultAsync();
|
||||
var projectCounts = await projectCountsQuery.FirstOrDefaultAsync() ?? new ProjectCounts { Secrets = 0, People = 0, ServiceAccounts = 0 };
|
||||
projectCounts.Secrets = secrets;
|
||||
|
||||
return projectCounts;
|
||||
}
|
||||
|
||||
private record ProjectAccess(Guid Id, bool Read, bool Write);
|
||||
|
||||
private static IQueryable<ProjectAccess> BuildProjectAccessQuery(IQueryable<Project> projectQuery, Guid userId,
|
||||
|
@ -1,7 +1,9 @@
|
||||
using System.Linq.Expressions;
|
||||
using AutoMapper;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.SecretsManager.Enums.AccessPolicies;
|
||||
using Bit.Core.SecretsManager.Models.Data;
|
||||
using Bit.Core.SecretsManager.Models.Data.AccessPolicyUpdates;
|
||||
using Bit.Core.SecretsManager.Repositories;
|
||||
using Bit.Infrastructure.EntityFramework;
|
||||
using Bit.Infrastructure.EntityFramework.Repositories;
|
||||
@ -17,7 +19,7 @@ public class SecretRepository : Repository<Core.SecretsManager.Entities.Secret,
|
||||
: base(serviceScopeFactory, mapper, db => db.Secret)
|
||||
{ }
|
||||
|
||||
public override async Task<Core.SecretsManager.Entities.Secret> GetByIdAsync(Guid id)
|
||||
public override async Task<Core.SecretsManager.Entities.Secret?> GetByIdAsync(Guid id)
|
||||
{
|
||||
using (var scope = ServiceScopeFactory.CreateScope())
|
||||
{
|
||||
@ -136,8 +138,8 @@ public class SecretRepository : Repository<Core.SecretsManager.Entities.Secret,
|
||||
return await secrets.ToListAsync();
|
||||
}
|
||||
|
||||
public override async Task<Core.SecretsManager.Entities.Secret> CreateAsync(
|
||||
Core.SecretsManager.Entities.Secret secret)
|
||||
public async Task<Core.SecretsManager.Entities.Secret> CreateAsync(
|
||||
Core.SecretsManager.Entities.Secret secret, SecretAccessPoliciesUpdates? accessPoliciesUpdates = null)
|
||||
{
|
||||
await using var scope = ServiceScopeFactory.CreateAsyncScope();
|
||||
var dbContext = GetDatabaseContext(scope);
|
||||
@ -158,13 +160,14 @@ public class SecretRepository : Repository<Core.SecretsManager.Entities.Secret,
|
||||
}
|
||||
|
||||
await dbContext.AddAsync(entity);
|
||||
await UpdateSecretAccessPoliciesAsync(dbContext, entity, accessPoliciesUpdates);
|
||||
await dbContext.SaveChangesAsync();
|
||||
await transaction.CommitAsync();
|
||||
secret.Id = entity.Id;
|
||||
return secret;
|
||||
}
|
||||
|
||||
public async Task<Core.SecretsManager.Entities.Secret> UpdateAsync(Core.SecretsManager.Entities.Secret secret)
|
||||
public async Task<Core.SecretsManager.Entities.Secret> UpdateAsync(Core.SecretsManager.Entities.Secret secret,
|
||||
SecretAccessPoliciesUpdates? accessPoliciesUpdates = null)
|
||||
{
|
||||
await using var scope = ServiceScopeFactory.CreateAsyncScope();
|
||||
var dbContext = GetDatabaseContext(scope);
|
||||
@ -173,36 +176,30 @@ public class SecretRepository : Repository<Core.SecretsManager.Entities.Secret,
|
||||
|
||||
var entity = await dbContext.Secret
|
||||
.Include(s => s.Projects)
|
||||
.Include(s => s.UserAccessPolicies)
|
||||
.Include(s => s.GroupAccessPolicies)
|
||||
.Include(s => s.ServiceAccountAccessPolicies)
|
||||
.FirstAsync(s => s.Id == secret.Id);
|
||||
|
||||
var projectsToRemove = entity.Projects.Where(p => mappedEntity.Projects.All(mp => mp.Id != p.Id)).ToList();
|
||||
var projectsToAdd = mappedEntity.Projects.Where(p => entity.Projects.All(ep => ep.Id != p.Id)).ToList();
|
||||
dbContext.Entry(entity).CurrentValues.SetValues(mappedEntity);
|
||||
|
||||
foreach (var p in projectsToRemove)
|
||||
if (secret.Projects != null)
|
||||
{
|
||||
entity.Projects.Remove(p);
|
||||
entity = await UpdateProjectMappingAsync(dbContext, entity, mappedEntity);
|
||||
}
|
||||
|
||||
foreach (var project in projectsToAdd)
|
||||
if (accessPoliciesUpdates != null)
|
||||
{
|
||||
var p = dbContext.AttachToOrGet<Project>(x => x.Id == project.Id, () => project);
|
||||
entity.Projects.Add(p);
|
||||
}
|
||||
|
||||
var projectIds = projectsToRemove.Select(p => p.Id).Concat(projectsToAdd.Select(p => p.Id)).ToList();
|
||||
if (projectIds.Count > 0)
|
||||
{
|
||||
await UpdateServiceAccountRevisionsByProjectIdsAsync(dbContext, projectIds);
|
||||
await UpdateSecretAccessPoliciesAsync(dbContext, entity, accessPoliciesUpdates);
|
||||
}
|
||||
|
||||
await UpdateServiceAccountRevisionsBySecretIdsAsync(dbContext, [entity.Id]);
|
||||
dbContext.Entry(entity).CurrentValues.SetValues(mappedEntity);
|
||||
await dbContext.SaveChangesAsync();
|
||||
await transaction.CommitAsync();
|
||||
|
||||
return secret;
|
||||
return Mapper.Map<Core.SecretsManager.Entities.Secret>(entity);
|
||||
}
|
||||
|
||||
|
||||
public async Task SoftDeleteManyByIdAsync(IEnumerable<Guid> ids)
|
||||
{
|
||||
await using var scope = ServiceScopeFactory.CreateAsyncScope();
|
||||
@ -289,41 +286,35 @@ public class SecretRepository : Repository<Core.SecretsManager.Entities.Secret,
|
||||
|
||||
public async Task<(bool Read, bool Write)> AccessToSecretAsync(Guid id, Guid userId, AccessClientType accessType)
|
||||
{
|
||||
using var scope = ServiceScopeFactory.CreateScope();
|
||||
await using var scope = ServiceScopeFactory.CreateAsyncScope();
|
||||
var dbContext = GetDatabaseContext(scope);
|
||||
|
||||
var secret = dbContext.Secret
|
||||
.Where(s => s.Id == id);
|
||||
|
||||
var query = accessType switch
|
||||
{
|
||||
AccessClientType.NoAccessCheck => secret.Select(_ => new { Read = true, Write = true }),
|
||||
AccessClientType.User => secret.Select(s => new
|
||||
{
|
||||
Read = s.Projects.Any(p =>
|
||||
p.UserAccessPolicies.Any(ap => ap.OrganizationUser.User.Id == userId && ap.Read) ||
|
||||
p.GroupAccessPolicies.Any(ap =>
|
||||
ap.Group.GroupUsers.Any(gu => gu.OrganizationUser.User.Id == userId && ap.Read))),
|
||||
Write = s.Projects.Any(p =>
|
||||
p.UserAccessPolicies.Any(ap => ap.OrganizationUser.User.Id == userId && ap.Write) ||
|
||||
p.GroupAccessPolicies.Any(ap =>
|
||||
ap.Group.GroupUsers.Any(gu => gu.OrganizationUser.User.Id == userId && ap.Write))),
|
||||
}),
|
||||
AccessClientType.ServiceAccount => secret.Select(s => new
|
||||
{
|
||||
Read = s.Projects.Any(p =>
|
||||
p.ServiceAccountAccessPolicies.Any(ap => ap.ServiceAccountId == userId && ap.Read)),
|
||||
Write = s.Projects.Any(p =>
|
||||
p.ServiceAccountAccessPolicies.Any(ap => ap.ServiceAccountId == userId && ap.Write)),
|
||||
}),
|
||||
_ => secret.Select(_ => new { Read = false, Write = false }),
|
||||
};
|
||||
var query = BuildSecretAccessQuery(secret, userId, accessType);
|
||||
|
||||
var policy = await query.FirstOrDefaultAsync();
|
||||
|
||||
return policy == null ? (false, false) : (policy.Read, policy.Write);
|
||||
}
|
||||
|
||||
public async Task<Dictionary<Guid, (bool Read, bool Write)>> AccessToSecretsAsync(
|
||||
IEnumerable<Guid> ids,
|
||||
Guid userId,
|
||||
AccessClientType accessType)
|
||||
{
|
||||
await using var scope = ServiceScopeFactory.CreateAsyncScope();
|
||||
var dbContext = GetDatabaseContext(scope);
|
||||
|
||||
var secrets = dbContext.Secret
|
||||
.Where(s => ids.Contains(s.Id));
|
||||
|
||||
var accessQuery = BuildSecretAccessQuery(secrets, userId, accessType);
|
||||
|
||||
return await accessQuery.ToDictionaryAsync(sa => sa.Id, sa => (sa.Read, sa.Write));
|
||||
}
|
||||
|
||||
public async Task EmptyTrash(DateTime currentDate, uint deleteAfterThisNumberOfDays)
|
||||
{
|
||||
using var scope = ServiceScopeFactory.CreateScope();
|
||||
@ -334,6 +325,23 @@ public class SecretRepository : Repository<Core.SecretsManager.Entities.Secret,
|
||||
await dbContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task<int> GetSecretsCountByOrganizationIdAsync(Guid organizationId, Guid userId,
|
||||
AccessClientType accessType)
|
||||
{
|
||||
await using var scope = ServiceScopeFactory.CreateAsyncScope();
|
||||
var dbContext = GetDatabaseContext(scope);
|
||||
var query = dbContext.Secret.Where(s => s.OrganizationId == organizationId && s.DeletedDate == null);
|
||||
|
||||
query = accessType switch
|
||||
{
|
||||
AccessClientType.NoAccessCheck => query,
|
||||
AccessClientType.User => query.Where(UserHasReadAccessToSecret(userId)),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(accessType), accessType, null),
|
||||
};
|
||||
|
||||
return await query.CountAsync();
|
||||
}
|
||||
|
||||
private IQueryable<SecretPermissionDetails> SecretToPermissionDetails(IQueryable<Secret> query, Guid userId, AccessClientType accessType)
|
||||
{
|
||||
var secrets = accessType switch
|
||||
@ -361,19 +369,27 @@ public class SecretRepository : Repository<Core.SecretsManager.Entities.Secret,
|
||||
private Expression<Func<Secret, SecretPermissionDetails>> SecretToPermissionsUser(Guid userId, bool read) =>
|
||||
s => new SecretPermissionDetails
|
||||
{
|
||||
Secret = Mapper.Map<Bit.Core.SecretsManager.Entities.Secret>(s),
|
||||
Secret = Mapper.Map<Core.SecretsManager.Entities.Secret>(s),
|
||||
Read = read,
|
||||
Write = s.Projects.Any(p =>
|
||||
p.UserAccessPolicies.Any(ap => ap.OrganizationUser.User.Id == userId && ap.Write) ||
|
||||
p.GroupAccessPolicies.Any(ap =>
|
||||
ap.Group.GroupUsers.Any(gu => gu.OrganizationUser.User.Id == userId && ap.Write))),
|
||||
Write =
|
||||
s.UserAccessPolicies.Any(ap => ap.OrganizationUser.User.Id == userId && ap.Write) ||
|
||||
s.GroupAccessPolicies.Any(ap =>
|
||||
ap.Group.GroupUsers.Any(gu => gu.OrganizationUser.User.Id == userId && ap.Write)) ||
|
||||
s.Projects.Any(p =>
|
||||
p.UserAccessPolicies.Any(ap => ap.OrganizationUser.User.Id == userId && ap.Write) ||
|
||||
p.GroupAccessPolicies.Any(ap =>
|
||||
ap.Group.GroupUsers.Any(gu => gu.OrganizationUser.User.Id == userId && ap.Write)))
|
||||
};
|
||||
|
||||
private static Expression<Func<Secret, bool>> ServiceAccountHasReadAccessToSecret(Guid serviceAccountId) => s =>
|
||||
s.ServiceAccountAccessPolicies.Any(ap => ap.ServiceAccountId == serviceAccountId && ap.Read) ||
|
||||
s.Projects.Any(p =>
|
||||
p.ServiceAccountAccessPolicies.Any(ap => ap.ServiceAccount.Id == serviceAccountId && ap.Read));
|
||||
|
||||
private static Expression<Func<Secret, bool>> UserHasReadAccessToSecret(Guid userId) => s =>
|
||||
s.UserAccessPolicies.Any(ap => ap.OrganizationUser.UserId == userId && ap.Read) ||
|
||||
s.GroupAccessPolicies.Any(ap =>
|
||||
ap.Group.GroupUsers.Any(gu => gu.OrganizationUser.UserId == userId && ap.Read)) ||
|
||||
s.Projects.Any(p =>
|
||||
p.UserAccessPolicies.Any(ap => ap.OrganizationUser.UserId == userId && ap.Read) ||
|
||||
p.GroupAccessPolicies.Any(ap =>
|
||||
@ -434,4 +450,197 @@ public class SecretRepository : Repository<Core.SecretsManager.Entities.Secret,
|
||||
.ExecuteUpdateAsync(setters => setters.SetProperty(b => b.RevisionDate, utcNow));
|
||||
}
|
||||
}
|
||||
|
||||
private static IQueryable<SecretAccess> BuildSecretAccessQuery(IQueryable<Secret> secrets, Guid accessClientId,
|
||||
AccessClientType accessType) =>
|
||||
accessType switch
|
||||
{
|
||||
AccessClientType.NoAccessCheck => secrets.Select(s => new SecretAccess(s.Id, true, true)),
|
||||
AccessClientType.User => secrets.Select(s => new SecretAccess(
|
||||
s.Id,
|
||||
s.UserAccessPolicies.Any(ap => ap.OrganizationUser.User.Id == accessClientId && ap.Read) ||
|
||||
s.GroupAccessPolicies.Any(ap =>
|
||||
ap.Group.GroupUsers.Any(gu => gu.OrganizationUser.User.Id == accessClientId && ap.Read)) ||
|
||||
s.Projects.Any(p =>
|
||||
p.UserAccessPolicies.Any(ap => ap.OrganizationUser.User.Id == accessClientId && ap.Read) ||
|
||||
p.GroupAccessPolicies.Any(ap =>
|
||||
ap.Group.GroupUsers.Any(gu => gu.OrganizationUser.User.Id == accessClientId && ap.Read))),
|
||||
s.UserAccessPolicies.Any(ap => ap.OrganizationUser.User.Id == accessClientId && ap.Write) ||
|
||||
s.GroupAccessPolicies.Any(ap =>
|
||||
ap.Group.GroupUsers.Any(gu => gu.OrganizationUser.User.Id == accessClientId && ap.Write)) ||
|
||||
s.Projects.Any(p =>
|
||||
p.UserAccessPolicies.Any(ap => ap.OrganizationUser.User.Id == accessClientId && ap.Write) ||
|
||||
p.GroupAccessPolicies.Any(ap =>
|
||||
ap.Group.GroupUsers.Any(gu => gu.OrganizationUser.User.Id == accessClientId && ap.Write)))
|
||||
)),
|
||||
AccessClientType.ServiceAccount => secrets.Select(s => new SecretAccess(
|
||||
s.Id,
|
||||
s.ServiceAccountAccessPolicies.Any(ap => ap.ServiceAccountId == accessClientId && ap.Read) ||
|
||||
s.Projects.Any(p =>
|
||||
p.ServiceAccountAccessPolicies.Any(ap => ap.ServiceAccountId == accessClientId && ap.Read)),
|
||||
s.ServiceAccountAccessPolicies.Any(ap => ap.ServiceAccountId == accessClientId && ap.Write) ||
|
||||
s.Projects.Any(p =>
|
||||
p.ServiceAccountAccessPolicies.Any(ap => ap.ServiceAccountId == accessClientId && ap.Write))
|
||||
)),
|
||||
_ => secrets.Select(s => new SecretAccess(s.Id, false, false))
|
||||
};
|
||||
|
||||
private static async Task<Secret> UpdateProjectMappingAsync(DatabaseContext dbContext, Secret currentEntity, Secret updatedEntity)
|
||||
{
|
||||
var projectsToRemove = currentEntity.Projects.Where(p => updatedEntity.Projects.All(mp => mp.Id != p.Id)).ToList();
|
||||
var projectsToAdd = updatedEntity.Projects.Where(p => currentEntity.Projects.All(ep => ep.Id != p.Id)).ToList();
|
||||
|
||||
foreach (var p in projectsToRemove)
|
||||
{
|
||||
currentEntity.Projects.Remove(p);
|
||||
}
|
||||
|
||||
foreach (var project in projectsToAdd)
|
||||
{
|
||||
var p = dbContext.AttachToOrGet<Project>(x => x.Id == project.Id, () => project);
|
||||
currentEntity.Projects.Add(p);
|
||||
}
|
||||
|
||||
var projectIds = projectsToRemove.Select(p => p.Id).Concat(projectsToAdd.Select(p => p.Id)).ToList();
|
||||
if (projectIds.Count > 0)
|
||||
{
|
||||
await UpdateServiceAccountRevisionsByProjectIdsAsync(dbContext, projectIds);
|
||||
}
|
||||
|
||||
return currentEntity;
|
||||
}
|
||||
|
||||
private static async Task DeleteSecretAccessPoliciesAsync(DatabaseContext dbContext, Secret entity,
|
||||
SecretAccessPoliciesUpdates accessPoliciesUpdates)
|
||||
{
|
||||
var userAccessPoliciesIdsToDelete = entity.UserAccessPolicies.Where(uap => accessPoliciesUpdates
|
||||
.UserAccessPolicyUpdates
|
||||
.Any(apu => apu.Operation == AccessPolicyOperation.Delete &&
|
||||
apu.AccessPolicy.OrganizationUserId == uap.OrganizationUserId))
|
||||
.Select(uap => uap.Id)
|
||||
.ToList();
|
||||
|
||||
var groupAccessPoliciesIdsToDelete = entity.GroupAccessPolicies.Where(gap => accessPoliciesUpdates
|
||||
.GroupAccessPolicyUpdates
|
||||
.Any(apu => apu.Operation == AccessPolicyOperation.Delete && apu.AccessPolicy.GroupId == gap.GroupId))
|
||||
.Select(gap => gap.Id)
|
||||
.ToList();
|
||||
|
||||
var serviceAccountAccessPoliciesIdsToDelete = entity.ServiceAccountAccessPolicies.Where(gap =>
|
||||
accessPoliciesUpdates.ServiceAccountAccessPolicyUpdates
|
||||
.Any(apu => apu.Operation == AccessPolicyOperation.Delete &&
|
||||
apu.AccessPolicy.ServiceAccountId == gap.ServiceAccountId))
|
||||
.Select(sap => sap.Id)
|
||||
.ToList();
|
||||
|
||||
var accessPoliciesIdsToDelete = userAccessPoliciesIdsToDelete
|
||||
.Concat(groupAccessPoliciesIdsToDelete)
|
||||
.Concat(serviceAccountAccessPoliciesIdsToDelete)
|
||||
.ToList();
|
||||
|
||||
await dbContext.AccessPolicies
|
||||
.Where(ap => accessPoliciesIdsToDelete.Contains(ap.Id))
|
||||
.ExecuteDeleteAsync();
|
||||
}
|
||||
|
||||
private static async Task UpsertSecretAccessPolicyAsync(DatabaseContext dbContext, BaseAccessPolicy updatedEntity,
|
||||
AccessPolicyOperation accessPolicyOperation, AccessPolicy? currentEntity, DateTime currentDate)
|
||||
{
|
||||
switch (accessPolicyOperation)
|
||||
{
|
||||
case AccessPolicyOperation.Create when currentEntity == null:
|
||||
updatedEntity.SetNewId();
|
||||
await dbContext.AddAsync(updatedEntity);
|
||||
break;
|
||||
|
||||
case AccessPolicyOperation.Update when currentEntity != null:
|
||||
dbContext.AccessPolicies.Attach(currentEntity);
|
||||
currentEntity.Read = updatedEntity.Read;
|
||||
currentEntity.Write = updatedEntity.Write;
|
||||
currentEntity.RevisionDate = currentDate;
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException("Policy updates failed due to unexpected state.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UpsertSecretAccessPoliciesAsync(DatabaseContext dbContext,
|
||||
Secret entity,
|
||||
SecretAccessPoliciesUpdates policyUpdates)
|
||||
{
|
||||
var currentDate = DateTime.UtcNow;
|
||||
|
||||
foreach (var policyUpdate in policyUpdates.UserAccessPolicyUpdates.Where(apu =>
|
||||
apu.Operation != AccessPolicyOperation.Delete))
|
||||
{
|
||||
var currentEntity = entity.UserAccessPolicies?.FirstOrDefault(e =>
|
||||
e.OrganizationUserId == policyUpdate.AccessPolicy.OrganizationUserId!.Value);
|
||||
|
||||
await UpsertSecretAccessPolicyAsync(dbContext, MapToEntity(policyUpdate.AccessPolicy),
|
||||
policyUpdate.Operation,
|
||||
currentEntity,
|
||||
currentDate);
|
||||
}
|
||||
|
||||
foreach (var policyUpdate in policyUpdates.GroupAccessPolicyUpdates.Where(apu =>
|
||||
apu.Operation != AccessPolicyOperation.Delete))
|
||||
{
|
||||
var currentEntity = entity.GroupAccessPolicies?.FirstOrDefault(e =>
|
||||
e.GroupId == policyUpdate.AccessPolicy.GroupId!.Value);
|
||||
|
||||
await UpsertSecretAccessPolicyAsync(dbContext, MapToEntity(policyUpdate.AccessPolicy),
|
||||
policyUpdate.Operation,
|
||||
currentEntity,
|
||||
currentDate);
|
||||
}
|
||||
|
||||
foreach (var policyUpdate in policyUpdates.ServiceAccountAccessPolicyUpdates.Where(apu =>
|
||||
apu.Operation != AccessPolicyOperation.Delete))
|
||||
{
|
||||
var currentEntity = entity.ServiceAccountAccessPolicies?.FirstOrDefault(e =>
|
||||
e.ServiceAccountId == policyUpdate.AccessPolicy.ServiceAccountId!.Value);
|
||||
|
||||
await UpsertSecretAccessPolicyAsync(dbContext, MapToEntity(policyUpdate.AccessPolicy),
|
||||
policyUpdate.Operation,
|
||||
currentEntity,
|
||||
currentDate);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UpdateSecretAccessPoliciesAsync(DatabaseContext dbContext,
|
||||
Secret entity,
|
||||
SecretAccessPoliciesUpdates? accessPoliciesUpdates)
|
||||
{
|
||||
if (accessPoliciesUpdates == null || !accessPoliciesUpdates.HasUpdates())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if ((entity.UserAccessPolicies != null && entity.UserAccessPolicies.Count != 0) ||
|
||||
(entity.GroupAccessPolicies != null && entity.GroupAccessPolicies.Count != 0) ||
|
||||
(entity.ServiceAccountAccessPolicies != null && entity.ServiceAccountAccessPolicies.Count != 0))
|
||||
{
|
||||
await DeleteSecretAccessPoliciesAsync(dbContext, entity, accessPoliciesUpdates);
|
||||
}
|
||||
|
||||
await UpsertSecretAccessPoliciesAsync(dbContext, entity, accessPoliciesUpdates);
|
||||
|
||||
await UpdateServiceAccountRevisionsAsync(dbContext,
|
||||
accessPoliciesUpdates.ServiceAccountAccessPolicyUpdates
|
||||
.Select(sap => sap.AccessPolicy.ServiceAccountId!.Value).ToList());
|
||||
}
|
||||
|
||||
private BaseAccessPolicy MapToEntity(Core.SecretsManager.Entities.BaseAccessPolicy baseAccessPolicy) =>
|
||||
baseAccessPolicy switch
|
||||
{
|
||||
Core.SecretsManager.Entities.UserSecretAccessPolicy accessPolicy => Mapper.Map<UserSecretAccessPolicy>(
|
||||
accessPolicy),
|
||||
Core.SecretsManager.Entities.GroupSecretAccessPolicy accessPolicy => Mapper.Map<GroupSecretAccessPolicy>(
|
||||
accessPolicy),
|
||||
Core.SecretsManager.Entities.ServiceAccountSecretAccessPolicy accessPolicy => Mapper
|
||||
.Map<ServiceAccountSecretAccessPolicy>(accessPolicy),
|
||||
_ => throw new ArgumentException("Unsupported access policy type")
|
||||
};
|
||||
|
||||
private record SecretAccess(Guid Id, bool Read, bool Write);
|
||||
}
|
||||
|
@ -125,6 +125,48 @@ public class ServiceAccountRepository : Repository<Core.SecretsManager.Entities.
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<int> GetServiceAccountCountByOrganizationIdAsync(Guid organizationId, Guid userId,
|
||||
AccessClientType accessType)
|
||||
{
|
||||
await using var scope = ServiceScopeFactory.CreateAsyncScope();
|
||||
var dbContext = GetDatabaseContext(scope);
|
||||
var query = dbContext.ServiceAccount.Where(sa => sa.OrganizationId == organizationId);
|
||||
|
||||
query = accessType switch
|
||||
{
|
||||
AccessClientType.NoAccessCheck => query,
|
||||
AccessClientType.User => query.Where(UserHasReadAccessToServiceAccount(userId)),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(accessType), accessType, null),
|
||||
};
|
||||
|
||||
return await query.CountAsync();
|
||||
}
|
||||
|
||||
public async Task<ServiceAccountCounts> GetServiceAccountCountsByIdAsync(Guid serviceAccountId, Guid userId,
|
||||
AccessClientType accessType)
|
||||
{
|
||||
await using var scope = ServiceScopeFactory.CreateAsyncScope();
|
||||
var dbContext = GetDatabaseContext(scope);
|
||||
var query = dbContext.ServiceAccount.Where(sa => sa.Id == serviceAccountId);
|
||||
|
||||
query = accessType switch
|
||||
{
|
||||
AccessClientType.NoAccessCheck => query,
|
||||
AccessClientType.User => query.Where(UserHasReadAccessToServiceAccount(userId)),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(accessType), accessType, null),
|
||||
};
|
||||
|
||||
var serviceAccountCountsQuery = query.Select(serviceAccount => new ServiceAccountCounts
|
||||
{
|
||||
Projects = serviceAccount.ProjectAccessPolicies.Count,
|
||||
People = serviceAccount.UserAccessPolicies.Count + serviceAccount.GroupAccessPolicies.Count,
|
||||
AccessTokens = serviceAccount.ApiKeys.Count
|
||||
});
|
||||
|
||||
var serviceAccountCounts = await serviceAccountCountsQuery.FirstOrDefaultAsync();
|
||||
return serviceAccountCounts ?? new ServiceAccountCounts { Projects = 0, People = 0, AccessTokens = 0 };
|
||||
}
|
||||
|
||||
public async Task<bool> ServiceAccountsAreInOrganizationAsync(List<Guid> serviceAccountIds, Guid organizationId)
|
||||
{
|
||||
await using var scope = ServiceScopeFactory.CreateAsyncScope();
|
||||
@ -135,38 +177,37 @@ public class ServiceAccountRepository : Repository<Core.SecretsManager.Entities.
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<ServiceAccountSecretsDetails>> GetManyByOrganizationIdWithSecretsDetailsAsync(
|
||||
Guid organizationId, Guid userId, AccessClientType accessType)
|
||||
Guid organizationId, Guid userId, AccessClientType accessType)
|
||||
{
|
||||
using var scope = ServiceScopeFactory.CreateScope();
|
||||
await using var scope = ServiceScopeFactory.CreateAsyncScope();
|
||||
var dbContext = GetDatabaseContext(scope);
|
||||
var query = from sa in dbContext.ServiceAccount
|
||||
join ap in dbContext.ServiceAccountProjectAccessPolicy
|
||||
on sa.Id equals ap.ServiceAccountId into grouping
|
||||
from ap in grouping.DefaultIfEmpty()
|
||||
where sa.OrganizationId == organizationId
|
||||
select new
|
||||
{
|
||||
ServiceAccount = sa,
|
||||
AccessToSecrets = ap.GrantedProject.Secrets.Count(s => s.DeletedDate == null)
|
||||
};
|
||||
|
||||
query = accessType switch
|
||||
var serviceAccountQuery = dbContext.ServiceAccount.Where(c => c.OrganizationId == organizationId);
|
||||
serviceAccountQuery = accessType switch
|
||||
{
|
||||
AccessClientType.NoAccessCheck => query,
|
||||
AccessClientType.User => query.Where(c =>
|
||||
c.ServiceAccount.UserAccessPolicies.Any(ap => ap.OrganizationUser.User.Id == userId && ap.Read) ||
|
||||
c.ServiceAccount.GroupAccessPolicies.Any(ap =>
|
||||
AccessClientType.NoAccessCheck => serviceAccountQuery,
|
||||
AccessClientType.User => serviceAccountQuery.Where(c =>
|
||||
c.UserAccessPolicies.Any(ap => ap.OrganizationUser.User.Id == userId && ap.Read) ||
|
||||
c.GroupAccessPolicies.Any(ap =>
|
||||
ap.Group.GroupUsers.Any(gu => gu.OrganizationUser.User.Id == userId && ap.Read))),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(accessType), accessType, null),
|
||||
};
|
||||
|
||||
var results = (await query.ToListAsync())
|
||||
var projectSecretsAccessQuery = BuildProjectSecretsAccessQuery(dbContext, serviceAccountQuery);
|
||||
var directSecretAccessQuery = BuildDirectSecretAccessQuery(dbContext, serviceAccountQuery);
|
||||
|
||||
var projectSecretsAccessResults = await projectSecretsAccessQuery.ToListAsync();
|
||||
var directSecretAccessResults = await directSecretAccessQuery.ToListAsync();
|
||||
|
||||
var applicableDirectSecretAccessResults = FilterDirectSecretAccessResults(projectSecretsAccessResults, directSecretAccessResults);
|
||||
|
||||
var results = projectSecretsAccessResults.Concat(applicableDirectSecretAccessResults)
|
||||
.GroupBy(g => g.ServiceAccount)
|
||||
.Select(g =>
|
||||
new ServiceAccountSecretsDetails
|
||||
{
|
||||
ServiceAccount = Mapper.Map<Core.SecretsManager.Entities.ServiceAccount>(g.Key),
|
||||
AccessToSecrets = g.Sum(x => x.AccessToSecrets),
|
||||
AccessToSecrets = g.Sum(x => x.SecretIds.Count())
|
||||
}).OrderBy(c => c.ServiceAccount.RevisionDate).ToList();
|
||||
|
||||
return results;
|
||||
@ -200,4 +241,46 @@ public class ServiceAccountRepository : Repository<Core.SecretsManager.Entities.
|
||||
private static Expression<Func<ServiceAccount, bool>> UserHasWriteAccessToServiceAccount(Guid userId) => sa =>
|
||||
sa.UserAccessPolicies.Any(ap => ap.OrganizationUser.User.Id == userId && ap.Write) ||
|
||||
sa.GroupAccessPolicies.Any(ap => ap.Group.GroupUsers.Any(gu => gu.OrganizationUser.User.Id == userId && ap.Write));
|
||||
|
||||
private static IQueryable<ServiceAccountSecretsAccess> BuildProjectSecretsAccessQuery(DatabaseContext dbContext,
|
||||
IQueryable<ServiceAccount> serviceAccountQuery) =>
|
||||
from sa in serviceAccountQuery
|
||||
join ap in dbContext.ServiceAccountProjectAccessPolicy
|
||||
on sa.Id equals ap.ServiceAccountId into grouping
|
||||
from ap in grouping.DefaultIfEmpty()
|
||||
select new ServiceAccountSecretsAccess
|
||||
(
|
||||
sa, ap.GrantedProject.Secrets.Where(s => s.DeletedDate == null).Select(s => s.Id)
|
||||
);
|
||||
|
||||
private static IQueryable<ServiceAccountSecretsAccess> BuildDirectSecretAccessQuery(
|
||||
DatabaseContext dbContext,
|
||||
IQueryable<ServiceAccount> serviceAccountQuery) =>
|
||||
from sa in serviceAccountQuery
|
||||
join ap in dbContext.ServiceAccountSecretAccessPolicy
|
||||
on sa.Id equals ap.ServiceAccountId into grouping
|
||||
from ap in grouping.DefaultIfEmpty()
|
||||
where ap.GrantedSecret.DeletedDate == null &&
|
||||
ap.GrantedSecretId != null
|
||||
select new ServiceAccountSecretsAccess(sa,
|
||||
new List<Guid> { ap.GrantedSecretId!.Value });
|
||||
|
||||
private static List<ServiceAccountSecretsAccess> FilterDirectSecretAccessResults(
|
||||
List<ServiceAccountSecretsAccess> projectSecretsAccessResults,
|
||||
List<ServiceAccountSecretsAccess> directSecretAccessResults) =>
|
||||
directSecretAccessResults.Where(directSecretAccessResult =>
|
||||
{
|
||||
var serviceAccountId = directSecretAccessResult.ServiceAccount.Id;
|
||||
var secretId = directSecretAccessResult.SecretIds.FirstOrDefault();
|
||||
if (secretId == Guid.Empty)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return !projectSecretsAccessResults
|
||||
.Where(x => x.ServiceAccount.Id == serviceAccountId)
|
||||
.Any(x => x.SecretIds.Contains(secretId));
|
||||
}).ToList();
|
||||
|
||||
private record ServiceAccountSecretsAccess(ServiceAccount ServiceAccount, IEnumerable<Guid> SecretIds);
|
||||
}
|
||||
|
@ -1,8 +1,66 @@
|
||||
namespace Bit.Scim.Models;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Business;
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Scim.Models;
|
||||
|
||||
public class ScimUserRequestModel : BaseScimUserModel
|
||||
{
|
||||
public ScimUserRequestModel()
|
||||
: base(false)
|
||||
{ }
|
||||
|
||||
public OrganizationUserInvite ToOrganizationUserInvite(ScimProviderType scimProvider)
|
||||
{
|
||||
return new OrganizationUserInvite
|
||||
{
|
||||
Emails = new[] { EmailForInvite(scimProvider) },
|
||||
|
||||
// Permissions cannot be set via SCIM so we use default values
|
||||
Type = OrganizationUserType.User,
|
||||
Collections = new List<CollectionAccessSelection>(),
|
||||
Groups = new List<Guid>()
|
||||
};
|
||||
}
|
||||
|
||||
private string EmailForInvite(ScimProviderType scimProvider)
|
||||
{
|
||||
var email = PrimaryEmail?.ToLowerInvariant();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(email))
|
||||
{
|
||||
return email;
|
||||
}
|
||||
|
||||
switch (scimProvider)
|
||||
{
|
||||
case ScimProviderType.AzureAd:
|
||||
return UserName?.ToLowerInvariant();
|
||||
default:
|
||||
email = WorkEmail?.ToLowerInvariant();
|
||||
if (string.IsNullOrWhiteSpace(email))
|
||||
{
|
||||
email = Emails?.FirstOrDefault()?.Value?.ToLowerInvariant();
|
||||
}
|
||||
|
||||
return email;
|
||||
}
|
||||
}
|
||||
|
||||
public string ExternalIdForInvite()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(ExternalId))
|
||||
{
|
||||
return ExternalId;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(UserName))
|
||||
{
|
||||
return UserName;
|
||||
}
|
||||
|
||||
return CoreHelpers.RandomString(15);
|
||||
}
|
||||
}
|
||||
|
@ -1,11 +1,8 @@
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Scim.Context;
|
||||
using Bit.Scim.Models;
|
||||
using Bit.Scim.Users.Interfaces;
|
||||
@ -14,39 +11,33 @@ namespace Bit.Scim.Users;
|
||||
|
||||
public class PostUserCommand : IPostUserCommand
|
||||
{
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly IOrganizationUserRepository _organizationUserRepository;
|
||||
private readonly IOrganizationService _organizationService;
|
||||
private readonly IPaymentService _paymentService;
|
||||
private readonly IScimContext _scimContext;
|
||||
|
||||
public PostUserCommand(
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IOrganizationService organizationService,
|
||||
IPaymentService paymentService,
|
||||
IScimContext scimContext)
|
||||
{
|
||||
_organizationRepository = organizationRepository;
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
_organizationService = organizationService;
|
||||
_paymentService = paymentService;
|
||||
_scimContext = scimContext;
|
||||
}
|
||||
|
||||
public async Task<OrganizationUserUserDetails> PostUserAsync(Guid organizationId, ScimUserRequestModel model)
|
||||
{
|
||||
var email = model.PrimaryEmail?.ToLowerInvariant();
|
||||
if (string.IsNullOrWhiteSpace(email))
|
||||
{
|
||||
switch (_scimContext.RequestScimProvider)
|
||||
{
|
||||
case ScimProviderType.AzureAd:
|
||||
email = model.UserName?.ToLowerInvariant();
|
||||
break;
|
||||
default:
|
||||
email = model.WorkEmail?.ToLowerInvariant();
|
||||
if (string.IsNullOrWhiteSpace(email))
|
||||
{
|
||||
email = model.Emails?.FirstOrDefault()?.Value?.ToLowerInvariant();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
var scimProvider = _scimContext.RequestScimProvider;
|
||||
var invite = model.ToOrganizationUserInvite(scimProvider);
|
||||
|
||||
var email = invite.Emails.Single();
|
||||
var externalId = model.ExternalIdForInvite();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(email) || !model.Active)
|
||||
{
|
||||
@ -60,28 +51,18 @@ public class PostUserCommand : IPostUserCommand
|
||||
throw new ConflictException();
|
||||
}
|
||||
|
||||
string externalId = null;
|
||||
if (!string.IsNullOrWhiteSpace(model.ExternalId))
|
||||
{
|
||||
externalId = model.ExternalId;
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(model.UserName))
|
||||
{
|
||||
externalId = model.UserName;
|
||||
}
|
||||
else
|
||||
{
|
||||
externalId = CoreHelpers.RandomString(15);
|
||||
}
|
||||
|
||||
var orgUserByExternalId = orgUsers.FirstOrDefault(ou => ou.ExternalId == externalId);
|
||||
if (orgUserByExternalId != null)
|
||||
{
|
||||
throw new ConflictException();
|
||||
}
|
||||
|
||||
var invitedOrgUser = await _organizationService.InviteUserAsync(organizationId, EventSystemUser.SCIM, email,
|
||||
OrganizationUserType.User, false, externalId, new List<CollectionAccessSelection>(), new List<Guid>());
|
||||
var organization = await _organizationRepository.GetByIdAsync(organizationId);
|
||||
var hasStandaloneSecretsManager = await _paymentService.HasSecretsManagerStandalone(organization);
|
||||
invite.AccessSecretsManager = hasStandaloneSecretsManager;
|
||||
|
||||
var invitedOrgUser = await _organizationService.InviteUserAsync(organizationId, invitingUserId: null, EventSystemUser.SCIM,
|
||||
invite, externalId);
|
||||
var orgUser = await _organizationUserRepository.GetDetailsByIdAsync(invitedOrgUser.Id);
|
||||
|
||||
return orgUser;
|
||||
|
@ -8,6 +8,7 @@ using Bit.Core.Auth.Models;
|
||||
using Bit.Core.Auth.Models.Business.Tokenables;
|
||||
using Bit.Core.Auth.Models.Data;
|
||||
using Bit.Core.Auth.Repositories;
|
||||
using Bit.Core.Auth.UserFeatures.Registration;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Api;
|
||||
@ -51,6 +52,7 @@ public class AccountController : Controller
|
||||
private readonly Core.Services.IEventService _eventService;
|
||||
private readonly IDataProtectorTokenFactory<SsoTokenable> _dataProtector;
|
||||
private readonly IOrganizationDomainRepository _organizationDomainRepository;
|
||||
private readonly IRegisterUserCommand _registerUserCommand;
|
||||
|
||||
public AccountController(
|
||||
IAuthenticationSchemeProvider schemeProvider,
|
||||
@ -70,7 +72,8 @@ public class AccountController : Controller
|
||||
IGlobalSettings globalSettings,
|
||||
Core.Services.IEventService eventService,
|
||||
IDataProtectorTokenFactory<SsoTokenable> dataProtector,
|
||||
IOrganizationDomainRepository organizationDomainRepository)
|
||||
IOrganizationDomainRepository organizationDomainRepository,
|
||||
IRegisterUserCommand registerUserCommand)
|
||||
{
|
||||
_schemeProvider = schemeProvider;
|
||||
_clientStore = clientStore;
|
||||
@ -90,6 +93,7 @@ public class AccountController : Controller
|
||||
_globalSettings = globalSettings;
|
||||
_dataProtector = dataProtector;
|
||||
_organizationDomainRepository = organizationDomainRepository;
|
||||
_registerUserCommand = registerUserCommand;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
@ -483,7 +487,8 @@ public class AccountController : Controller
|
||||
if (orgUser.Status == OrganizationUserStatusType.Invited)
|
||||
{
|
||||
// Org User is invited - they must manually accept the invite via email and authenticate with MP
|
||||
throw new Exception(_i18nService.T("UserAlreadyInvited", email, organization.DisplayName()));
|
||||
// This allows us to enroll them in MP reset if required
|
||||
throw new Exception(_i18nService.T("AcceptInviteBeforeUsingSSO", organization.DisplayName()));
|
||||
}
|
||||
|
||||
// Accepted or Confirmed - create SSO link and return;
|
||||
@ -497,7 +502,6 @@ public class AccountController : Controller
|
||||
var occupiedSeats = await _organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
|
||||
var initialSeatCount = organization.Seats.Value;
|
||||
var availableSeats = initialSeatCount - occupiedSeats;
|
||||
var prorationDate = DateTime.UtcNow;
|
||||
if (availableSeats < 1)
|
||||
{
|
||||
try
|
||||
@ -507,13 +511,13 @@ public class AccountController : Controller
|
||||
throw new Exception("Cannot autoscale on self-hosted instance.");
|
||||
}
|
||||
|
||||
await _organizationService.AutoAddSeatsAsync(organization, 1, prorationDate);
|
||||
await _organizationService.AutoAddSeatsAsync(organization, 1);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
if (organization.Seats.Value != initialSeatCount)
|
||||
{
|
||||
await _organizationService.AdjustSeatsAsync(orgId, initialSeatCount - organization.Seats.Value, prorationDate);
|
||||
await _organizationService.AdjustSeatsAsync(orgId, initialSeatCount - organization.Seats.Value);
|
||||
}
|
||||
_logger.LogInformation(e, "SSO auto provisioning failed");
|
||||
throw new Exception(_i18nService.T("NoSeatsAvailable", organization.DisplayName()));
|
||||
@ -538,7 +542,7 @@ public class AccountController : Controller
|
||||
EmailVerified = emailVerified,
|
||||
ApiKey = CoreHelpers.SecureRandomString(30)
|
||||
};
|
||||
await _userService.RegisterUserAsync(user);
|
||||
await _registerUserCommand.RegisterUser(user);
|
||||
|
||||
// If the organization has 2fa policy enabled, make sure to default jit user 2fa to email
|
||||
var twoFactorPolicy =
|
||||
|
@ -10,7 +10,7 @@
|
||||
<!-- This is a transitive dependency to Sustainsys.Saml2.AspNetCore2 -->
|
||||
<PackageReference Include="Microsoft.AspNetCore.Http" Version="2.2.2" />
|
||||
|
||||
<PackageReference Include="Sustainsys.Saml2.AspNetCore2" Version="2.9.2" />
|
||||
<PackageReference Include="Sustainsys.Saml2.AspNetCore2" Version="2.10.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
@ -7,15 +7,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>@ViewData["Title"] - SSO</title>
|
||||
|
||||
<link rel="stylesheet" href="~/css/webfonts.css" />
|
||||
<environment include="Development">
|
||||
<link rel="stylesheet" href="~/lib/font-awesome/css/font-awesome.css" />
|
||||
<link rel="stylesheet" href="~/css/site.css" />
|
||||
</environment>
|
||||
<environment exclude="Development">
|
||||
<link rel="stylesheet" href="~/lib/font-awesome/css/font-awesome.min.css" asp-append-version="true" />
|
||||
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
|
||||
</environment>
|
||||
<link rel="stylesheet" href="~/assets/site.css" asp-append-version="true" />
|
||||
@RenderSection("Head", required: false)
|
||||
</head>
|
||||
<body>
|
||||
@ -43,18 +35,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<environment include="Development">
|
||||
<script src="~/lib/jquery/jquery.slim.js"></script>
|
||||
<script src="~/lib/popper/popper.js"></script>
|
||||
<script src="~/lib/bootstrap/js/bootstrap.js"></script>
|
||||
</environment>
|
||||
<environment exclude="Development">
|
||||
<script src="~/lib/jquery/jquery.slim.min.js" asp-append-version="true"></script>
|
||||
<script src="~/lib/popper/popper.min.js" asp-append-version="true"></script>
|
||||
<script src="~/lib/bootstrap/js/bootstrap.min.js" asp-append-version="true"></script>
|
||||
</environment>
|
||||
|
||||
<script src="~/js/site.js" asp-append-version="true"></script>
|
||||
<script src="~/assets/site.js" asp-append-version="true"></script>
|
||||
@RenderSection("Scripts", required: false)
|
||||
</body>
|
||||
</html>
|
||||
|
@ -1,71 +0,0 @@
|
||||
/// <binding BeforeBuild='build' Clean='clean' ProjectOpened='build' />
|
||||
|
||||
const gulp = require('gulp');
|
||||
const merge = require('merge-stream');
|
||||
const sass = require('gulp-sass')(require("sass"));
|
||||
const del = require('del');
|
||||
|
||||
const paths = {};
|
||||
paths.webroot = './wwwroot/';
|
||||
paths.npmDir = './node_modules/';
|
||||
paths.sassDir = './Sass/';
|
||||
paths.libDir = paths.webroot + 'lib/';
|
||||
paths.cssDir = paths.webroot + 'css/';
|
||||
paths.jsDir = paths.webroot + 'js/';
|
||||
|
||||
paths.sass = paths.sassDir + '**/*.scss';
|
||||
paths.minCss = paths.cssDir + '**/*.min.css';
|
||||
paths.js = paths.jsDir + '**/*.js';
|
||||
paths.minJs = paths.jsDir + '**/*.min.js';
|
||||
paths.libJs = paths.libDir + '**/*.js';
|
||||
paths.libMinJs = paths.libDir + '**/*.min.js';
|
||||
|
||||
function clean() {
|
||||
return del([paths.minJs, paths.cssDir, paths.libDir]);
|
||||
}
|
||||
|
||||
function lib() {
|
||||
const libs = [
|
||||
{
|
||||
src: paths.npmDir + 'bootstrap/dist/js/*',
|
||||
dest: paths.libDir + 'bootstrap/js'
|
||||
},
|
||||
{
|
||||
src: paths.npmDir + 'popper.js/dist/umd/*',
|
||||
dest: paths.libDir + 'popper'
|
||||
},
|
||||
{
|
||||
src: paths.npmDir + 'font-awesome/css/*',
|
||||
dest: paths.libDir + 'font-awesome/css'
|
||||
},
|
||||
{
|
||||
src: paths.npmDir + 'font-awesome/fonts/*',
|
||||
dest: paths.libDir + 'font-awesome/fonts'
|
||||
},
|
||||
{
|
||||
src: paths.npmDir + 'jquery/dist/jquery.slim*',
|
||||
dest: paths.libDir + 'jquery'
|
||||
},
|
||||
];
|
||||
|
||||
const tasks = libs.map((lib) => {
|
||||
return gulp.src(lib.src).pipe(gulp.dest(lib.dest));
|
||||
});
|
||||
return merge(tasks);
|
||||
}
|
||||
|
||||
function runSass() {
|
||||
return gulp.src(paths.sass)
|
||||
.pipe(sass({ outputStyle: 'compressed' }).on('error', sass.logError))
|
||||
.pipe(gulp.dest(paths.cssDir));
|
||||
}
|
||||
|
||||
function sassWatch() {
|
||||
gulp.watch(paths.sass, runSass);
|
||||
}
|
||||
|
||||
exports.build = gulp.series(clean, gulp.parallel([lib, runSass]));
|
||||
exports['sass:watch'] = sassWatch;
|
||||
exports.sass = runSass;
|
||||
exports.lib = lib;
|
||||
exports.clean = clean;
|
6764
bitwarden_license/src/Sso/package-lock.json
generated
6764
bitwarden_license/src/Sso/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -5,17 +5,21 @@
|
||||
"repository": "https://github.com/bitwarden/enterprise",
|
||||
"license": "-",
|
||||
"scripts": {
|
||||
"build": "gulp build"
|
||||
"build": "webpack"
|
||||
},
|
||||
"dependencies": {
|
||||
"bootstrap": "5.3.3",
|
||||
"font-awesome": "4.7.0",
|
||||
"jquery": "3.7.1",
|
||||
"popper.js": "1.16.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"bootstrap": "4.6.2",
|
||||
"del": "6.1.1",
|
||||
"font-awesome": "4.7.0",
|
||||
"gulp": "4.0.2",
|
||||
"gulp-sass": "5.1.0",
|
||||
"jquery": "3.7.1",
|
||||
"merge-stream": "2.0.0",
|
||||
"popper.js": "1.16.1",
|
||||
"sass": "1.75.0"
|
||||
"css-loader": "7.1.2",
|
||||
"expose-loader": "5.0.0",
|
||||
"mini-css-extract-plugin": "2.9.0",
|
||||
"sass": "1.77.8",
|
||||
"sass-loader": "16.0.0",
|
||||
"webpack": "5.94.0",
|
||||
"webpack-cli": "5.1.4"
|
||||
}
|
||||
}
|
||||
|
57
bitwarden_license/src/Sso/webpack.config.js
Normal file
57
bitwarden_license/src/Sso/webpack.config.js
Normal file
@ -0,0 +1,57 @@
|
||||
const path = require("path");
|
||||
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
|
||||
|
||||
const paths = {
|
||||
assets: "./wwwroot/assets/",
|
||||
sassDir: "./Sass/",
|
||||
};
|
||||
|
||||
/** @type {import("webpack").Configuration} */
|
||||
module.exports = {
|
||||
mode: "production",
|
||||
devtool: "source-map",
|
||||
entry: {
|
||||
site: [
|
||||
path.resolve(__dirname, paths.sassDir, "site.scss"),
|
||||
|
||||
"popper.js",
|
||||
"bootstrap",
|
||||
"jquery",
|
||||
"font-awesome/css/font-awesome.css",
|
||||
],
|
||||
},
|
||||
output: {
|
||||
clean: true,
|
||||
path: path.resolve(__dirname, paths.assets),
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.(sa|sc|c)ss$/,
|
||||
use: [MiniCssExtractPlugin.loader, "css-loader", "sass-loader"],
|
||||
},
|
||||
{
|
||||
test: /.(ttf|otf|eot|svg|woff(2)?)(\?[a-z0-9]+)?$/,
|
||||
exclude: /loading(|-white).svg/,
|
||||
generator: {
|
||||
filename: "fonts/[name].[contenthash][ext]",
|
||||
},
|
||||
type: "asset/resource",
|
||||
},
|
||||
|
||||
// Expose jquery globally so they can be used directly in asp.net
|
||||
{
|
||||
test: require.resolve("jquery"),
|
||||
loader: "expose-loader",
|
||||
options: {
|
||||
exposes: ["$", "jQuery"],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
plugins: [
|
||||
new MiniCssExtractPlugin({
|
||||
filename: "[name].css",
|
||||
}),
|
||||
],
|
||||
};
|
@ -1,4 +0,0 @@
|
||||
// Please see documentation at https://docs.microsoft.com/aspnet/core/client-side/bundling-and-minification
|
||||
// for details on configuring this project to bundle and minify static web assets.
|
||||
|
||||
// Write your JavaScript code.
|
5
bitwarden_license/test/Bitwarden.License.Tests.proj
Normal file
5
bitwarden_license/test/Bitwarden.License.Tests.proj
Normal file
@ -0,0 +1,5 @@
|
||||
<Project Sdk="Microsoft.Build.Traversal">
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="**\*.*proj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
@ -4,17 +4,19 @@ using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Commands;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using NSubstitute;
|
||||
using Stripe;
|
||||
using Xunit;
|
||||
using IMailService = Bit.Core.Services.IMailService;
|
||||
|
||||
namespace Bit.Commercial.Core.Test.AdminConsole.ProviderFeatures;
|
||||
|
||||
@ -74,9 +76,9 @@ public class RemoveOrganizationFromProviderCommandTests
|
||||
providerOrganization.ProviderId = provider.Id;
|
||||
|
||||
sutProvider.GetDependency<IOrganizationService>().HasConfirmedOwnersExceptAsync(
|
||||
providerOrganization.OrganizationId,
|
||||
Array.Empty<Guid>(),
|
||||
includeProvider: false)
|
||||
providerOrganization.OrganizationId,
|
||||
[],
|
||||
includeProvider: false)
|
||||
.Returns(false);
|
||||
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization));
|
||||
@ -85,56 +87,53 @@ public class RemoveOrganizationFromProviderCommandTests
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task RemoveOrganizationFromProvider_NoStripeObjects_MakesCorrectInvocations(
|
||||
public async Task RemoveOrganizationFromProvider_OrganizationNotStripeEnabled_MakesCorrectInvocations(
|
||||
Provider provider,
|
||||
ProviderOrganization providerOrganization,
|
||||
Organization organization,
|
||||
SutProvider<RemoveOrganizationFromProviderCommand> sutProvider)
|
||||
{
|
||||
providerOrganization.ProviderId = provider.Id;
|
||||
|
||||
organization.GatewayCustomerId = null;
|
||||
organization.GatewaySubscriptionId = null;
|
||||
|
||||
providerOrganization.ProviderId = provider.Id;
|
||||
|
||||
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
|
||||
|
||||
sutProvider.GetDependency<IOrganizationService>().HasConfirmedOwnersExceptAsync(
|
||||
providerOrganization.OrganizationId,
|
||||
Array.Empty<Guid>(),
|
||||
[],
|
||||
includeProvider: false)
|
||||
.Returns(true);
|
||||
|
||||
var organizationOwnerEmails = new List<string> { "a@gmail.com", "b@gmail.com" };
|
||||
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
|
||||
|
||||
organizationRepository.GetOwnerEmailAddressesById(organization.Id).Returns(organizationOwnerEmails);
|
||||
organizationRepository.GetOwnerEmailAddressesById(organization.Id).Returns([
|
||||
"a@example.com",
|
||||
"b@example.com"
|
||||
]);
|
||||
|
||||
await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization);
|
||||
|
||||
await organizationRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(
|
||||
org => org.Id == organization.Id && org.BillingEmail == "a@gmail.com"));
|
||||
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
|
||||
await stripeAdapter.DidNotReceiveWithAnyArgs().CustomerUpdateAsync(Arg.Any<string>(), Arg.Any<CustomerUpdateOptions>());
|
||||
|
||||
await stripeAdapter.DidNotReceiveWithAnyArgs().SubscriptionUpdateAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>());
|
||||
|
||||
await sutProvider.GetDependency<IMailService>().DidNotReceiveWithAnyArgs().SendProviderUpdatePaymentMethod(
|
||||
Arg.Any<Guid>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<IEnumerable<string>>());
|
||||
await organizationRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(org => org.BillingEmail == "a@example.com"));
|
||||
|
||||
await sutProvider.GetDependency<IProviderOrganizationRepository>().Received(1)
|
||||
.DeleteAsync(providerOrganization);
|
||||
|
||||
await sutProvider.GetDependency<IEventService>().Received(1).LogProviderOrganizationEventAsync(
|
||||
providerOrganization,
|
||||
EventType.ProviderOrganization_Removed);
|
||||
await sutProvider.GetDependency<IEventService>().Received(1)
|
||||
.LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_Removed);
|
||||
|
||||
await sutProvider.GetDependency<IMailService>().Received(1)
|
||||
.SendProviderUpdatePaymentMethod(
|
||||
organization.Id,
|
||||
organization.Name,
|
||||
provider.Name,
|
||||
Arg.Is<IEnumerable<string>>(emails => emails.FirstOrDefault() == "a@example.com"));
|
||||
|
||||
await sutProvider.GetDependency<IStripeAdapter>().DidNotReceiveWithAnyArgs()
|
||||
.CustomerUpdateAsync(Arg.Any<string>(), Arg.Any<CustomerUpdateOptions>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task RemoveOrganizationFromProvider_MakesCorrectInvocations__FeatureFlagOff(
|
||||
public async Task RemoveOrganizationFromProvider_OrganizationStripeEnabled_NonConsolidatedBilling_MakesCorrectInvocations(
|
||||
Provider provider,
|
||||
ProviderOrganization providerOrganization,
|
||||
Organization organization,
|
||||
@ -142,104 +141,150 @@ public class RemoveOrganizationFromProviderCommandTests
|
||||
{
|
||||
providerOrganization.ProviderId = provider.Id;
|
||||
|
||||
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
|
||||
|
||||
sutProvider.GetDependency<IOrganizationService>().HasConfirmedOwnersExceptAsync(
|
||||
providerOrganization.OrganizationId,
|
||||
Array.Empty<Guid>(),
|
||||
[],
|
||||
includeProvider: false)
|
||||
.Returns(true);
|
||||
|
||||
var organizationOwnerEmails = new List<string> { "a@example.com", "b@example.com" };
|
||||
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
|
||||
|
||||
organizationRepository.GetOwnerEmailAddressesById(organization.Id).Returns(organizationOwnerEmails);
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
stripeAdapter.SubscriptionCreateAsync(default).ReturnsForAnyArgs(new Stripe.Subscription
|
||||
{
|
||||
Id = "S-1",
|
||||
CurrentPeriodEnd = DateTime.Today.AddDays(10),
|
||||
});
|
||||
organizationRepository.GetOwnerEmailAddressesById(organization.Id).Returns([
|
||||
"a@example.com",
|
||||
"b@example.com"
|
||||
]);
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling)
|
||||
.Returns(false);
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>().SubscriptionGetAsync(organization.GatewaySubscriptionId)
|
||||
.Returns(GetSubscription(organization.GatewaySubscriptionId));
|
||||
|
||||
await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization);
|
||||
|
||||
await organizationRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(
|
||||
org => org.Id == organization.Id && org.BillingEmail == "a@example.com"));
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
|
||||
await stripeAdapter.Received(1).CustomerUpdateAsync(
|
||||
organization.GatewayCustomerId, Arg.Is<CustomerUpdateOptions>(
|
||||
options => options.Coupon == string.Empty && options.Email == "a@example.com"));
|
||||
await stripeAdapter.Received(1).CustomerUpdateAsync(organization.GatewayCustomerId,
|
||||
Arg.Is<CustomerUpdateOptions>(options =>
|
||||
options.Coupon == string.Empty && options.Email == "a@example.com"));
|
||||
|
||||
await sutProvider.GetDependency<IMailService>().Received(1).SendProviderUpdatePaymentMethod(
|
||||
organization.Id,
|
||||
organization.Name,
|
||||
provider.Name,
|
||||
Arg.Is<IEnumerable<string>>(emails => emails.Contains("a@example.com") && emails.Contains("b@example.com")));
|
||||
await stripeAdapter.Received(1).SubscriptionUpdateAsync(organization.GatewaySubscriptionId,
|
||||
Arg.Is<SubscriptionUpdateOptions>(options =>
|
||||
options.CollectionMethod == StripeConstants.CollectionMethod.SendInvoice &&
|
||||
options.DaysUntilDue == 30));
|
||||
|
||||
await sutProvider.GetDependency<ISubscriberService>().Received(1).RemovePaymentSource(organization);
|
||||
|
||||
await organizationRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(org => org.BillingEmail == "a@example.com"));
|
||||
|
||||
await sutProvider.GetDependency<IProviderOrganizationRepository>().Received(1)
|
||||
.DeleteAsync(providerOrganization);
|
||||
|
||||
await sutProvider.GetDependency<IEventService>().Received(1).LogProviderOrganizationEventAsync(
|
||||
providerOrganization,
|
||||
EventType.ProviderOrganization_Removed);
|
||||
await sutProvider.GetDependency<IEventService>().Received(1)
|
||||
.LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_Removed);
|
||||
|
||||
await sutProvider.GetDependency<IMailService>().Received(1)
|
||||
.SendProviderUpdatePaymentMethod(
|
||||
organization.Id,
|
||||
organization.Name,
|
||||
provider.Name,
|
||||
Arg.Is<IEnumerable<string>>(emails => emails.FirstOrDefault() == "a@example.com"));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task RemoveOrganizationFromProvider_CreatesSubscriptionAndScalesSeats_FeatureFlagON(Provider provider,
|
||||
public async Task RemoveOrganizationFromProvider_OrganizationStripeEnabled_ConsolidatedBilling_MakesCorrectInvocations(
|
||||
Provider provider,
|
||||
ProviderOrganization providerOrganization,
|
||||
Organization organization,
|
||||
SutProvider<RemoveOrganizationFromProviderCommand> sutProvider)
|
||||
{
|
||||
providerOrganization.ProviderId = provider.Id;
|
||||
provider.Status = ProviderStatusType.Billable;
|
||||
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
|
||||
|
||||
providerOrganization.ProviderId = provider.Id;
|
||||
|
||||
organization.Status = OrganizationStatusType.Managed;
|
||||
|
||||
organization.PlanType = PlanType.TeamsMonthly;
|
||||
|
||||
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationService>().HasConfirmedOwnersExceptAsync(
|
||||
providerOrganization.OrganizationId,
|
||||
Array.Empty<Guid>(),
|
||||
[],
|
||||
includeProvider: false)
|
||||
.Returns(true);
|
||||
|
||||
var organizationOwnerEmails = new List<string> { "a@example.com", "b@example.com" };
|
||||
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
|
||||
|
||||
organizationRepository.GetOwnerEmailAddressesById(organization.Id).Returns(organizationOwnerEmails);
|
||||
organizationRepository.GetOwnerEmailAddressesById(organization.Id).Returns([
|
||||
"a@example.com",
|
||||
"b@example.com"
|
||||
]);
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling)
|
||||
.Returns(true);
|
||||
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
stripeAdapter.SubscriptionCreateAsync(default).ReturnsForAnyArgs(new Stripe.Subscription
|
||||
|
||||
stripeAdapter.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(new Subscription
|
||||
{
|
||||
Id = "S-1",
|
||||
CurrentPeriodEnd = DateTime.Today.AddDays(10),
|
||||
Id = "subscription_id"
|
||||
});
|
||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling).Returns(true);
|
||||
|
||||
await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization);
|
||||
await stripeAdapter.Received(1).CustomerUpdateAsync(
|
||||
organization.GatewayCustomerId, Arg.Is<CustomerUpdateOptions>(
|
||||
options => options.Coupon == string.Empty && options.Email == "a@example.com"));
|
||||
|
||||
await stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(c =>
|
||||
c.Customer == organization.GatewayCustomerId &&
|
||||
c.CollectionMethod == "send_invoice" &&
|
||||
c.DaysUntilDue == 30 &&
|
||||
c.Items.Count == 1
|
||||
));
|
||||
await stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(options =>
|
||||
options.Customer == organization.GatewayCustomerId &&
|
||||
options.CollectionMethod == StripeConstants.CollectionMethod.SendInvoice &&
|
||||
options.DaysUntilDue == 30 &&
|
||||
options.AutomaticTax.Enabled == true &&
|
||||
options.Metadata["organizationId"] == organization.Id.ToString() &&
|
||||
options.OffSession == true &&
|
||||
options.ProrationBehavior == StripeConstants.ProrationBehavior.CreateProrations &&
|
||||
options.Items.First().Price == teamsMonthlyPlan.PasswordManager.StripeSeatPlanId &&
|
||||
options.Items.First().Quantity == organization.Seats));
|
||||
|
||||
await sutProvider.GetDependency<IScaleSeatsCommand>().Received(1)
|
||||
.ScalePasswordManagerSeats(provider, organization.PlanType, -(int)organization.Seats);
|
||||
await sutProvider.GetDependency<IProviderBillingService>().Received(1)
|
||||
.ScaleSeats(provider, organization.PlanType, -organization.Seats ?? 0);
|
||||
|
||||
await organizationRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(
|
||||
org => org.Id == organization.Id && org.BillingEmail == "a@example.com" &&
|
||||
org.GatewaySubscriptionId == "S-1"));
|
||||
|
||||
await sutProvider.GetDependency<IMailService>().Received(1).SendProviderUpdatePaymentMethod(
|
||||
organization.Id,
|
||||
organization.Name,
|
||||
provider.Name,
|
||||
Arg.Is<IEnumerable<string>>(emails =>
|
||||
emails.Contains("a@example.com") && emails.Contains("b@example.com")));
|
||||
org =>
|
||||
org.BillingEmail == "a@example.com" &&
|
||||
org.GatewaySubscriptionId == "subscription_id" &&
|
||||
org.Status == OrganizationStatusType.Created));
|
||||
|
||||
await sutProvider.GetDependency<IProviderOrganizationRepository>().Received(1)
|
||||
.DeleteAsync(providerOrganization);
|
||||
|
||||
await sutProvider.GetDependency<IEventService>().Received(1).LogProviderOrganizationEventAsync(
|
||||
providerOrganization,
|
||||
EventType.ProviderOrganization_Removed);
|
||||
await sutProvider.GetDependency<IEventService>().Received(1)
|
||||
.LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_Removed);
|
||||
|
||||
await sutProvider.GetDependency<IMailService>().Received(1)
|
||||
.SendProviderUpdatePaymentMethod(
|
||||
organization.Id,
|
||||
organization.Name,
|
||||
provider.Name,
|
||||
Arg.Is<IEnumerable<string>>(emails => emails.FirstOrDefault() == "a@example.com"));
|
||||
}
|
||||
|
||||
private static Subscription GetSubscription(string subscriptionId) =>
|
||||
new()
|
||||
{
|
||||
Id = subscriptionId,
|
||||
Status = StripeConstants.SubscriptionStatus.Active,
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = new List<SubscriptionItem>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = "sub_item_123",
|
||||
Price = new Price()
|
||||
{
|
||||
Id = "2023-enterprise-org-seat-annually"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
@ -7,6 +7,8 @@ using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.Models.Business.Provider;
|
||||
using Bit.Core.AdminConsole.Models.Business.Tokenables;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
@ -80,6 +82,51 @@ public class ProviderServiceTests
|
||||
.ReplaceAsync(Arg.Is<ProviderUser>(pu => pu.UserId == user.Id && pu.ProviderId == provider.Id && pu.Key == key));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task CompleteSetupAsync_ConsolidatedBilling_Success(User user, Provider provider, string key, TaxInfo taxInfo,
|
||||
[ProviderUser(ProviderUserStatusType.Confirmed, ProviderUserType.ProviderAdmin)] ProviderUser providerUser,
|
||||
SutProvider<ProviderService> sutProvider)
|
||||
{
|
||||
providerUser.ProviderId = provider.Id;
|
||||
providerUser.UserId = user.Id;
|
||||
var userService = sutProvider.GetDependency<IUserService>();
|
||||
userService.GetUserByIdAsync(user.Id).Returns(user);
|
||||
|
||||
var providerUserRepository = sutProvider.GetDependency<IProviderUserRepository>();
|
||||
providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id).Returns(providerUser);
|
||||
|
||||
var dataProtectionProvider = DataProtectionProvider.Create("ApplicationName");
|
||||
var protector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector");
|
||||
sutProvider.GetDependency<IDataProtectionProvider>().CreateProtector("ProviderServiceDataProtector")
|
||||
.Returns(protector);
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling)
|
||||
.Returns(true);
|
||||
|
||||
var providerBillingService = sutProvider.GetDependency<IProviderBillingService>();
|
||||
|
||||
var customer = new Customer { Id = "customer_id" };
|
||||
providerBillingService.SetupCustomer(provider, taxInfo).Returns(customer);
|
||||
|
||||
var subscription = new Subscription { Id = "subscription_id" };
|
||||
providerBillingService.SetupSubscription(provider).Returns(subscription);
|
||||
|
||||
sutProvider.Create();
|
||||
|
||||
var token = protector.Protect($"ProviderSetupInvite {provider.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}");
|
||||
|
||||
await sutProvider.Sut.CompleteSetupAsync(provider, user.Id, token, key, taxInfo);
|
||||
|
||||
await sutProvider.GetDependency<IProviderRepository>().Received().UpsertAsync(Arg.Is<Provider>(
|
||||
p =>
|
||||
p.GatewayCustomerId == customer.Id &&
|
||||
p.GatewaySubscriptionId == subscription.Id &&
|
||||
p.Status == ProviderStatusType.Billable));
|
||||
|
||||
await sutProvider.GetDependency<IProviderUserRepository>().Received()
|
||||
.ReplaceAsync(Arg.Is<ProviderUser>(pu => pu.UserId == user.Id && pu.ProviderId == provider.Id && pu.Key == key));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateAsync_ProviderIdIsInvalid_Throws(Provider provider, SutProvider<ProviderService> sutProvider)
|
||||
{
|
||||
@ -612,7 +659,7 @@ public class ProviderServiceTests
|
||||
await sutProvider.GetDependency<IEventService>().DidNotReceiveWithAnyArgs().LogProviderOrganizationEventsAsync(default);
|
||||
}
|
||||
|
||||
[Theory, OrganizationCustomize(FlexibleCollections = false), BitAutoData]
|
||||
[Theory, OrganizationCustomize, BitAutoData]
|
||||
public async Task CreateOrganizationAsync_Success(Provider provider, OrganizationSignup organizationSignup,
|
||||
Organization organization, string clientOwnerEmail, User user, SutProvider<ProviderService> sutProvider)
|
||||
{
|
||||
@ -631,17 +678,16 @@ public class ProviderServiceTests
|
||||
.Received().LogProviderOrganizationEventAsync(providerOrganization,
|
||||
EventType.ProviderOrganization_Created);
|
||||
await sutProvider.GetDependency<IOrganizationService>()
|
||||
.Received().InviteUsersAsync(organization.Id, user.Id, Arg.Is<IEnumerable<(OrganizationUserInvite, string)>>(
|
||||
.Received().InviteUsersAsync(organization.Id, user.Id, systemUser: null, Arg.Is<IEnumerable<(OrganizationUserInvite, string)>>(
|
||||
t => t.Count() == 1 &&
|
||||
t.First().Item1.Emails.Count() == 1 &&
|
||||
t.First().Item1.Emails.First() == clientOwnerEmail &&
|
||||
t.First().Item1.Type == OrganizationUserType.Owner &&
|
||||
t.First().Item1.AccessAll &&
|
||||
!t.First().Item1.Collections.Any() &&
|
||||
t.First().Item1.Collections.Count() == 1 &&
|
||||
t.First().Item2 == null));
|
||||
}
|
||||
|
||||
[Theory, OrganizationCustomize(FlexibleCollections = false), BitAutoData]
|
||||
[Theory, OrganizationCustomize, BitAutoData]
|
||||
public async Task CreateOrganizationAsync_ConsolidatedBillingEnabled_InvalidPlanType_ThrowsBadRequestException(
|
||||
Provider provider,
|
||||
OrganizationSignup organizationSignup,
|
||||
@ -670,7 +716,7 @@ public class ProviderServiceTests
|
||||
await providerOrganizationRepository.DidNotReceiveWithAnyArgs().CreateAsync(default);
|
||||
}
|
||||
|
||||
[Theory, OrganizationCustomize(FlexibleCollections = false), BitAutoData]
|
||||
[Theory, OrganizationCustomize, BitAutoData]
|
||||
public async Task CreateOrganizationAsync_ConsolidatedBillingEnabled_InvokeSignupClientAsync(
|
||||
Provider provider,
|
||||
OrganizationSignup organizationSignup,
|
||||
@ -709,19 +755,19 @@ public class ProviderServiceTests
|
||||
.InviteUsersAsync(
|
||||
organization.Id,
|
||||
user.Id,
|
||||
systemUser: null,
|
||||
Arg.Is<IEnumerable<(OrganizationUserInvite, string)>>(
|
||||
t =>
|
||||
t.Count() == 1 &&
|
||||
t.First().Item1.Emails.Count() == 1 &&
|
||||
t.First().Item1.Emails.First() == clientOwnerEmail &&
|
||||
t.First().Item1.Type == OrganizationUserType.Owner &&
|
||||
t.First().Item1.AccessAll &&
|
||||
!t.First().Item1.Collections.Any() &&
|
||||
t.First().Item1.Collections.Count() == 1 &&
|
||||
t.First().Item2 == null));
|
||||
}
|
||||
|
||||
[Theory, OrganizationCustomize(FlexibleCollections = true), BitAutoData]
|
||||
public async Task CreateOrganizationAsync_WithFlexibleCollections_SetsAccessAllToFalse
|
||||
[Theory, OrganizationCustomize, BitAutoData]
|
||||
public async Task CreateOrganizationAsync_SetsAccessAllToFalse
|
||||
(Provider provider, OrganizationSignup organizationSignup, Organization organization, string clientOwnerEmail,
|
||||
User user, SutProvider<ProviderService> sutProvider, Collection defaultCollection)
|
||||
{
|
||||
@ -740,12 +786,11 @@ public class ProviderServiceTests
|
||||
.Received().LogProviderOrganizationEventAsync(providerOrganization,
|
||||
EventType.ProviderOrganization_Created);
|
||||
await sutProvider.GetDependency<IOrganizationService>()
|
||||
.Received().InviteUsersAsync(organization.Id, user.Id, Arg.Is<IEnumerable<(OrganizationUserInvite, string)>>(
|
||||
.Received().InviteUsersAsync(organization.Id, user.Id, systemUser: null, Arg.Is<IEnumerable<(OrganizationUserInvite, string)>>(
|
||||
t => t.Count() == 1 &&
|
||||
t.First().Item1.Emails.Count() == 1 &&
|
||||
t.First().Item1.Emails.First() == clientOwnerEmail &&
|
||||
t.First().Item1.Type == OrganizationUserType.Owner &&
|
||||
t.First().Item1.AccessAll == false &&
|
||||
t.First().Item1.Collections.Single().Id == defaultCollection.Id &&
|
||||
!t.First().Item1.Collections.Single().HidePasswords &&
|
||||
!t.First().Item1.Collections.Single().ReadOnly &&
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -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;
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
@ -352,14 +352,16 @@ public class SecretAuthorizationHandlerTests
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task CanUpdateSecret_WithoutProjectUser_DoesNotSucceed(
|
||||
public async Task CanUpdateSecret_ClearProjectsUser_DoesNotSucceed(
|
||||
SutProvider<SecretAuthorizationHandler> sutProvider, Secret secret,
|
||||
Guid userId,
|
||||
ClaimsPrincipal claimsPrincipal)
|
||||
{
|
||||
secret.Projects = null;
|
||||
secret.Projects = [];
|
||||
var requirement = SecretOperations.Update;
|
||||
SetupPermission(sutProvider, PermissionType.RunAsUserWithPermission, secret.OrganizationId, userId);
|
||||
sutProvider.GetDependency<ISecretRepository>().AccessToSecretAsync(secret.Id, Arg.Any<Guid>(), Arg.Any<AccessClientType>()).Returns(
|
||||
(true, true));
|
||||
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
|
||||
claimsPrincipal, secret);
|
||||
|
||||
@ -370,12 +372,12 @@ public class SecretAuthorizationHandlerTests
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task CanUpdateSecret_WithoutProjectAdmin_Success(SutProvider<SecretAuthorizationHandler> sutProvider,
|
||||
public async Task CanUpdateSecret_ClearProjectsAdmin_Success(SutProvider<SecretAuthorizationHandler> sutProvider,
|
||||
Secret secret,
|
||||
Guid userId,
|
||||
ClaimsPrincipal claimsPrincipal)
|
||||
{
|
||||
secret.Projects = null;
|
||||
secret.Projects = [];
|
||||
var requirement = SecretOperations.Update;
|
||||
SetupPermission(sutProvider, PermissionType.RunAsAdmin, secret.OrganizationId, userId);
|
||||
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
|
||||
@ -386,6 +388,35 @@ public class SecretAuthorizationHandlerTests
|
||||
Assert.True(authzContext.HasSucceeded);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData(PermissionType.RunAsUserWithPermission, false, false, false)]
|
||||
[BitAutoData(PermissionType.RunAsUserWithPermission, false, true, true)]
|
||||
[BitAutoData(PermissionType.RunAsUserWithPermission, true, false, false)]
|
||||
[BitAutoData(PermissionType.RunAsUserWithPermission, true, true, true)]
|
||||
[BitAutoData(PermissionType.RunAsServiceAccountWithPermission, false, false, false)]
|
||||
[BitAutoData(PermissionType.RunAsServiceAccountWithPermission, false, true, true)]
|
||||
[BitAutoData(PermissionType.RunAsServiceAccountWithPermission, true, false, false)]
|
||||
[BitAutoData(PermissionType.RunAsServiceAccountWithPermission, true, true, true)]
|
||||
public async Task CanUpdateSecret_NoProjectChanges_ReturnsExpected(PermissionType permissionType, bool read,
|
||||
bool write, bool expected,
|
||||
SutProvider<SecretAuthorizationHandler> sutProvider, Secret secret,
|
||||
Guid userId,
|
||||
ClaimsPrincipal claimsPrincipal)
|
||||
{
|
||||
var requirement = SecretOperations.Update;
|
||||
secret.Projects = null;
|
||||
SetupPermission(sutProvider, permissionType, secret.OrganizationId, userId);
|
||||
sutProvider.GetDependency<ISecretRepository>()
|
||||
.AccessToSecretAsync(secret.Id, userId, Arg.Any<AccessClientType>()).Returns(
|
||||
(read, write));
|
||||
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
|
||||
claimsPrincipal, secret);
|
||||
|
||||
await sutProvider.Sut.HandleAsync(authzContext);
|
||||
|
||||
Assert.Equal(expected, authzContext.HasSucceeded);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData(PermissionType.RunAsUserWithPermission, true, true, true, false)]
|
||||
[BitAutoData(PermissionType.RunAsUserWithPermission, true, true, false, false)]
|
||||
@ -517,4 +548,85 @@ public class SecretAuthorizationHandlerTests
|
||||
|
||||
Assert.Equal(expected, authzContext.HasSucceeded);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task CanReadAccessPolicies_AccessToSecretsManagerFalse_DoesNotSucceed(
|
||||
SutProvider<SecretAuthorizationHandler> sutProvider, Secret secret,
|
||||
ClaimsPrincipal claimsPrincipal)
|
||||
{
|
||||
var requirement = SecretOperations.ReadAccessPolicies;
|
||||
sutProvider.GetDependency<ICurrentContext>().AccessSecretsManager(secret.OrganizationId)
|
||||
.Returns(false);
|
||||
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
|
||||
claimsPrincipal, secret);
|
||||
|
||||
await sutProvider.Sut.HandleAsync(authzContext);
|
||||
|
||||
Assert.False(authzContext.HasSucceeded);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task CanReadAccessPolicies_NullResource_DoesNotSucceed(
|
||||
SutProvider<SecretAuthorizationHandler> sutProvider, Secret secret,
|
||||
ClaimsPrincipal claimsPrincipal,
|
||||
Guid userId)
|
||||
{
|
||||
var requirement = SecretOperations.ReadAccessPolicies;
|
||||
SetupPermission(sutProvider, PermissionType.RunAsAdmin, secret.OrganizationId, userId);
|
||||
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
|
||||
claimsPrincipal, null);
|
||||
|
||||
await sutProvider.Sut.HandleAsync(authzContext);
|
||||
|
||||
Assert.False(authzContext.HasSucceeded);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData(AccessClientType.ServiceAccount)]
|
||||
[BitAutoData(AccessClientType.Organization)]
|
||||
public async Task CanReadAccessPolicies_UnsupportedClient_DoesNotSucceed(
|
||||
AccessClientType clientType,
|
||||
SutProvider<SecretAuthorizationHandler> sutProvider, Secret secret,
|
||||
ClaimsPrincipal claimsPrincipal)
|
||||
{
|
||||
var requirement = SecretOperations.ReadAccessPolicies;
|
||||
sutProvider.GetDependency<ICurrentContext>().AccessSecretsManager(secret.OrganizationId)
|
||||
.Returns(true);
|
||||
sutProvider.GetDependency<IAccessClientQuery>()
|
||||
.GetAccessClientAsync(Arg.Any<ClaimsPrincipal>(), secret.OrganizationId)
|
||||
.Returns((clientType, Guid.NewGuid()));
|
||||
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
|
||||
claimsPrincipal, secret);
|
||||
|
||||
await sutProvider.Sut.HandleAsync(authzContext);
|
||||
|
||||
Assert.False(authzContext.HasSucceeded);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData(PermissionType.RunAsAdmin, true, true, true)]
|
||||
[BitAutoData(PermissionType.RunAsUserWithPermission, false, false, false)]
|
||||
[BitAutoData(PermissionType.RunAsUserWithPermission, false, true, true)]
|
||||
[BitAutoData(PermissionType.RunAsUserWithPermission, true, false, false)]
|
||||
[BitAutoData(PermissionType.RunAsUserWithPermission, true, true, true)]
|
||||
public async Task CanReadAccessPolicies_AccessCheck(PermissionType permissionType, bool read, bool write,
|
||||
bool expected,
|
||||
SutProvider<SecretAuthorizationHandler> sutProvider, Secret secret,
|
||||
ClaimsPrincipal claimsPrincipal,
|
||||
Guid userId)
|
||||
{
|
||||
var requirement = SecretOperations.ReadAccessPolicies;
|
||||
SetupPermission(sutProvider, permissionType, secret.OrganizationId, userId);
|
||||
sutProvider.GetDependency<ISecretRepository>()
|
||||
.AccessToSecretAsync(secret.Id, userId, Arg.Any<AccessClientType>())
|
||||
.Returns((read, write));
|
||||
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
|
||||
claimsPrincipal, secret);
|
||||
|
||||
await sutProvider.Sut.HandleAsync(authzContext);
|
||||
|
||||
Assert.Equal(expected, authzContext.HasSucceeded);
|
||||
}
|
||||
}
|
||||
|
@ -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>());
|
||||
}
|
||||
}
|
@ -20,9 +20,9 @@ public class CreateSecretCommandTests
|
||||
{
|
||||
data.Projects = new List<Project>() { mockProject };
|
||||
|
||||
await sutProvider.Sut.CreateAsync(data);
|
||||
await sutProvider.Sut.CreateAsync(data, null);
|
||||
|
||||
await sutProvider.GetDependency<ISecretRepository>().Received(1)
|
||||
.CreateAsync(data);
|
||||
.CreateAsync(data, null);
|
||||
}
|
||||
}
|
||||
|
@ -1,12 +1,11 @@
|
||||
using Bit.Commercial.Core.SecretsManager.Commands.Secrets;
|
||||
using Bit.Core.Exceptions;
|
||||
#nullable enable
|
||||
using Bit.Commercial.Core.SecretsManager.Commands.Secrets;
|
||||
using Bit.Core.SecretsManager.Entities;
|
||||
using Bit.Core.SecretsManager.Repositories;
|
||||
using Bit.Core.Test.SecretsManager.AutoFixture.ProjectsFixture;
|
||||
using Bit.Core.Test.SecretsManager.AutoFixture.SecretsFixture;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Bit.Test.Common.Helpers;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
@ -19,109 +18,13 @@ public class UpdateSecretCommandTests
|
||||
{
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task UpdateAsync_SecretDoesNotExist_ThrowsNotFound(Secret data, SutProvider<UpdateSecretCommand> sutProvider)
|
||||
public async Task UpdateAsync_Success(SutProvider<UpdateSecretCommand> sutProvider, Secret data, Project project)
|
||||
{
|
||||
await Assert.ThrowsAsync<NotFoundException>(() => sutProvider.Sut.UpdateAsync(data));
|
||||
data.Projects = new List<Project> { project };
|
||||
|
||||
await sutProvider.GetDependency<ISecretRepository>().DidNotReceiveWithAnyArgs().UpdateAsync(default);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task UpdateAsync_Success(Secret existingSecret, Secret data, SutProvider<UpdateSecretCommand> sutProvider, Project mockProject)
|
||||
{
|
||||
sutProvider.GetDependency<ISecretRepository>().GetByIdAsync(existingSecret.Id).Returns(existingSecret);
|
||||
data.Projects = new List<Project>() { mockProject };
|
||||
|
||||
sutProvider.GetDependency<ISecretRepository>().GetByIdAsync(data.Id).Returns(data);
|
||||
await sutProvider.Sut.UpdateAsync(data);
|
||||
await sutProvider.Sut.UpdateAsync(data, null);
|
||||
|
||||
await sutProvider.GetDependency<ISecretRepository>().Received(1)
|
||||
.UpdateAsync(data);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task UpdateAsync_DoesNotModifyOrganizationId(Secret existingSecret, SutProvider<UpdateSecretCommand> sutProvider)
|
||||
{
|
||||
var updatedOrgId = Guid.NewGuid();
|
||||
sutProvider.GetDependency<ISecretRepository>().GetByIdAsync(existingSecret.Id).Returns(existingSecret);
|
||||
|
||||
var secretUpdate = new Secret()
|
||||
{
|
||||
OrganizationId = updatedOrgId,
|
||||
Id = existingSecret.Id,
|
||||
Key = existingSecret.Key,
|
||||
};
|
||||
|
||||
var result = await sutProvider.Sut.UpdateAsync(secretUpdate);
|
||||
|
||||
Assert.Equal(existingSecret.OrganizationId, result.OrganizationId);
|
||||
Assert.NotEqual(existingSecret.OrganizationId, updatedOrgId);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task UpdateAsync_DoesNotModifyCreationDate(Secret existingSecret, SutProvider<UpdateSecretCommand> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<ISecretRepository>().GetByIdAsync(existingSecret.Id).Returns(existingSecret);
|
||||
|
||||
var updatedCreationDate = DateTime.UtcNow;
|
||||
var secretUpdate = new Secret()
|
||||
{
|
||||
CreationDate = updatedCreationDate,
|
||||
Id = existingSecret.Id,
|
||||
Key = existingSecret.Key,
|
||||
OrganizationId = existingSecret.OrganizationId
|
||||
};
|
||||
|
||||
var result = await sutProvider.Sut.UpdateAsync(secretUpdate);
|
||||
|
||||
Assert.Equal(existingSecret.CreationDate, result.CreationDate);
|
||||
Assert.NotEqual(existingSecret.CreationDate, updatedCreationDate);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task UpdateAsync_DoesNotModifyDeletionDate(Secret existingSecret, SutProvider<UpdateSecretCommand> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<ISecretRepository>().GetByIdAsync(existingSecret.Id).Returns(existingSecret);
|
||||
|
||||
var updatedDeletionDate = DateTime.UtcNow;
|
||||
var secretUpdate = new Secret()
|
||||
{
|
||||
DeletedDate = updatedDeletionDate,
|
||||
Id = existingSecret.Id,
|
||||
Key = existingSecret.Key,
|
||||
OrganizationId = existingSecret.OrganizationId
|
||||
};
|
||||
|
||||
var result = await sutProvider.Sut.UpdateAsync(secretUpdate);
|
||||
|
||||
Assert.Equal(existingSecret.DeletedDate, result.DeletedDate);
|
||||
Assert.NotEqual(existingSecret.DeletedDate, updatedDeletionDate);
|
||||
}
|
||||
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task UpdateAsync_RevisionDateIsUpdatedToUtcNow(Secret existingSecret, SutProvider<UpdateSecretCommand> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<ISecretRepository>().GetByIdAsync(existingSecret.Id).Returns(existingSecret);
|
||||
|
||||
var updatedRevisionDate = DateTime.UtcNow.AddDays(10);
|
||||
var secretUpdate = new Secret()
|
||||
{
|
||||
RevisionDate = updatedRevisionDate,
|
||||
Id = existingSecret.Id,
|
||||
Key = existingSecret.Key,
|
||||
OrganizationId = existingSecret.OrganizationId
|
||||
};
|
||||
|
||||
var result = await sutProvider.Sut.UpdateAsync(secretUpdate);
|
||||
|
||||
Assert.NotEqual(secretUpdate.RevisionDate, result.RevisionDate);
|
||||
AssertHelper.AssertRecent(result.RevisionDate);
|
||||
.UpdateAsync(data, null);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -18,13 +18,21 @@ public class RevokeAccessTokenCommandTests
|
||||
var apiKey1 = new ApiKey
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ServiceAccountId = serviceAccount.Id
|
||||
ServiceAccountId = serviceAccount.Id,
|
||||
Name = "Test Name",
|
||||
Scope = "Test Scope",
|
||||
EncryptedPayload = "Test EncryptedPayload",
|
||||
Key = "Test Key",
|
||||
};
|
||||
|
||||
var apiKey2 = new ApiKey
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ServiceAccountId = serviceAccount.Id
|
||||
ServiceAccountId = serviceAccount.Id,
|
||||
Name = "Test Name",
|
||||
Scope = "Test Scope",
|
||||
EncryptedPayload = "Test EncryptedPayload",
|
||||
Key = "Test Key",
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IApiKeyRepository>()
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
using Bit.Commercial.Core.SecretsManager.Queries.Projects;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.SecretsManager.Repositories;
|
||||
|
@ -201,7 +201,14 @@ public class ScimApplicationFactory : WebApplicationFactoryBase<Startup>
|
||||
{
|
||||
return new List<Organization>()
|
||||
{
|
||||
new Organization { Id = TestOrganizationId1, Name = "Test Organization 1", UseGroups = true }
|
||||
new Organization
|
||||
{
|
||||
Id = TestOrganizationId1,
|
||||
Name = "Test Organization 1",
|
||||
BillingEmail = $"billing-email+{TestOrganizationId1}@example.com",
|
||||
UseGroups = true,
|
||||
Plan = "Enterprise",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -38,7 +38,6 @@ public class PutGroupCommandTests
|
||||
var expectedResult = new Group
|
||||
{
|
||||
Id = group.Id,
|
||||
AccessAll = group.AccessAll,
|
||||
ExternalId = group.ExternalId,
|
||||
Name = displayName,
|
||||
OrganizationId = group.OrganizationId
|
||||
@ -77,7 +76,6 @@ public class PutGroupCommandTests
|
||||
var expectedResult = new Group
|
||||
{
|
||||
Id = group.Id,
|
||||
AccessAll = group.AccessAll,
|
||||
ExternalId = group.ExternalId,
|
||||
Name = displayName,
|
||||
OrganizationId = group.OrganizationId
|
||||
|
@ -1,6 +1,7 @@
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Core.Models.Business;
|
||||
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
@ -19,7 +20,7 @@ public class PostUserCommandTests
|
||||
{
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PostUser_Success(SutProvider<PostUserCommand> sutProvider, string externalId, Guid organizationId, List<BaseScimUserModel.EmailModel> emails, ICollection<OrganizationUserUserDetails> organizationUsers, Core.Entities.OrganizationUser newUser)
|
||||
public async Task PostUser_Success(SutProvider<PostUserCommand> sutProvider, string externalId, Guid organizationId, List<BaseScimUserModel.EmailModel> emails, ICollection<OrganizationUserUserDetails> organizationUsers, Core.Entities.OrganizationUser newUser, Organization organization)
|
||||
{
|
||||
var scimUserRequestModel = new ScimUserRequestModel
|
||||
{
|
||||
@ -33,16 +34,30 @@ public class PostUserCommandTests
|
||||
.GetManyDetailsByOrganizationAsync(organizationId)
|
||||
.Returns(organizationUsers);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId).Returns(organization);
|
||||
|
||||
sutProvider.GetDependency<IPaymentService>().HasSecretsManagerStandalone(organization).Returns(true);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationService>()
|
||||
.InviteUserAsync(organizationId, EventSystemUser.SCIM, scimUserRequestModel.PrimaryEmail.ToLowerInvariant(),
|
||||
OrganizationUserType.User, false, externalId, Arg.Any<List<CollectionAccessSelection>>(),
|
||||
Arg.Any<List<Guid>>())
|
||||
.InviteUserAsync(organizationId, invitingUserId: null, EventSystemUser.SCIM,
|
||||
Arg.Is<OrganizationUserInvite>(i =>
|
||||
i.Emails.Single().Equals(scimUserRequestModel.PrimaryEmail.ToLowerInvariant()) &&
|
||||
i.Type == OrganizationUserType.User &&
|
||||
!i.Collections.Any() &&
|
||||
!i.Groups.Any() &&
|
||||
i.AccessSecretsManager), externalId)
|
||||
.Returns(newUser);
|
||||
|
||||
var user = await sutProvider.Sut.PostUserAsync(organizationId, scimUserRequestModel);
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationService>().Received(1).InviteUserAsync(organizationId, EventSystemUser.SCIM, scimUserRequestModel.PrimaryEmail.ToLowerInvariant(),
|
||||
OrganizationUserType.User, false, scimUserRequestModel.ExternalId, Arg.Any<List<CollectionAccessSelection>>(), Arg.Any<List<Guid>>());
|
||||
await sutProvider.GetDependency<IOrganizationService>().Received(1).InviteUserAsync(organizationId,
|
||||
invitingUserId: null, EventSystemUser.SCIM,
|
||||
Arg.Is<OrganizationUserInvite>(i =>
|
||||
i.Emails.Single().Equals(scimUserRequestModel.PrimaryEmail.ToLowerInvariant()) &&
|
||||
i.Type == OrganizationUserType.User &&
|
||||
!i.Collections.Any() &&
|
||||
!i.Groups.Any() &&
|
||||
i.AccessSecretsManager), externalId);
|
||||
await sutProvider.GetDependency<IOrganizationUserRepository>().Received(1).GetDetailsByIdAsync(newUser.Id);
|
||||
}
|
||||
|
||||
|
@ -1,37 +0,0 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 16
|
||||
VisualStudioVersion = 25.0.1703.8
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Commercial.Core.Test", "Commercial.Core.Test\Commercial.Core.Test.csproj", "{70F03E72-2F38-4497-BF31-EA19B13B2161}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Scim.Test", "Scim.Test\Scim.Test.csproj", "{9BC8E2C9-400D-4FA7-86CA-3E1794E7CA4C}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Scim.IntegrationTest", "Scim.IntegrationTest\Scim.IntegrationTest.csproj", "{45CD3F1B-127E-44B7-B22B-28D677B621D9}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{70F03E72-2F38-4497-BF31-EA19B13B2161}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{70F03E72-2F38-4497-BF31-EA19B13B2161}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{70F03E72-2F38-4497-BF31-EA19B13B2161}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{70F03E72-2F38-4497-BF31-EA19B13B2161}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{9BC8E2C9-400D-4FA7-86CA-3E1794E7CA4C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{9BC8E2C9-400D-4FA7-86CA-3E1794E7CA4C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{9BC8E2C9-400D-4FA7-86CA-3E1794E7CA4C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{9BC8E2C9-400D-4FA7-86CA-3E1794E7CA4C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{45CD3F1B-127E-44B7-B22B-28D677B621D9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{45CD3F1B-127E-44B7-B22B-28D677B621D9}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{45CD3F1B-127E-44B7-B22B-28D677B621D9}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{45CD3F1B-127E-44B7-B22B-28D677B621D9}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {BAD5FA17-2653-401A-A1E5-A31C420B9DE8}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
@ -9,7 +9,11 @@ $service = "mysql"
|
||||
|
||||
Write-Output "--- Attempting to start $service service ---"
|
||||
|
||||
docker-compose --profile $service up -d --no-recreate
|
||||
# Attempt to start mysql but if docker-compose doesn't
|
||||
# exist just trust that the user has it running some other way
|
||||
if (command -v docker-compose) {
|
||||
docker-compose --profile $service up -d --no-recreate
|
||||
}
|
||||
|
||||
dotnet tool restore
|
||||
|
||||
|
@ -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"
|
@ -1,9 +1,6 @@
|
||||
#!/usr/bin/env pwsh
|
||||
# Creates the vault_dev database, and runs all the migrations.
|
||||
|
||||
# Due to azure-edge-sql not containing the mssql-tools on ARM, we manually use
|
||||
# the mssql-tools container which runs under x86_64.
|
||||
|
||||
param(
|
||||
[switch]$all,
|
||||
[switch]$postgres,
|
||||
@ -30,21 +27,17 @@ if ($all -or $postgres -or $mysql -or $sqlite) {
|
||||
|
||||
if ($all -or $mssql) {
|
||||
function Get-UserSecrets {
|
||||
return dotnet user-secrets list --json --project ../src/Api | ConvertFrom-Json
|
||||
# The dotnet cli command sometimes adds //BEGIN and //END comments to the output, Where-Object removes comments
|
||||
# to ensure a valid json
|
||||
return dotnet user-secrets list --json --project ../src/Api | Where-Object { $_ -notmatch "^//" } | ConvertFrom-Json
|
||||
}
|
||||
|
||||
if ($selfhost) {
|
||||
$msSqlConnectionString = $(Get-UserSecrets).'dev:selfHostOverride:globalSettings:sqlServer:connectionString'
|
||||
$envName = "self-host"
|
||||
|
||||
Write-Output "Migrating your migrations to use MsSqlMigratorUtility (if needed)"
|
||||
./migrate_migration_record.ps1 -s
|
||||
} else {
|
||||
$msSqlConnectionString = $(Get-UserSecrets).'globalSettings:sqlServer:connectionString'
|
||||
$envName = "cloud"
|
||||
|
||||
Write-Output "Migrating your migrations to use MsSqlMigratorUtility (if needed)"
|
||||
./migrate_migration_record.ps1
|
||||
}
|
||||
|
||||
Write-Host "Starting Microsoft SQL Server Migrations for $envName"
|
||||
|
@ -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
|
@ -2,5 +2,8 @@
|
||||
"sdk": {
|
||||
"version": "8.0.100",
|
||||
"rollForward": "latestFeature"
|
||||
},
|
||||
"msbuild-sdks": {
|
||||
"Microsoft.Build.Traversal": "4.1.0"
|
||||
}
|
||||
}
|
||||
|
@ -15,7 +15,7 @@ public class StaticClientStoreTests
|
||||
}
|
||||
|
||||
[Params("mobile", "connector", "invalid", "a_much_longer_invalid_value_that_i_am_making_up", "WEB", "")]
|
||||
public string? ClientId { get; set; }
|
||||
public string ClientId { get; set; } = null!;
|
||||
|
||||
[Benchmark]
|
||||
public Client? TryGetValue()
|
||||
|
@ -7,8 +7,8 @@ using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Providers.Interfaces;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Commands;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
@ -54,9 +54,8 @@ public class OrganizationsController : Controller
|
||||
private readonly IServiceAccountRepository _serviceAccountRepository;
|
||||
private readonly IProviderOrganizationRepository _providerOrganizationRepository;
|
||||
private readonly IRemoveOrganizationFromProviderCommand _removeOrganizationFromProviderCommand;
|
||||
private readonly IRemovePaymentMethodCommand _removePaymentMethodCommand;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IScaleSeatsCommand _scaleSeatsCommand;
|
||||
private readonly IProviderBillingService _providerBillingService;
|
||||
|
||||
public OrganizationsController(
|
||||
IOrganizationService organizationService,
|
||||
@ -82,9 +81,8 @@ public class OrganizationsController : Controller
|
||||
IServiceAccountRepository serviceAccountRepository,
|
||||
IProviderOrganizationRepository providerOrganizationRepository,
|
||||
IRemoveOrganizationFromProviderCommand removeOrganizationFromProviderCommand,
|
||||
IRemovePaymentMethodCommand removePaymentMethodCommand,
|
||||
IFeatureService featureService,
|
||||
IScaleSeatsCommand scaleSeatsCommand)
|
||||
IProviderBillingService providerBillingService)
|
||||
{
|
||||
_organizationService = organizationService;
|
||||
_organizationRepository = organizationRepository;
|
||||
@ -109,9 +107,8 @@ public class OrganizationsController : Controller
|
||||
_serviceAccountRepository = serviceAccountRepository;
|
||||
_providerOrganizationRepository = providerOrganizationRepository;
|
||||
_removeOrganizationFromProviderCommand = removeOrganizationFromProviderCommand;
|
||||
_removePaymentMethodCommand = removePaymentMethodCommand;
|
||||
_featureService = featureService;
|
||||
_scaleSeatsCommand = scaleSeatsCommand;
|
||||
_providerBillingService = providerBillingService;
|
||||
}
|
||||
|
||||
[RequirePermission(Permission.Org_List_View)]
|
||||
@ -201,15 +198,32 @@ public class OrganizationsController : Controller
|
||||
}
|
||||
var users = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(id);
|
||||
var billingInfo = await _paymentService.GetBillingAsync(organization);
|
||||
var billingHistoryInfo = await _paymentService.GetBillingHistoryAsync(organization);
|
||||
var billingSyncConnection = _globalSettings.EnableCloudCommunication ? await _organizationConnectionRepository.GetByOrganizationIdTypeAsync(id, OrganizationConnectionType.CloudBillingSync) : null;
|
||||
var secrets = organization.UseSecretsManager ? await _secretRepository.GetSecretsCountByOrganizationIdAsync(id) : -1;
|
||||
var projects = organization.UseSecretsManager ? await _projectRepository.GetProjectCountByOrganizationIdAsync(id) : -1;
|
||||
var serviceAccounts = organization.UseSecretsManager ? await _serviceAccountRepository.GetServiceAccountCountByOrganizationIdAsync(id) : -1;
|
||||
|
||||
var smSeats = organization.UseSecretsManager
|
||||
? await _organizationUserRepository.GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id)
|
||||
: -1;
|
||||
return View(new OrganizationEditModel(organization, provider, users, ciphers, collections, groups, policies,
|
||||
billingInfo, billingSyncConnection, _globalSettings, secrets, projects, serviceAccounts, smSeats));
|
||||
|
||||
return View(new OrganizationEditModel(
|
||||
organization,
|
||||
provider,
|
||||
users,
|
||||
ciphers,
|
||||
collections,
|
||||
groups,
|
||||
policies,
|
||||
billingInfo,
|
||||
billingHistoryInfo,
|
||||
billingSyncConnection,
|
||||
_globalSettings,
|
||||
secrets,
|
||||
projects,
|
||||
serviceAccounts,
|
||||
smSeats));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
@ -256,7 +270,7 @@ public class OrganizationsController : Controller
|
||||
|
||||
if (provider.IsBillable())
|
||||
{
|
||||
await _scaleSeatsCommand.ScalePasswordManagerSeats(
|
||||
await _providerBillingService.ScaleSeats(
|
||||
provider,
|
||||
organization.PlanType,
|
||||
-organization.Seats ?? 0);
|
||||
@ -269,6 +283,35 @@ public class OrganizationsController : Controller
|
||||
return RedirectToAction("Index");
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
[RequirePermission(Permission.Org_Delete)]
|
||||
public async Task<IActionResult> DeleteInitiation(Guid id, OrganizationInitiateDeleteModel model)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
TempData["Error"] = ModelState.GetErrorMessage();
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
var organization = await _organizationRepository.GetByIdAsync(id);
|
||||
if (organization != null)
|
||||
{
|
||||
await _organizationService.InitiateDeleteAsync(organization, model.AdminEmail);
|
||||
TempData["Success"] = "The request to initiate deletion of the organization has been sent.";
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
TempData["Error"] = ex.Message;
|
||||
}
|
||||
}
|
||||
|
||||
return RedirectToAction("Edit", new { id });
|
||||
}
|
||||
|
||||
public async Task<IActionResult> TriggerBillingSync(Guid id)
|
||||
{
|
||||
var organization = await _organizationRepository.GetByIdAsync(id);
|
||||
@ -349,11 +392,6 @@ public class OrganizationsController : Controller
|
||||
providerOrganization,
|
||||
organization);
|
||||
|
||||
if (organization.IsStripeEnabled())
|
||||
{
|
||||
await _removePaymentMethodCommand.RemovePaymentMethod(organization);
|
||||
}
|
||||
|
||||
return Json(null);
|
||||
}
|
||||
private async Task<Organization> GetOrganization(Guid id, OrganizationEditModel model)
|
||||
@ -414,5 +452,4 @@ public class OrganizationsController : Controller
|
||||
|
||||
return organization;
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -2,8 +2,6 @@
|
||||
using Bit.Admin.Utilities;
|
||||
using Bit.Core.AdminConsole.Providers.Interfaces;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Commands;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Utilities;
|
||||
@ -20,19 +18,16 @@ public class ProviderOrganizationsController : Controller
|
||||
private readonly IProviderOrganizationRepository _providerOrganizationRepository;
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly IRemoveOrganizationFromProviderCommand _removeOrganizationFromProviderCommand;
|
||||
private readonly IRemovePaymentMethodCommand _removePaymentMethodCommand;
|
||||
|
||||
public ProviderOrganizationsController(IProviderRepository providerRepository,
|
||||
IProviderOrganizationRepository providerOrganizationRepository,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IRemoveOrganizationFromProviderCommand removeOrganizationFromProviderCommand,
|
||||
IRemovePaymentMethodCommand removePaymentMethodCommand)
|
||||
IRemoveOrganizationFromProviderCommand removeOrganizationFromProviderCommand)
|
||||
{
|
||||
_providerRepository = providerRepository;
|
||||
_providerOrganizationRepository = providerOrganizationRepository;
|
||||
_organizationRepository = organizationRepository;
|
||||
_removeOrganizationFromProviderCommand = removeOrganizationFromProviderCommand;
|
||||
_removePaymentMethodCommand = removePaymentMethodCommand;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
@ -69,12 +64,6 @@ public class ProviderOrganizationsController : Controller
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
|
||||
|
||||
if (organization.IsStripeEnabled())
|
||||
{
|
||||
await _removePaymentMethodCommand.RemovePaymentMethod(organization);
|
||||
}
|
||||
|
||||
return Json(null);
|
||||
}
|
||||
}
|
||||
|
@ -10,8 +10,10 @@ using Bit.Core.AdminConsole.Providers.Interfaces;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Billing.Entities;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Repositories;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
@ -39,6 +41,10 @@ public class ProvidersController : Controller
|
||||
private readonly ICreateProviderCommand _createProviderCommand;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IProviderPlanRepository _providerPlanRepository;
|
||||
private readonly IProviderBillingService _providerBillingService;
|
||||
private readonly string _stripeUrl;
|
||||
private readonly string _braintreeMerchantUrl;
|
||||
private readonly string _braintreeMerchantId;
|
||||
|
||||
public ProvidersController(
|
||||
IOrganizationRepository organizationRepository,
|
||||
@ -52,7 +58,9 @@ public class ProvidersController : Controller
|
||||
IUserService userService,
|
||||
ICreateProviderCommand createProviderCommand,
|
||||
IFeatureService featureService,
|
||||
IProviderPlanRepository providerPlanRepository)
|
||||
IProviderPlanRepository providerPlanRepository,
|
||||
IProviderBillingService providerBillingService,
|
||||
IWebHostEnvironment webHostEnvironment)
|
||||
{
|
||||
_organizationRepository = organizationRepository;
|
||||
_organizationService = organizationService;
|
||||
@ -66,6 +74,10 @@ public class ProvidersController : Controller
|
||||
_createProviderCommand = createProviderCommand;
|
||||
_featureService = featureService;
|
||||
_providerPlanRepository = providerPlanRepository;
|
||||
_providerBillingService = providerBillingService;
|
||||
_stripeUrl = webHostEnvironment.GetStripeUrl();
|
||||
_braintreeMerchantUrl = webHostEnvironment.GetBraintreeMerchantUrl();
|
||||
_braintreeMerchantId = globalSettings.Braintree.MerchantId;
|
||||
}
|
||||
|
||||
[RequirePermission(Permission.Provider_List_View)]
|
||||
@ -168,7 +180,9 @@ public class ProvidersController : Controller
|
||||
|
||||
var providerPlans = await _providerPlanRepository.GetByProviderId(id);
|
||||
|
||||
return View(new ProviderEditModel(provider, users, providerOrganizations, providerPlans.ToList()));
|
||||
return View(new ProviderEditModel(
|
||||
provider, users, providerOrganizations,
|
||||
providerPlans.ToList(), GetGatewayCustomerUrl(provider), GetGatewaySubscriptionUrl(provider)));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
@ -213,19 +227,10 @@ public class ProvidersController : Controller
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var providerPlan in providerPlans)
|
||||
{
|
||||
if (providerPlan.PlanType == PlanType.EnterpriseMonthly)
|
||||
{
|
||||
providerPlan.SeatMinimum = model.EnterpriseMonthlySeatMinimum;
|
||||
}
|
||||
else if (providerPlan.PlanType == PlanType.TeamsMonthly)
|
||||
{
|
||||
providerPlan.SeatMinimum = model.TeamsMonthlySeatMinimum;
|
||||
}
|
||||
|
||||
await _providerPlanRepository.ReplaceAsync(providerPlan);
|
||||
}
|
||||
await _providerBillingService.UpdateSeatMinimums(
|
||||
provider,
|
||||
model.EnterpriseMonthlySeatMinimum,
|
||||
model.TeamsMonthlySeatMinimum);
|
||||
}
|
||||
|
||||
return RedirectToAction("Edit", new { id });
|
||||
@ -305,9 +310,7 @@ public class ProvidersController : Controller
|
||||
return RedirectToAction("Index");
|
||||
}
|
||||
|
||||
var flexibleCollectionsSignupEnabled = _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsSignup);
|
||||
var flexibleCollectionsV1Enabled = _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1);
|
||||
var organization = model.CreateOrganization(provider, flexibleCollectionsSignupEnabled, flexibleCollectionsV1Enabled);
|
||||
var organization = model.CreateOrganization(provider);
|
||||
await _organizationService.CreatePendingOrganization(organization, model.Owners, User, _userService, model.SalesAssistedTrialStarted);
|
||||
await _providerService.AddOrganization(providerId, organization.Id, null);
|
||||
|
||||
@ -373,4 +376,34 @@ public class ProvidersController : Controller
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
private string GetGatewayCustomerUrl(Provider provider)
|
||||
{
|
||||
if (!provider.Gateway.HasValue || string.IsNullOrEmpty(provider.GatewayCustomerId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return provider.Gateway switch
|
||||
{
|
||||
GatewayType.Stripe => $"{_stripeUrl}/customers/{provider.GatewayCustomerId}",
|
||||
GatewayType.PayPal => $"{_braintreeMerchantUrl}/{_braintreeMerchantId}/${provider.GatewayCustomerId}",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private string GetGatewaySubscriptionUrl(Provider provider)
|
||||
{
|
||||
if (!provider.Gateway.HasValue || string.IsNullOrEmpty(provider.GatewaySubscriptionId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return provider.Gateway switch
|
||||
{
|
||||
GatewayType.Stripe => $"{_stripeUrl}/subscriptions/{provider.GatewaySubscriptionId}",
|
||||
GatewayType.PayPal => $"{_braintreeMerchantUrl}/{_braintreeMerchantId}/subscriptions/${provider.GatewaySubscriptionId}",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -3,9 +3,10 @@ using System.Net;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Business;
|
||||
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Utilities;
|
||||
@ -22,19 +23,43 @@ public class OrganizationEditModel : OrganizationViewModel
|
||||
{
|
||||
Provider = provider;
|
||||
BillingEmail = provider.Type == ProviderType.Reseller ? provider.BillingEmail : string.Empty;
|
||||
PlanType = Core.Enums.PlanType.TeamsMonthly;
|
||||
Plan = Core.Enums.PlanType.TeamsMonthly.GetDisplayAttribute()?.GetName();
|
||||
PlanType = Core.Billing.Enums.PlanType.TeamsMonthly;
|
||||
Plan = Core.Billing.Enums.PlanType.TeamsMonthly.GetDisplayAttribute()?.GetName();
|
||||
LicenseKey = RandomLicenseKey;
|
||||
}
|
||||
|
||||
public OrganizationEditModel(Organization org, Provider provider, IEnumerable<OrganizationUserUserDetails> orgUsers,
|
||||
IEnumerable<Cipher> ciphers, IEnumerable<Collection> collections, IEnumerable<Group> groups,
|
||||
IEnumerable<Policy> policies, BillingInfo billingInfo, IEnumerable<OrganizationConnection> connections,
|
||||
GlobalSettings globalSettings, int secrets, int projects, int serviceAccounts, int occupiedSmSeats)
|
||||
: base(org, provider, connections, orgUsers, ciphers, collections, groups, policies, secrets, projects,
|
||||
serviceAccounts, occupiedSmSeats)
|
||||
public OrganizationEditModel(
|
||||
Organization org,
|
||||
Provider provider,
|
||||
IEnumerable<OrganizationUserUserDetails> orgUsers,
|
||||
IEnumerable<Cipher> ciphers,
|
||||
IEnumerable<Collection> collections,
|
||||
IEnumerable<Group> groups,
|
||||
IEnumerable<Policy> policies,
|
||||
BillingInfo billingInfo,
|
||||
BillingHistoryInfo billingHistoryInfo,
|
||||
IEnumerable<OrganizationConnection> connections,
|
||||
GlobalSettings globalSettings,
|
||||
int secrets,
|
||||
int projects,
|
||||
int serviceAccounts,
|
||||
int occupiedSmSeats)
|
||||
: base(
|
||||
org,
|
||||
provider,
|
||||
connections,
|
||||
orgUsers,
|
||||
ciphers,
|
||||
collections,
|
||||
groups,
|
||||
policies,
|
||||
secrets,
|
||||
projects,
|
||||
serviceAccounts,
|
||||
occupiedSmSeats)
|
||||
{
|
||||
BillingInfo = billingInfo;
|
||||
BillingHistoryInfo = billingHistoryInfo;
|
||||
BraintreeMerchantId = globalSettings.Braintree.MerchantId;
|
||||
|
||||
Name = org.DisplayName();
|
||||
@ -73,6 +98,7 @@ public class OrganizationEditModel : OrganizationViewModel
|
||||
}
|
||||
|
||||
public BillingInfo BillingInfo { get; set; }
|
||||
public BillingHistoryInfo BillingHistoryInfo { get; set; }
|
||||
public string RandomLicenseKey => CoreHelpers.SecureRandomString(20);
|
||||
public string FourteenDayExpirationDate => DateTime.Now.AddDays(14).ToString("yyyy-MM-ddTHH:mm");
|
||||
public string BraintreeMerchantId { get; set; }
|
||||
@ -153,31 +179,91 @@ public class OrganizationEditModel : OrganizationViewModel
|
||||
* This is mapped manually below to provide some type safety in case the plan objects change
|
||||
* Add mappings for individual properties as you need them
|
||||
*/
|
||||
public IEnumerable<Dictionary<string, object>> GetPlansHelper() =>
|
||||
public object GetPlansHelper() =>
|
||||
StaticStore.Plans
|
||||
.Where(p => p.SupportsSecretsManager)
|
||||
.Select(p => new Dictionary<string, object>
|
||||
.Select(p =>
|
||||
{
|
||||
{ "type", p.Type },
|
||||
{ "baseServiceAccount", p.SecretsManager.BaseServiceAccount }
|
||||
var plan = new
|
||||
{
|
||||
Type = p.Type,
|
||||
ProductTier = p.ProductTier,
|
||||
Name = p.Name,
|
||||
IsAnnual = p.IsAnnual,
|
||||
NameLocalizationKey = p.NameLocalizationKey,
|
||||
DescriptionLocalizationKey = p.DescriptionLocalizationKey,
|
||||
CanBeUsedByBusiness = p.CanBeUsedByBusiness,
|
||||
TrialPeriodDays = p.TrialPeriodDays,
|
||||
HasSelfHost = p.HasSelfHost,
|
||||
HasPolicies = p.HasPolicies,
|
||||
HasGroups = p.HasGroups,
|
||||
HasDirectory = p.HasDirectory,
|
||||
HasEvents = p.HasEvents,
|
||||
HasTotp = p.HasTotp,
|
||||
Has2fa = p.Has2fa,
|
||||
HasApi = p.HasApi,
|
||||
HasSso = p.HasSso,
|
||||
HasKeyConnector = p.HasKeyConnector,
|
||||
HasScim = p.HasScim,
|
||||
HasResetPassword = p.HasResetPassword,
|
||||
UsersGetPremium = p.UsersGetPremium,
|
||||
HasCustomPermissions = p.HasCustomPermissions,
|
||||
UpgradeSortOrder = p.UpgradeSortOrder,
|
||||
DisplaySortOrder = p.DisplaySortOrder,
|
||||
LegacyYear = p.LegacyYear,
|
||||
Disabled = p.Disabled,
|
||||
SupportsSecretsManager = p.SupportsSecretsManager,
|
||||
PasswordManager =
|
||||
new
|
||||
{
|
||||
StripePlanId = p.PasswordManager?.StripePlanId,
|
||||
StripeSeatPlanId = p.PasswordManager?.StripeSeatPlanId,
|
||||
StripeProviderPortalSeatPlanId = p.PasswordManager?.StripeProviderPortalSeatPlanId,
|
||||
BasePrice = p.PasswordManager?.BasePrice,
|
||||
SeatPrice = p.PasswordManager?.SeatPrice,
|
||||
ProviderPortalSeatPrice = p.PasswordManager?.ProviderPortalSeatPrice,
|
||||
AllowSeatAutoscale = p.PasswordManager?.AllowSeatAutoscale,
|
||||
HasAdditionalSeatsOption = p.PasswordManager?.HasAdditionalSeatsOption,
|
||||
MaxAdditionalSeats = p.PasswordManager?.MaxAdditionalSeats,
|
||||
BaseSeats = p.PasswordManager?.BaseSeats,
|
||||
HasPremiumAccessOption = p.PasswordManager?.HasPremiumAccessOption,
|
||||
StripePremiumAccessPlanId = p.PasswordManager?.StripePremiumAccessPlanId,
|
||||
PremiumAccessOptionPrice = p.PasswordManager?.PremiumAccessOptionPrice,
|
||||
MaxSeats = p.PasswordManager?.MaxSeats,
|
||||
BaseStorageGb = p.PasswordManager?.BaseStorageGb,
|
||||
HasAdditionalStorageOption = p.PasswordManager?.HasAdditionalStorageOption,
|
||||
AdditionalStoragePricePerGb = p.PasswordManager?.AdditionalStoragePricePerGb,
|
||||
StripeStoragePlanId = p.PasswordManager?.StripeStoragePlanId,
|
||||
MaxAdditionalStorage = p.PasswordManager?.MaxAdditionalStorage,
|
||||
MaxCollections = p.PasswordManager?.MaxCollections
|
||||
},
|
||||
SecretsManager = new
|
||||
{
|
||||
MaxServiceAccounts = p.SecretsManager?.MaxServiceAccounts,
|
||||
AllowServiceAccountsAutoscale = p.SecretsManager?.AllowServiceAccountsAutoscale,
|
||||
StripeServiceAccountPlanId = p.SecretsManager?.StripeServiceAccountPlanId,
|
||||
AdditionalPricePerServiceAccount = p.SecretsManager?.AdditionalPricePerServiceAccount,
|
||||
BaseServiceAccount = p.SecretsManager?.BaseServiceAccount,
|
||||
MaxAdditionalServiceAccount = p.SecretsManager?.MaxAdditionalServiceAccount,
|
||||
HasAdditionalServiceAccountOption = p.SecretsManager?.HasAdditionalServiceAccountOption,
|
||||
StripeSeatPlanId = p.SecretsManager?.StripeSeatPlanId,
|
||||
HasAdditionalSeatsOption = p.SecretsManager?.HasAdditionalSeatsOption,
|
||||
BasePrice = p.SecretsManager?.BasePrice,
|
||||
SeatPrice = p.SecretsManager?.SeatPrice,
|
||||
BaseSeats = p.SecretsManager?.BaseSeats,
|
||||
MaxSeats = p.SecretsManager?.MaxSeats,
|
||||
MaxAdditionalSeats = p.SecretsManager?.MaxAdditionalSeats,
|
||||
AllowSeatAutoscale = p.SecretsManager?.AllowSeatAutoscale,
|
||||
MaxProjects = p.SecretsManager?.MaxProjects
|
||||
}
|
||||
};
|
||||
return plan;
|
||||
});
|
||||
|
||||
public Organization CreateOrganization(Provider provider, bool flexibleCollectionsSignupEnabled, bool flexibleCollectionsV1Enabled)
|
||||
public Organization CreateOrganization(Provider provider)
|
||||
{
|
||||
BillingEmail = provider.BillingEmail;
|
||||
|
||||
var newOrg = new Organization
|
||||
{
|
||||
// This feature flag indicates that new organizations should be automatically onboarded to
|
||||
// Flexible Collections enhancements
|
||||
FlexibleCollections = flexibleCollectionsSignupEnabled,
|
||||
// These collection management settings smooth the migration for existing organizations by disabling some FC behavior.
|
||||
// If the organization is onboarded to Flexible Collections on signup, we turn them OFF to enable all new behaviour.
|
||||
// If the organization is NOT onboarded now, they will have to be migrated later, so they default to ON to limit FC changes on migration.
|
||||
LimitCollectionCreationDeletion = !flexibleCollectionsSignupEnabled,
|
||||
AllowAdminAccessToAllCollectionItems = !flexibleCollectionsV1Enabled
|
||||
};
|
||||
return ToOrganization(newOrg);
|
||||
return ToOrganization(new Organization());
|
||||
}
|
||||
|
||||
public Organization ToOrganization(Organization existingOrganization)
|
||||
|
@ -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; }
|
||||
}
|
@ -69,14 +69,4 @@ public class OrganizationViewModel
|
||||
public int ServiceAccountsCount { get; set; }
|
||||
public int OccupiedSmSeatsCount { get; set; }
|
||||
public bool UseSecretsManager => Organization.UseSecretsManager;
|
||||
|
||||
public string GetCollectionManagementSetting(bool collectionManagementSetting)
|
||||
{
|
||||
if (!Organization.FlexibleCollections)
|
||||
{
|
||||
return "N/A";
|
||||
}
|
||||
|
||||
return collectionManagementSetting ? "On" : "Off";
|
||||
}
|
||||
}
|
||||
|
@ -2,6 +2,7 @@
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Models.Data.Provider;
|
||||
using Bit.Core.Billing.Entities;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Enums;
|
||||
|
||||
namespace Bit.Admin.AdminConsole.Models;
|
||||
@ -14,7 +15,9 @@ public class ProviderEditModel : ProviderViewModel
|
||||
Provider provider,
|
||||
IEnumerable<ProviderUserUserDetails> providerUsers,
|
||||
IEnumerable<ProviderOrganizationOrganizationDetails> organizations,
|
||||
IReadOnlyCollection<ProviderPlan> providerPlans) : base(provider, providerUsers, organizations)
|
||||
IReadOnlyCollection<ProviderPlan> providerPlans,
|
||||
string gatewayCustomerUrl = null,
|
||||
string gatewaySubscriptionUrl = null) : base(provider, providerUsers, organizations)
|
||||
{
|
||||
Name = provider.DisplayName();
|
||||
BusinessName = provider.DisplayBusinessName();
|
||||
@ -25,6 +28,8 @@ public class ProviderEditModel : ProviderViewModel
|
||||
Gateway = provider.Gateway;
|
||||
GatewayCustomerId = provider.GatewayCustomerId;
|
||||
GatewaySubscriptionId = provider.GatewaySubscriptionId;
|
||||
GatewayCustomerUrl = gatewayCustomerUrl;
|
||||
GatewaySubscriptionUrl = gatewaySubscriptionUrl;
|
||||
}
|
||||
|
||||
[Display(Name = "Billing Email")]
|
||||
@ -45,6 +50,8 @@ public class ProviderEditModel : ProviderViewModel
|
||||
public string GatewayCustomerId { get; set; }
|
||||
[Display(Name = "Gateway Subscription Id")]
|
||||
public string GatewaySubscriptionId { get; set; }
|
||||
public string GatewayCustomerUrl { get; }
|
||||
public string GatewaySubscriptionUrl { get; }
|
||||
|
||||
public virtual Provider ToProvider(Provider existingProvider)
|
||||
{
|
||||
|
@ -1,5 +1,6 @@
|
||||
@using Bit.Admin.Enums;
|
||||
@using Bit.Admin.Enums;
|
||||
@using Bit.Admin.Models
|
||||
@using Bit.Core.Billing.Enums
|
||||
@using Bit.Core.Enums
|
||||
@inject Bit.Admin.Services.IAccessControlService AccessControlService
|
||||
@model OrganizationEditModel
|
||||
@ -18,24 +19,43 @@
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
document.getElementById('teams-trial').addEventListener('click', () => {
|
||||
if (document.getElementById('@(nameof(Model.PlanType))').value !== '@((byte)PlanType.Free)') {
|
||||
alert('Organization is not on a free plan.');
|
||||
return;
|
||||
}
|
||||
setTrialDefaults('@((byte)PlanType.TeamsAnnually)');
|
||||
togglePlanFeatures('@((byte)PlanType.TeamsAnnually)');
|
||||
document.getElementById('@(nameof(Model.Plan))').value = 'Teams (Trial)';
|
||||
});
|
||||
document.getElementById('enterprise-trial').addEventListener('click', () => {
|
||||
if (document.getElementById('@(nameof(Model.PlanType))').value !== '@((byte)PlanType.Free)') {
|
||||
alert('Organization is not on a free plan.');
|
||||
return;
|
||||
}
|
||||
setTrialDefaults('@((byte)PlanType.EnterpriseAnnually)');
|
||||
togglePlanFeatures('@((byte)PlanType.EnterpriseAnnually)');
|
||||
document.getElementById('@(nameof(Model.Plan))').value = 'Enterprise (Trial)';
|
||||
});
|
||||
const treamsTrialButton = document.getElementById('teams-trial');
|
||||
if (treamsTrialButton != null) {
|
||||
treamsTrialButton.addEventListener('click', () => {
|
||||
if (document.getElementById('@(nameof(Model.PlanType))').value !== '@((byte)PlanType.Free)') {
|
||||
alert('Organization is not on a free plan.');
|
||||
return;
|
||||
}
|
||||
setTrialDefaults('@((byte)PlanType.TeamsAnnually)');
|
||||
togglePlanFeatures('@((byte)PlanType.TeamsAnnually)');
|
||||
document.getElementById('@(nameof(Model.Plan))').value = 'Teams (Trial)';
|
||||
});
|
||||
}
|
||||
|
||||
const entTrialButton = document.getElementById('enterprise-trial');
|
||||
if (entTrialButton != null) {
|
||||
entTrialButton.addEventListener('click', () => {
|
||||
if (document.getElementById('@(nameof(Model.PlanType))').value !== '@((byte)PlanType.Free)') {
|
||||
alert('Organization is not on a free plan.');
|
||||
return;
|
||||
}
|
||||
setTrialDefaults('@((byte)PlanType.EnterpriseAnnually)');
|
||||
togglePlanFeatures('@((byte)PlanType.EnterpriseAnnually)');
|
||||
document.getElementById('@(nameof(Model.Plan))').value = 'Enterprise (Trial)';
|
||||
});
|
||||
}
|
||||
|
||||
const initDeleteButton = document.getElementById('initiate-delete-form');
|
||||
if (initDeleteButton != null) {
|
||||
initDeleteButton.addEventListener('submit', (e) => {
|
||||
const email = prompt('Enter the email address of the owner/admin that your want to ' +
|
||||
'request the organization delete verification process with.');
|
||||
document.getElementById('AdminEmail').value = email;
|
||||
if (email == null || email === '') {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setTrialDefaults(planType) {
|
||||
// Plan
|
||||
@ -76,7 +96,7 @@
|
||||
{
|
||||
<h2>Billing Information</h2>
|
||||
@await Html.PartialAsync("_BillingInformation",
|
||||
new BillingInformationModel { BillingInfo = Model.BillingInfo, OrganizationId = Model.Organization.Id, Entity = "Organization" })
|
||||
new BillingInformationModel { BillingInfo = Model.BillingInfo, BillingHistoryInfo = Model.BillingHistoryInfo, OrganizationId = Model.Organization.Id, Entity = "Organization" })
|
||||
}
|
||||
|
||||
@await Html.PartialAsync("~/AdminConsole/Views/Shared/_OrganizationForm.cshtml", Model)
|
||||
@ -95,18 +115,20 @@
|
||||
}
|
||||
@if (canUnlinkFromProvider && Model.Provider is not null)
|
||||
{
|
||||
<button
|
||||
class="btn btn-outline-danger mr-2"
|
||||
onclick="return unlinkProvider('@Model.Organization.Id');"
|
||||
>
|
||||
<button class="btn btn-outline-danger mr-2"
|
||||
onclick="return unlinkProvider('@Model.Organization.Id');">
|
||||
Unlink provider
|
||||
</button>
|
||||
}
|
||||
@if (canDelete)
|
||||
{
|
||||
<form asp-action="DeleteInitiation" asp-route-id="@Model.Organization.Id" id="initiate-delete-form">
|
||||
<input type="hidden" name="AdminEmail" id="AdminEmail" />
|
||||
<button class="btn btn-danger mr-2" type="submit">Request Delete</button>
|
||||
</form>
|
||||
<form asp-action="Delete" asp-route-id="@Model.Organization.Id"
|
||||
onsubmit="return confirm('Are you sure you want to delete this organization?')">
|
||||
<button class="btn btn-danger" type="submit">Delete</button>
|
||||
onsubmit="return confirm('Are you sure you want to hard delete this organization?')">
|
||||
<button class="btn btn-outline-danger" type="submit">Delete</button>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
|
@ -50,14 +50,11 @@
|
||||
<dt class="col-sm-4 col-lg-3">Collections</dt>
|
||||
<dd class="col-sm-8 col-lg-9">@Model.CollectionCount</dd>
|
||||
|
||||
<dt class="col-sm-4 col-lg-3">Collection management enhancements</dt>
|
||||
<dd class="col-sm-8 col-lg-9">@(Model.Organization.FlexibleCollections ? "On" : "Off")</dd>
|
||||
|
||||
<dt class="col-sm-4 col-lg-3">Administrators manage all collections</dt>
|
||||
<dd class="col-sm-8 col-lg-9">@(Model.GetCollectionManagementSetting(Model.Organization.AllowAdminAccessToAllCollectionItems))</dd>
|
||||
<dd class="col-sm-8 col-lg-9">@(Model.Organization.AllowAdminAccessToAllCollectionItems ? "On" : "Off")</dd>
|
||||
|
||||
<dt class="col-sm-4 col-lg-3">Limit collection creation to administrators</dt>
|
||||
<dd class="col-sm-8 col-lg-9">@(Model.GetCollectionManagementSetting(Model.Organization.LimitCollectionCreationDeletion))</dd>
|
||||
<dd class="col-sm-8 col-lg-9">@(Model.Organization.LimitCollectionCreationDeletion ? "On" : "Off")</dd>
|
||||
</dl>
|
||||
|
||||
<h2>Secrets Manager</h2>
|
||||
|
@ -78,9 +78,9 @@
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" asp-for="GatewayCustomerId">
|
||||
<div class="input-group-append">
|
||||
<button class="btn btn-secondary" type="button" id="gateway-customer-link">
|
||||
<a href="@Model.GatewayCustomerUrl" class="btn btn-secondary" target="_blank">
|
||||
<i class="fa fa-external-link"></i>
|
||||
</button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -91,9 +91,9 @@
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" asp-for="GatewaySubscriptionId">
|
||||
<div class="input-group-append">
|
||||
<button class="btn btn-secondary" type="button" id="gateway-subscription-link">
|
||||
<a href="@Model.GatewaySubscriptionUrl" class="btn btn-secondary" target="_blank">
|
||||
<i class="fa fa-external-link"></i>
|
||||
</button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,6 +1,7 @@
|
||||
@using Bit.Admin.Enums;
|
||||
@using Bit.Core.Enums
|
||||
@using Bit.Core.AdminConsole.Enums.Provider
|
||||
@using Bit.Core.Billing.Enums
|
||||
@using Bit.SharedWeb.Utilities
|
||||
@inject Bit.Admin.Services.IAccessControlService AccessControlService;
|
||||
|
||||
@ -70,9 +71,10 @@
|
||||
@{
|
||||
var planTypes = Enum.GetValues<PlanType>()
|
||||
.Where(p =>
|
||||
Model.Provider == null ||
|
||||
(Model.Provider != null
|
||||
&& p is >= PlanType.TeamsMonthly2019 and <= PlanType.EnterpriseAnnually2019 or >= PlanType.TeamsMonthly2020 and <= PlanType.EnterpriseAnnually)
|
||||
(Model.Provider == null ||
|
||||
p is >= PlanType.TeamsMonthly2019 and <= PlanType.EnterpriseAnnually2019 or
|
||||
>= PlanType.TeamsMonthly2020 and <= PlanType.EnterpriseAnnually) &&
|
||||
(Model.PlanType == PlanType.TeamsStarter || p is not PlanType.TeamsStarter)
|
||||
)
|
||||
.Select(e => new SelectListItem
|
||||
{
|
||||
|
@ -1,6 +1,8 @@
|
||||
@inject IWebHostEnvironment HostingEnvironment
|
||||
@using Bit.Admin.Utilities
|
||||
@using Bit.Core.Billing.Enums
|
||||
@using Bit.Core.Enums
|
||||
@using Bit.Core.Utilities
|
||||
@model OrganizationEditModel
|
||||
|
||||
<script>
|
||||
@ -52,55 +54,38 @@
|
||||
})();
|
||||
|
||||
function togglePlanFeatures(planType) {
|
||||
switch(planType) {
|
||||
case '@((byte)PlanType.TeamsMonthly2019)':
|
||||
case '@((byte)PlanType.TeamsAnnually2019)':
|
||||
case '@((byte)PlanType.TeamsMonthly2020)':
|
||||
case '@((byte)PlanType.TeamsAnnually2020)':
|
||||
case '@((byte)PlanType.TeamsMonthly2023)':
|
||||
case '@((byte)PlanType.TeamsAnnually2023)':
|
||||
case '@((byte)PlanType.TeamsMonthly)':
|
||||
case '@((byte)PlanType.TeamsAnnually)':
|
||||
case '@((byte)PlanType.TeamsStarter2023)':
|
||||
case '@((byte)PlanType.TeamsStarter)':
|
||||
document.getElementById('@(nameof(Model.UsePolicies))').checked = false;
|
||||
document.getElementById('@(nameof(Model.UseSso))').checked = false;
|
||||
document.getElementById('@(nameof(Model.UseGroups))').checked = true;
|
||||
document.getElementById('@(nameof(Model.UseDirectory))').checked = true;
|
||||
document.getElementById('@(nameof(Model.UseEvents))').checked = true;
|
||||
document.getElementById('@(nameof(Model.UsersGetPremium))').checked = true;
|
||||
document.getElementById('@(nameof(Model.UseCustomPermissions))').checked = false;
|
||||
document.getElementById('@(nameof(Model.UseTotp))').checked = true;
|
||||
document.getElementById('@(nameof(Model.Use2fa))').checked = true;
|
||||
document.getElementById('@(nameof(Model.UseApi))').checked = true;
|
||||
document.getElementById('@(nameof(Model.SelfHost))').checked = false;
|
||||
document.getElementById('@(nameof(Model.UseResetPassword))').checked = false;
|
||||
document.getElementById('@(nameof(Model.UseScim))').checked = false;
|
||||
break;
|
||||
const plan = getPlan(planType);
|
||||
|
||||
case '@((byte)PlanType.EnterpriseMonthly2019)':
|
||||
case '@((byte)PlanType.EnterpriseAnnually2019)':
|
||||
case '@((byte)PlanType.EnterpriseMonthly2020)':
|
||||
case '@((byte)PlanType.EnterpriseAnnually2020)':
|
||||
case '@((byte)PlanType.EnterpriseMonthly2023)':
|
||||
case '@((byte)PlanType.EnterpriseAnnually2023)':
|
||||
case '@((byte)PlanType.EnterpriseMonthly)':
|
||||
case '@((byte)PlanType.EnterpriseAnnually)':
|
||||
document.getElementById('@(nameof(Model.UsePolicies))').checked = true;
|
||||
document.getElementById('@(nameof(Model.UseSso))').checked = true;
|
||||
document.getElementById('@(nameof(Model.UseGroups))').checked = true;
|
||||
document.getElementById('@(nameof(Model.UseDirectory))').checked = true;
|
||||
document.getElementById('@(nameof(Model.UseEvents))').checked = true;
|
||||
document.getElementById('@(nameof(Model.UsersGetPremium))').checked = true;
|
||||
document.getElementById('@(nameof(Model.UseCustomPermissions))').checked = true;
|
||||
document.getElementById('@(nameof(Model.UseTotp))').checked = true;
|
||||
document.getElementById('@(nameof(Model.Use2fa))').checked = true;
|
||||
document.getElementById('@(nameof(Model.UseApi))').checked = true;
|
||||
document.getElementById('@(nameof(Model.SelfHost))').checked = true;
|
||||
document.getElementById('@(nameof(Model.UseResetPassword))').checked = true;
|
||||
document.getElementById('@(nameof(Model.UseScim))').checked = true;
|
||||
break;
|
||||
if (!plan) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(plan);
|
||||
|
||||
document.getElementById('@(nameof(Model.SelfHost))').checked = plan.hasSelfHost;
|
||||
|
||||
document.getElementById('@(nameof(Model.Use2fa))').checked = plan.has2fa;
|
||||
document.getElementById('@(nameof(Model.UseApi))').checked = plan.hasApi;
|
||||
document.getElementById('@(nameof(Model.UseGroups))').checked = plan.hasGroups;
|
||||
document.getElementById('@(nameof(Model.UsePolicies))').checked = plan.hasPolicies;
|
||||
document.getElementById('@(nameof(Model.UseSso))').checked = plan.hasSso;
|
||||
document.getElementById('@(nameof(Model.UseScim))').checked = plan.hasScim;
|
||||
document.getElementById('@(nameof(Model.UseDirectory))').checked = plan.hasDirectory;
|
||||
document.getElementById('@(nameof(Model.UseEvents))').checked = plan.hasEvents;
|
||||
document.getElementById('@(nameof(Model.UseResetPassword))').checked = plan.hasResetPassword;
|
||||
document.getElementById('@(nameof(Model.UseCustomPermissions))').checked = plan.hasCustomPermissions;
|
||||
// use key connector is intentionally omitted
|
||||
|
||||
document.getElementById('@(nameof(Model.UseTotp))').checked = plan.hasTotp;
|
||||
document.getElementById('@(nameof(Model.UsersGetPremium))').checked = plan.usersGetPremium;
|
||||
|
||||
document.getElementById('@(nameof(Model.MaxStorageGb))').value =
|
||||
document.getElementById('@(nameof(Model.MaxStorageGb))').value ||
|
||||
plan.passwordManager.baseStorageGb ||
|
||||
1;
|
||||
document.getElementById('@(nameof(Model.Seats))').value = document.getElementById('@(nameof(Model.Seats))').value ||
|
||||
plan.passwordManager.baseSeats ||
|
||||
1;
|
||||
}
|
||||
|
||||
function unlinkProvider(id) {
|
||||
@ -133,7 +118,7 @@
|
||||
document.getElementById('@(nameof(Model.SmSeats))').value = Math.max(@Model.OccupiedSmSeatsCount, 1);
|
||||
|
||||
// Service accounts
|
||||
const baseServiceAccounts = getPlan(planType)?.baseServiceAccount ?? 0;
|
||||
const baseServiceAccounts = getPlan(planType)?.secretsManager?.baseServiceAccount ?? 0;
|
||||
if (planType !== '@((byte)PlanType.Free)' && @Model.ServiceAccountsCount > baseServiceAccounts) {
|
||||
document.getElementById('@(nameof(Model.SmServiceAccounts))').value = @Model.ServiceAccountsCount;
|
||||
} else {
|
||||
|
@ -13,6 +13,7 @@ using Bit.Core.Settings;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TaxRate = Bit.Core.Entities.TaxRate;
|
||||
|
||||
namespace Bit.Admin.Controllers;
|
||||
|
||||
@ -518,8 +519,17 @@ public class ToolsController : Controller
|
||||
{
|
||||
model.Filter.StartingAfter = null;
|
||||
}
|
||||
|
||||
if (model.Action == StripeSubscriptionsAction.NextPage || model.Action == StripeSubscriptionsAction.Search)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(model.Filter.StartingAfter))
|
||||
{
|
||||
var subscription = await _stripeAdapter.SubscriptionGetAsync(model.Filter.StartingAfter);
|
||||
if (subscription.Status == "canceled")
|
||||
{
|
||||
model.Filter.StartingAfter = null;
|
||||
}
|
||||
}
|
||||
model.Filter.EndingBefore = null;
|
||||
}
|
||||
}
|
||||
|
@ -3,6 +3,7 @@ using Bit.Admin.Models;
|
||||
using Bit.Admin.Services;
|
||||
using Bit.Admin.Utilities;
|
||||
using Bit.Core;
|
||||
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Repositories;
|
||||
@ -25,9 +26,7 @@ public class UsersController : Controller
|
||||
private readonly IAccessControlService _accessControlService;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
private readonly IFeatureService _featureService;
|
||||
|
||||
private bool UseFlexibleCollections =>
|
||||
_featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections);
|
||||
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
|
||||
|
||||
public UsersController(
|
||||
IUserRepository userRepository,
|
||||
@ -36,7 +35,8 @@ public class UsersController : Controller
|
||||
GlobalSettings globalSettings,
|
||||
IAccessControlService accessControlService,
|
||||
ICurrentContext currentContext,
|
||||
IFeatureService featureService)
|
||||
IFeatureService featureService,
|
||||
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
_cipherRepository = cipherRepository;
|
||||
@ -45,6 +45,7 @@ public class UsersController : Controller
|
||||
_accessControlService = accessControlService;
|
||||
_currentContext = currentContext;
|
||||
_featureService = featureService;
|
||||
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
|
||||
}
|
||||
|
||||
[RequirePermission(Permission.User_List_View)]
|
||||
@ -62,6 +63,12 @@ public class UsersController : Controller
|
||||
|
||||
var skip = (page - 1) * count;
|
||||
var users = await _userRepository.SearchAsync(email, skip, count);
|
||||
|
||||
if (_featureService.IsEnabled(FeatureFlagKeys.MembersTwoFAQueryOptimization))
|
||||
{
|
||||
TempData["UsersTwoFactorIsEnabled"] = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(users.Select(u => u.Id));
|
||||
}
|
||||
|
||||
return View(new UsersModel
|
||||
{
|
||||
Items = users as List<User>,
|
||||
@ -80,7 +87,7 @@ public class UsersController : Controller
|
||||
return RedirectToAction("Index");
|
||||
}
|
||||
|
||||
var ciphers = await _cipherRepository.GetManyByUserIdAsync(id, useFlexibleCollections: UseFlexibleCollections);
|
||||
var ciphers = await _cipherRepository.GetManyByUserIdAsync(id);
|
||||
return View(new UserViewModel(user, ciphers));
|
||||
}
|
||||
|
||||
@ -93,9 +100,10 @@ public class UsersController : Controller
|
||||
return RedirectToAction("Index");
|
||||
}
|
||||
|
||||
var ciphers = await _cipherRepository.GetManyByUserIdAsync(id, useFlexibleCollections: UseFlexibleCollections);
|
||||
var ciphers = await _cipherRepository.GetManyByUserIdAsync(id);
|
||||
var billingInfo = await _paymentService.GetBillingAsync(user);
|
||||
return View(new UserEditModel(user, ciphers, billingInfo, _globalSettings));
|
||||
var billingHistoryInfo = await _paymentService.GetBillingHistoryAsync(user);
|
||||
return View(new UserEditModel(user, ciphers, billingInfo, billingHistoryInfo, _globalSettings));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
|
@ -1,10 +1,11 @@
|
||||
using Bit.Core.Models.Business;
|
||||
using Bit.Core.Billing.Models;
|
||||
|
||||
namespace Bit.Admin.Models;
|
||||
|
||||
public class BillingInformationModel
|
||||
{
|
||||
public BillingInfo BillingInfo { get; set; }
|
||||
public BillingHistoryInfo BillingHistoryInfo { get; set; }
|
||||
public Guid? UserId { get; set; }
|
||||
public Guid? OrganizationId { get; set; }
|
||||
public string Entity { get; set; }
|
||||
|
@ -1,6 +1,6 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Models.Business;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Core.Vault.Entities;
|
||||
@ -11,11 +11,16 @@ public class UserEditModel : UserViewModel
|
||||
{
|
||||
public UserEditModel() { }
|
||||
|
||||
public UserEditModel(User user, IEnumerable<Cipher> ciphers, BillingInfo billingInfo,
|
||||
public UserEditModel(
|
||||
User user,
|
||||
IEnumerable<Cipher> ciphers,
|
||||
BillingInfo billingInfo,
|
||||
BillingHistoryInfo billingHistoryInfo,
|
||||
GlobalSettings globalSettings)
|
||||
: base(user, ciphers)
|
||||
{
|
||||
BillingInfo = billingInfo;
|
||||
BillingHistoryInfo = billingHistoryInfo;
|
||||
BraintreeMerchantId = globalSettings.Braintree.MerchantId;
|
||||
|
||||
Name = user.Name;
|
||||
@ -31,6 +36,7 @@ public class UserEditModel : UserViewModel
|
||||
}
|
||||
|
||||
public BillingInfo BillingInfo { get; set; }
|
||||
public BillingHistoryInfo BillingHistoryInfo { get; set; }
|
||||
public string RandomLicenseKey => CoreHelpers.SecureRandomString(20);
|
||||
public string OneYearExpirationDate => DateTime.Now.AddYears(1).ToString("yyyy-MM-ddTHH:mm");
|
||||
public string BraintreeMerchantId { get; set; }
|
||||
|
@ -1,4 +1,4 @@
|
||||
@import "webfonts.css";
|
||||
@import "webfonts.scss";
|
||||
|
||||
$primary: #175DDC;
|
||||
$primary-accent: #1252A3;
|
||||
@ -17,7 +17,7 @@ $h4-font-size: 1rem;
|
||||
$h5-font-size: 1rem;
|
||||
$h6-font-size: 1rem;
|
||||
|
||||
@import "../node_modules/bootstrap/scss/bootstrap.scss";
|
||||
@import "bootstrap/scss/bootstrap.scss";
|
||||
|
||||
h1 {
|
||||
border-bottom: 1px solid $border-color;
|
||||
|
@ -3,10 +3,10 @@
|
||||
@model BillingInformationModel
|
||||
|
||||
@{
|
||||
var canManageTransactions = Model.Entity == "User" ? AccessControlService.UserHasPermission(Permission.User_BillingInformation_CreateEditTransaction)
|
||||
var canManageTransactions = Model.Entity == "User" ? AccessControlService.UserHasPermission(Permission.User_BillingInformation_CreateEditTransaction)
|
||||
: AccessControlService.UserHasPermission(Permission.Org_BillingInformation_CreateEditTransaction);
|
||||
|
||||
var canDownloadInvoice = Model.Entity == "User" ? AccessControlService.UserHasPermission(Permission.User_BillingInformation_DownloadInvoice)
|
||||
var canDownloadInvoice = Model.Entity == "User" ? AccessControlService.UserHasPermission(Permission.User_BillingInformation_DownloadInvoice)
|
||||
: AccessControlService.UserHasPermission(Permission.Org_BillingInformation_DownloadInvoice);
|
||||
}
|
||||
|
||||
@ -16,11 +16,11 @@
|
||||
|
||||
<dt class="col-sm-4 col-lg-3">Invoices</dt>
|
||||
<dd class="col-sm-8 col-lg-9">
|
||||
@if(Model.BillingInfo.Invoices?.Any() ?? false)
|
||||
@if(Model.BillingHistoryInfo.Invoices?.Any() ?? false)
|
||||
{
|
||||
<table class="table">
|
||||
<tbody>
|
||||
@foreach(var invoice in Model.BillingInfo.Invoices)
|
||||
@foreach(var invoice in Model.BillingHistoryInfo.Invoices)
|
||||
{
|
||||
<tr>
|
||||
<td>@invoice.Date</td>
|
||||
@ -28,7 +28,7 @@
|
||||
</td>
|
||||
<td>@invoice.Amount.ToString("C")</td>
|
||||
<td>@(invoice.Paid ? "Paid" : "Unpaid")</td>
|
||||
@if (canDownloadInvoice)
|
||||
@if (canDownloadInvoice)
|
||||
{
|
||||
<td>
|
||||
<a target="_blank" rel="noreferrer" href="@invoice.PdfUrl" title="Download Invoice">
|
||||
@ -49,11 +49,11 @@
|
||||
|
||||
<dt class="col-sm-4 col-lg-3">Transactions</dt>
|
||||
<dd class="col-sm-8 col-lg-9">
|
||||
@if(Model.BillingInfo.Transactions?.Any() ?? false)
|
||||
@if(Model.BillingHistoryInfo.Transactions?.Any() ?? false)
|
||||
{
|
||||
<table class="table">
|
||||
<tbody>
|
||||
@foreach(var transaction in Model.BillingInfo.Transactions)
|
||||
@foreach(var transaction in Model.BillingHistoryInfo.Transactions)
|
||||
{
|
||||
<tr>
|
||||
<td>@transaction.CreatedDate</td>
|
||||
|
@ -27,17 +27,7 @@
|
||||
<meta name="robots" content="noindex,nofollow" />
|
||||
<title>@ViewData["Title"] - Bitwarden Admin Portal</title>
|
||||
|
||||
<link rel="stylesheet" href="~/css/webfonts.css" />
|
||||
<environment include="Development">
|
||||
<link rel="stylesheet" href="~/lib/font-awesome/css/font-awesome.css" />
|
||||
<link rel="stylesheet" href="~/css/site.css" />
|
||||
<link rel="stylesheet" href="~/lib/toastr/toastr.css" />
|
||||
</environment>
|
||||
<environment exclude="Development">
|
||||
<link rel="stylesheet" href="~/lib/font-awesome/css/font-awesome.min.css" asp-append-version="true" />
|
||||
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
|
||||
<link rel="stylesheet" href="~/lib/toastr/toastr.min.css" />
|
||||
</environment>
|
||||
<link rel="stylesheet" href="~/assets/site.css" asp-append-version="true" />
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-md navbar-dark bg-dark mb-4">
|
||||
@ -153,18 +143,7 @@
|
||||
© @DateTime.Now.Year, Bitwarden Inc.
|
||||
</footer>
|
||||
|
||||
<environment include="Development">
|
||||
<script src="~/lib/jquery/jquery.js"></script>
|
||||
<script src="~/lib/popper/popper.js"></script>
|
||||
<script src="~/lib/bootstrap/js/bootstrap.js"></script>
|
||||
<script src="~/lib/toastr/toastr.min.js"></script>
|
||||
</environment>
|
||||
<environment exclude="Development">
|
||||
<script src="~/lib/jquery/jquery.min.js" asp-append-version="true"></script>
|
||||
<script src="~/lib/popper/popper.min.js" asp-append-version="true"></script>
|
||||
<script src="~/lib/bootstrap/js/bootstrap.min.js" asp-append-version="true"></script>
|
||||
<script src="~/lib/toastr/toastr.min.js" asp-append-version="true"></script>
|
||||
</environment>
|
||||
<script src="~/assets/site.js" asp-append-version="true"></script>
|
||||
|
||||
@if (TempData["Error"] != null)
|
||||
{
|
||||
@ -174,6 +153,14 @@
|
||||
});
|
||||
</script>
|
||||
}
|
||||
@if (TempData["Success"] != null)
|
||||
{
|
||||
<script>
|
||||
$(document).ready(function () {
|
||||
toastr.success("@TempData["Success"]")
|
||||
});
|
||||
</script>
|
||||
}
|
||||
|
||||
@RenderSection("Scripts", required: false)
|
||||
</body>
|
||||
|
@ -109,7 +109,7 @@
|
||||
<option asp-selected="Model.Filter.Price == null" value="@null">All</option>
|
||||
@foreach (var price in Model.Prices)
|
||||
{
|
||||
<option asp-selected='@Model.Filter.Price == @price.Id' value="@price.Id">@price.Id</option>
|
||||
<option asp-selected='@(Model.Filter.Price == price.Id)' value="@price.Id">@price.Id</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
@ -119,7 +119,7 @@
|
||||
<option asp-selected="Model.Filter.TestClock == null" value="@null">All</option>
|
||||
@foreach (var clock in Model.TestClocks)
|
||||
{
|
||||
<option asp-selected='@Model.Filter.TestClock == @clock.Id' value="@clock.Id">@clock.Name</option>
|
||||
<option asp-selected='@(Model.Filter.TestClock == clock.Id)' value="@clock.Id">@clock.Name</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
@ -149,7 +149,7 @@
|
||||
<th>Id</th>
|
||||
<th>Customer Email</th>
|
||||
<th>Status</th>
|
||||
<th>Product</th>
|
||||
<th>Product Tier</th>
|
||||
<th>Current Period End</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@ -278,4 +278,4 @@
|
||||
</span>
|
||||
</span>
|
||||
</nav>
|
||||
</form>
|
||||
</form>
|
||||
|
@ -92,7 +92,7 @@
|
||||
{
|
||||
<h2>Billing Information</h2>
|
||||
@await Html.PartialAsync("_BillingInformation",
|
||||
new BillingInformationModel { BillingInfo = Model.BillingInfo, UserId = Model.User.Id, Entity = "User" })
|
||||
new BillingInformationModel { BillingInfo = Model.BillingInfo, BillingHistoryInfo = Model.BillingHistoryInfo, UserId = Model.User.Id, Entity = "User" })
|
||||
}
|
||||
@if (canViewGeneral)
|
||||
{
|
||||
|
@ -1,5 +1,6 @@
|
||||
@model UsersModel
|
||||
@inject Bit.Core.Services.IUserService userService
|
||||
@inject Bit.Core.Services.IFeatureService featureService
|
||||
@{
|
||||
ViewData["Title"] = "Users";
|
||||
}
|
||||
@ -69,13 +70,28 @@
|
||||
{
|
||||
<i class="fa fa-times-circle-o fa-lg fa-fw text-muted" title="Email Not Verified"></i>
|
||||
}
|
||||
@if(await userService.TwoFactorIsEnabledAsync(user))
|
||||
@if (featureService.IsEnabled(Bit.Core.FeatureFlagKeys.MembersTwoFAQueryOptimization))
|
||||
{
|
||||
<i class="fa fa-lock fa-lg fa-fw" title="2FA Enabled"></i>
|
||||
var usersTwoFactorIsEnabled = TempData["UsersTwoFactorIsEnabled"] as IEnumerable<(Guid userId, bool twoFactorIsEnabled)>;
|
||||
@if(usersTwoFactorIsEnabled.FirstOrDefault(tuple => tuple.userId == user.Id).twoFactorIsEnabled)
|
||||
{
|
||||
<i class="fa fa-lock fa-lg fa-fw" title="2FA Enabled"></i>
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="fa fa-unlock fa-lg fa-fw text-muted" title="2FA Not Enabled"></i>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="fa fa-unlock fa-lg fa-fw text-muted" title="2FA Not Enabled"></i>
|
||||
@if(await userService.TwoFactorIsEnabledAsync(user))
|
||||
{
|
||||
<i class="fa fa-lock fa-lg fa-fw" title="2FA Enabled"></i>
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="fa fa-unlock fa-lg fa-fw text-muted" title="2FA Not Enabled"></i>
|
||||
}
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user