mirror of
https://github.com/bitwarden/server
synced 2025-12-19 17:53:44 +00:00
Merge branch 'main' into SM-1571-DisableSMAdsForUsers
This commit is contained in:
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
@@ -36,6 +36,7 @@ util/Setup/** @bitwarden/dept-bre @bitwarden/team-platform-dev
|
|||||||
|
|
||||||
# UIF
|
# UIF
|
||||||
src/Core/MailTemplates/Mjml @bitwarden/team-ui-foundation # Teams are expected to own sub-directories of this project
|
src/Core/MailTemplates/Mjml @bitwarden/team-ui-foundation # Teams are expected to own sub-directories of this project
|
||||||
|
src/Core/MailTemplates/Mjml/.mjmlconfig # This change allows teams to add components within their own subdirectories without requiring a code review from UIF.
|
||||||
|
|
||||||
# Auth team
|
# Auth team
|
||||||
**/Auth @bitwarden/team-auth-dev
|
**/Auth @bitwarden/team-auth-dev
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
name: Bitwarden Unified Deployment Bug Report
|
name: Bitwarden lite Deployment Bug Report
|
||||||
description: File a bug report
|
description: File a bug report
|
||||||
labels: [bug, bw-unified-deploy]
|
labels: [bug, bw-lite-deploy]
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
@@ -74,7 +74,7 @@ body:
|
|||||||
id: epic-label
|
id: epic-label
|
||||||
attributes:
|
attributes:
|
||||||
label: Issue-Link
|
label: Issue-Link
|
||||||
description: Link to our pinned issue, tracking all Bitwarden Unified
|
description: Link to our pinned issue, tracking all Bitwarden lite
|
||||||
value: |
|
value: |
|
||||||
https://github.com/bitwarden/server/issues/2480
|
https://github.com/bitwarden/server/issues/2480
|
||||||
validations:
|
validations:
|
||||||
8
.github/renovate.json5
vendored
8
.github/renovate.json5
vendored
@@ -42,7 +42,7 @@
|
|||||||
dependencyDashboardApproval: false,
|
dependencyDashboardApproval: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
matchSourceUrls: ["https://github.com/bitwarden/sdk-internal"],
|
matchPackageNames: ["https://github.com/bitwarden/sdk-internal.git"],
|
||||||
groupName: "sdk-internal",
|
groupName: "sdk-internal",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -63,7 +63,6 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
matchPackageNames: [
|
matchPackageNames: [
|
||||||
"Azure.Extensions.AspNetCore.DataProtection.Blobs",
|
|
||||||
"DuoUniversal",
|
"DuoUniversal",
|
||||||
"Fido2.AspNet",
|
"Fido2.AspNet",
|
||||||
"Duende.IdentityServer",
|
"Duende.IdentityServer",
|
||||||
@@ -90,11 +89,7 @@
|
|||||||
"Microsoft.AspNetCore.Mvc.Testing",
|
"Microsoft.AspNetCore.Mvc.Testing",
|
||||||
"Newtonsoft.Json",
|
"Newtonsoft.Json",
|
||||||
"NSubstitute",
|
"NSubstitute",
|
||||||
"Sentry.Serilog",
|
|
||||||
"Serilog.AspNetCore",
|
|
||||||
"Serilog.Extensions.Logging",
|
|
||||||
"Serilog.Extensions.Logging.File",
|
"Serilog.Extensions.Logging.File",
|
||||||
"Serilog.Sinks.SyslogMessages",
|
|
||||||
"Stripe.net",
|
"Stripe.net",
|
||||||
"Swashbuckle.AspNetCore",
|
"Swashbuckle.AspNetCore",
|
||||||
"Swashbuckle.AspNetCore.SwaggerGen",
|
"Swashbuckle.AspNetCore.SwaggerGen",
|
||||||
@@ -141,6 +136,7 @@
|
|||||||
"AspNetCoreRateLimit",
|
"AspNetCoreRateLimit",
|
||||||
"AspNetCoreRateLimit.Redis",
|
"AspNetCoreRateLimit.Redis",
|
||||||
"Azure.Data.Tables",
|
"Azure.Data.Tables",
|
||||||
|
"Azure.Extensions.AspNetCore.DataProtection.Blobs",
|
||||||
"Azure.Messaging.EventGrid",
|
"Azure.Messaging.EventGrid",
|
||||||
"Azure.Messaging.ServiceBus",
|
"Azure.Messaging.ServiceBus",
|
||||||
"Azure.Storage.Blobs",
|
"Azure.Storage.Blobs",
|
||||||
|
|||||||
82
.github/workflows/build.yml
vendored
82
.github/workflows/build.yml
vendored
@@ -22,7 +22,7 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint:
|
||||||
name: Lint
|
name: Lint
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
@@ -38,7 +38,7 @@ jobs:
|
|||||||
|
|
||||||
build-artifacts:
|
build-artifacts:
|
||||||
name: Build Docker images
|
name: Build Docker images
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-22.04
|
||||||
needs:
|
needs:
|
||||||
- lint
|
- lint
|
||||||
outputs:
|
outputs:
|
||||||
@@ -46,6 +46,7 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
security-events: write
|
security-events: write
|
||||||
id-token: write
|
id-token: write
|
||||||
|
timeout-minutes: 45
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -122,7 +123,7 @@ jobs:
|
|||||||
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
|
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
|
||||||
|
|
||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||||
with:
|
with:
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
cache-dependency-path: "**/package-lock.json"
|
cache-dependency-path: "**/package-lock.json"
|
||||||
@@ -159,7 +160,7 @@ jobs:
|
|||||||
ls -atlh ../../../
|
ls -atlh ../../../
|
||||||
|
|
||||||
- name: Upload project artifact
|
- name: Upload project artifact
|
||||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||||
if: ${{ matrix.dotnet }}
|
if: ${{ matrix.dotnet }}
|
||||||
with:
|
with:
|
||||||
name: ${{ matrix.project_name }}.zip
|
name: ${{ matrix.project_name }}.zip
|
||||||
@@ -184,13 +185,6 @@ jobs:
|
|||||||
- name: Log in to ACR - production subscription
|
- name: Log in to ACR - production subscription
|
||||||
run: az acr login -n bitwardenprod
|
run: az acr login -n bitwardenprod
|
||||||
|
|
||||||
- name: Retrieve GitHub PAT secrets
|
|
||||||
id: retrieve-secret-pat
|
|
||||||
uses: bitwarden/gh-actions/get-keyvault-secrets@main
|
|
||||||
with:
|
|
||||||
keyvault: "bitwarden-ci"
|
|
||||||
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
|
|
||||||
|
|
||||||
########## Generate image tag and build Docker image ##########
|
########## Generate image tag and build Docker image ##########
|
||||||
- name: Generate Docker image tag
|
- name: Generate Docker image tag
|
||||||
id: tag
|
id: tag
|
||||||
@@ -249,8 +243,6 @@ jobs:
|
|||||||
linux/arm64
|
linux/arm64
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.image-tags.outputs.tags }}
|
tags: ${{ steps.image-tags.outputs.tags }}
|
||||||
secrets: |
|
|
||||||
"GH_PAT=${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}"
|
|
||||||
|
|
||||||
- name: Install Cosign
|
- name: Install Cosign
|
||||||
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
||||||
@@ -279,7 +271,7 @@ jobs:
|
|||||||
output-format: sarif
|
output-format: sarif
|
||||||
|
|
||||||
- name: Upload Grype results to GitHub
|
- name: Upload Grype results to GitHub
|
||||||
uses: github/codeql-action/upload-sarif@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3.28.8
|
uses: github/codeql-action/upload-sarif@e12f0178983d466f2f6028f5cc7a6d786fd97f4b # v4.31.4
|
||||||
with:
|
with:
|
||||||
sarif_file: ${{ steps.container-scan.outputs.sarif }}
|
sarif_file: ${{ steps.container-scan.outputs.sarif }}
|
||||||
sha: ${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.sha || github.sha }}
|
sha: ${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.sha || github.sha }}
|
||||||
@@ -290,7 +282,7 @@ jobs:
|
|||||||
|
|
||||||
upload:
|
upload:
|
||||||
name: Upload
|
name: Upload
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-22.04
|
||||||
needs: build-artifacts
|
needs: build-artifacts
|
||||||
permissions:
|
permissions:
|
||||||
id-token: write
|
id-token: write
|
||||||
@@ -364,7 +356,7 @@ jobs:
|
|||||||
if: |
|
if: |
|
||||||
github.event_name != 'pull_request'
|
github.event_name != 'pull_request'
|
||||||
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
|
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
|
||||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||||
with:
|
with:
|
||||||
name: docker-stub-US.zip
|
name: docker-stub-US.zip
|
||||||
path: docker-stub-US.zip
|
path: docker-stub-US.zip
|
||||||
@@ -374,7 +366,7 @@ jobs:
|
|||||||
if: |
|
if: |
|
||||||
github.event_name != 'pull_request'
|
github.event_name != 'pull_request'
|
||||||
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
|
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
|
||||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||||
with:
|
with:
|
||||||
name: docker-stub-EU.zip
|
name: docker-stub-EU.zip
|
||||||
path: docker-stub-EU.zip
|
path: docker-stub-EU.zip
|
||||||
@@ -386,21 +378,21 @@ jobs:
|
|||||||
pwsh ./generate_openapi_files.ps1
|
pwsh ./generate_openapi_files.ps1
|
||||||
|
|
||||||
- name: Upload Public API Swagger artifact
|
- name: Upload Public API Swagger artifact
|
||||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||||
with:
|
with:
|
||||||
name: swagger.json
|
name: swagger.json
|
||||||
path: api.public.json
|
path: api.public.json
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
- name: Upload Internal API Swagger artifact
|
- name: Upload Internal API Swagger artifact
|
||||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||||
with:
|
with:
|
||||||
name: internal.json
|
name: internal.json
|
||||||
path: api.json
|
path: api.json
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
- name: Upload Identity Swagger artifact
|
- name: Upload Identity Swagger artifact
|
||||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||||
with:
|
with:
|
||||||
name: identity.json
|
name: identity.json
|
||||||
path: identity.json
|
path: identity.json
|
||||||
@@ -408,7 +400,7 @@ jobs:
|
|||||||
|
|
||||||
build-mssqlmigratorutility:
|
build-mssqlmigratorutility:
|
||||||
name: Build MSSQL migrator utility
|
name: Build MSSQL migrator utility
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-22.04
|
||||||
needs:
|
needs:
|
||||||
- lint
|
- lint
|
||||||
defaults:
|
defaults:
|
||||||
@@ -446,7 +438,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload project artifact for Windows
|
- name: Upload project artifact for Windows
|
||||||
if: ${{ contains(matrix.target, 'win') == true }}
|
if: ${{ contains(matrix.target, 'win') == true }}
|
||||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||||
with:
|
with:
|
||||||
name: MsSqlMigratorUtility-${{ matrix.target }}
|
name: MsSqlMigratorUtility-${{ matrix.target }}
|
||||||
path: util/MsSqlMigratorUtility/obj/build-output/publish/MsSqlMigratorUtility.exe
|
path: util/MsSqlMigratorUtility/obj/build-output/publish/MsSqlMigratorUtility.exe
|
||||||
@@ -454,7 +446,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload project artifact
|
- name: Upload project artifact
|
||||||
if: ${{ contains(matrix.target, 'win') == false }}
|
if: ${{ contains(matrix.target, 'win') == false }}
|
||||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||||
with:
|
with:
|
||||||
name: MsSqlMigratorUtility-${{ matrix.target }}
|
name: MsSqlMigratorUtility-${{ matrix.target }}
|
||||||
path: util/MsSqlMigratorUtility/obj/build-output/publish/MsSqlMigratorUtility
|
path: util/MsSqlMigratorUtility/obj/build-output/publish/MsSqlMigratorUtility
|
||||||
@@ -465,7 +457,7 @@ jobs:
|
|||||||
if: |
|
if: |
|
||||||
github.event_name != 'pull_request'
|
github.event_name != 'pull_request'
|
||||||
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
|
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-22.04
|
||||||
needs:
|
needs:
|
||||||
- build-artifacts
|
- build-artifacts
|
||||||
permissions:
|
permissions:
|
||||||
@@ -478,25 +470,34 @@ jobs:
|
|||||||
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
|
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
|
||||||
client_id: ${{ secrets.AZURE_CLIENT_ID }}
|
client_id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||||
|
|
||||||
- name: Retrieve GitHub PAT secrets
|
- name: Get Azure Key Vault secrets
|
||||||
id: retrieve-secret-pat
|
id: get-kv-secrets
|
||||||
uses: bitwarden/gh-actions/get-keyvault-secrets@main
|
uses: bitwarden/gh-actions/get-keyvault-secrets@main
|
||||||
with:
|
with:
|
||||||
keyvault: "bitwarden-ci"
|
keyvault: gh-org-bitwarden
|
||||||
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
|
secrets: "BW-GHAPP-ID,BW-GHAPP-KEY"
|
||||||
|
|
||||||
- name: Log out from Azure
|
- name: Log out from Azure
|
||||||
uses: bitwarden/gh-actions/azure-logout@main
|
uses: bitwarden/gh-actions/azure-logout@main
|
||||||
|
|
||||||
- name: Trigger self-host build
|
- name: Generate GH App token
|
||||||
|
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
|
||||||
|
id: app-token
|
||||||
|
with:
|
||||||
|
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
|
||||||
|
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
|
||||||
|
owner: ${{ github.repository_owner }}
|
||||||
|
repositories: self-host
|
||||||
|
|
||||||
|
- name: Trigger Bitwarden lite build
|
||||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||||
with:
|
with:
|
||||||
github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
|
github-token: ${{ steps.app-token.outputs.token }}
|
||||||
script: |
|
script: |
|
||||||
await github.rest.actions.createWorkflowDispatch({
|
await github.rest.actions.createWorkflowDispatch({
|
||||||
owner: 'bitwarden',
|
owner: 'bitwarden',
|
||||||
repo: 'self-host',
|
repo: 'self-host',
|
||||||
workflow_id: 'build-unified.yml',
|
workflow_id: 'build-bitwarden-lite.yml',
|
||||||
ref: 'main',
|
ref: 'main',
|
||||||
inputs: {
|
inputs: {
|
||||||
server_branch: process.env.GITHUB_REF
|
server_branch: process.env.GITHUB_REF
|
||||||
@@ -519,20 +520,29 @@ jobs:
|
|||||||
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
|
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
|
||||||
client_id: ${{ secrets.AZURE_CLIENT_ID }}
|
client_id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||||
|
|
||||||
- name: Retrieve GitHub PAT secrets
|
- name: Get Azure Key Vault secrets
|
||||||
id: retrieve-secret-pat
|
id: get-kv-secrets
|
||||||
uses: bitwarden/gh-actions/get-keyvault-secrets@main
|
uses: bitwarden/gh-actions/get-keyvault-secrets@main
|
||||||
with:
|
with:
|
||||||
keyvault: "bitwarden-ci"
|
keyvault: gh-org-bitwarden
|
||||||
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
|
secrets: "BW-GHAPP-ID,BW-GHAPP-KEY"
|
||||||
|
|
||||||
- name: Log out from Azure
|
- name: Log out from Azure
|
||||||
uses: bitwarden/gh-actions/azure-logout@main
|
uses: bitwarden/gh-actions/azure-logout@main
|
||||||
|
|
||||||
|
- name: Generate GH App token
|
||||||
|
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
|
||||||
|
id: app-token
|
||||||
|
with:
|
||||||
|
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
|
||||||
|
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
|
||||||
|
owner: ${{ github.repository_owner }}
|
||||||
|
repositories: devops
|
||||||
|
|
||||||
- name: Trigger k8s deploy
|
- name: Trigger k8s deploy
|
||||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||||
with:
|
with:
|
||||||
github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
|
github-token: ${{ steps.app-token.outputs.token }}
|
||||||
script: |
|
script: |
|
||||||
await github.rest.actions.createWorkflowDispatch({
|
await github.rest.actions.createWorkflowDispatch({
|
||||||
owner: 'bitwarden',
|
owner: 'bitwarden',
|
||||||
|
|||||||
1
.github/workflows/review-code.yml
vendored
1
.github/workflows/review-code.yml
vendored
@@ -15,6 +15,7 @@ jobs:
|
|||||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||||
permissions:
|
permissions:
|
||||||
|
actions: read
|
||||||
contents: read
|
contents: read
|
||||||
id-token: write
|
id-token: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
|
|||||||
31
.github/workflows/test-database.yml
vendored
31
.github/workflows/test-database.yml
vendored
@@ -62,7 +62,7 @@ jobs:
|
|||||||
docker compose --profile mssql --profile postgres --profile mysql up -d
|
docker compose --profile mssql --profile postgres --profile mysql up -d
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
|
|
||||||
- name: Add MariaDB for unified
|
- name: Add MariaDB for Bitwarden lite
|
||||||
# Use a different port than MySQL
|
# Use a different port than MySQL
|
||||||
run: |
|
run: |
|
||||||
docker run --detach --name mariadb --env MARIADB_ROOT_PASSWORD=mariadb-password -p 4306:3306 mariadb:10
|
docker run --detach --name mariadb --env MARIADB_ROOT_PASSWORD=mariadb-password -p 4306:3306 mariadb:10
|
||||||
@@ -133,7 +133,7 @@ jobs:
|
|||||||
# Default Sqlite
|
# Default Sqlite
|
||||||
BW_TEST_DATABASES__3__TYPE: "Sqlite"
|
BW_TEST_DATABASES__3__TYPE: "Sqlite"
|
||||||
BW_TEST_DATABASES__3__CONNECTIONSTRING: "Data Source=${{ runner.temp }}/test.db"
|
BW_TEST_DATABASES__3__CONNECTIONSTRING: "Data Source=${{ runner.temp }}/test.db"
|
||||||
# Unified MariaDB
|
# Bitwarden lite MariaDB
|
||||||
BW_TEST_DATABASES__4__TYPE: "MySql"
|
BW_TEST_DATABASES__4__TYPE: "MySql"
|
||||||
BW_TEST_DATABASES__4__CONNECTIONSTRING: "server=localhost;port=4306;uid=root;pwd=mariadb-password;database=vault_dev;Allow User Variables=true"
|
BW_TEST_DATABASES__4__CONNECTIONSTRING: "server=localhost;port=4306;uid=root;pwd=mariadb-password;database=vault_dev;Allow User Variables=true"
|
||||||
run: dotnet test --logger "trx;LogFileName=infrastructure-test-results.trx" /p:CoverletOutputFormatter="cobertura" --collect:"XPlat Code Coverage"
|
run: dotnet test --logger "trx;LogFileName=infrastructure-test-results.trx" /p:CoverletOutputFormatter="cobertura" --collect:"XPlat Code Coverage"
|
||||||
@@ -197,7 +197,7 @@ jobs:
|
|||||||
shell: pwsh
|
shell: pwsh
|
||||||
|
|
||||||
- name: Upload DACPAC
|
- name: Upload DACPAC
|
||||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||||
with:
|
with:
|
||||||
name: sql.dacpac
|
name: sql.dacpac
|
||||||
path: Sql.dacpac
|
path: Sql.dacpac
|
||||||
@@ -223,7 +223,7 @@ jobs:
|
|||||||
shell: pwsh
|
shell: pwsh
|
||||||
|
|
||||||
- name: Report validation results
|
- name: Report validation results
|
||||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||||
with:
|
with:
|
||||||
name: report.xml
|
name: report.xml
|
||||||
path: |
|
path: |
|
||||||
@@ -262,3 +262,26 @@ jobs:
|
|||||||
working-directory: "dev"
|
working-directory: "dev"
|
||||||
run: docker compose down
|
run: docker compose down
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
|
|
||||||
|
validate-migration-naming:
|
||||||
|
name: Validate new migration naming and order
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check out repo
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Validate new migrations for pull request
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
run: |
|
||||||
|
git fetch origin main:main
|
||||||
|
pwsh dev/verify_migrations.ps1 -BaseRef main
|
||||||
|
shell: pwsh
|
||||||
|
|
||||||
|
- name: Validate new migrations for push
|
||||||
|
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
|
||||||
|
run: pwsh dev/verify_migrations.ps1 -BaseRef HEAD~1
|
||||||
|
shell: pwsh
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
|
||||||
<Version>2025.11.0</Version>
|
<Version>2025.12.0</Version>
|
||||||
|
|
||||||
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
|
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
|||||||
@@ -35,8 +35,9 @@ public class ProviderService : IProviderService
|
|||||||
{
|
{
|
||||||
private static readonly PlanType[] _resellerDisallowedOrganizationTypes = [
|
private static readonly PlanType[] _resellerDisallowedOrganizationTypes = [
|
||||||
PlanType.Free,
|
PlanType.Free,
|
||||||
PlanType.FamiliesAnnually,
|
PlanType.FamiliesAnnually2025,
|
||||||
PlanType.FamiliesAnnually2019
|
PlanType.FamiliesAnnually2019,
|
||||||
|
PlanType.FamiliesAnnually
|
||||||
];
|
];
|
||||||
|
|
||||||
private readonly IDataProtector _dataProtector;
|
private readonly IDataProtector _dataProtector;
|
||||||
|
|||||||
@@ -11,21 +11,8 @@ public class Program
|
|||||||
.ConfigureWebHostDefaults(webBuilder =>
|
.ConfigureWebHostDefaults(webBuilder =>
|
||||||
{
|
{
|
||||||
webBuilder.UseStartup<Startup>();
|
webBuilder.UseStartup<Startup>();
|
||||||
webBuilder.ConfigureLogging((hostingContext, logging) =>
|
|
||||||
logging.AddSerilog(hostingContext, (e, globalSettings) =>
|
|
||||||
{
|
|
||||||
var context = e.Properties["SourceContext"].ToString();
|
|
||||||
|
|
||||||
if (e.Properties.TryGetValue("RequestPath", out var requestPath) &&
|
|
||||||
!string.IsNullOrWhiteSpace(requestPath?.ToString()) &&
|
|
||||||
(context.Contains(".Server.Kestrel") || context.Contains(".Core.IISHttpServer")))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return e.Level >= globalSettings.MinLogLevel.ScimSettings.Default;
|
|
||||||
}));
|
|
||||||
})
|
})
|
||||||
|
.AddSerilogFileLogging()
|
||||||
.Build()
|
.Build()
|
||||||
.Run();
|
.Run();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,11 +94,8 @@ public class Startup
|
|||||||
public void Configure(
|
public void Configure(
|
||||||
IApplicationBuilder app,
|
IApplicationBuilder app,
|
||||||
IWebHostEnvironment env,
|
IWebHostEnvironment env,
|
||||||
IHostApplicationLifetime appLifetime,
|
|
||||||
GlobalSettings globalSettings)
|
GlobalSettings globalSettings)
|
||||||
{
|
{
|
||||||
app.UseSerilog(env, appLifetime, globalSettings);
|
|
||||||
|
|
||||||
// Add general security headers
|
// Add general security headers
|
||||||
app.UseMiddleware<SecurityHeadersMiddleware>();
|
app.UseMiddleware<SecurityHeadersMiddleware>();
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
},
|
},
|
||||||
"storage": {
|
"storage": {
|
||||||
"connectionString": "UseDevelopmentStorage=true"
|
"connectionString": "UseDevelopmentStorage=true"
|
||||||
}
|
},
|
||||||
|
"pricingUri": "https://billingpricing.qa.bitwarden.pw"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,9 +30,6 @@
|
|||||||
"connectionString": "SECRET",
|
"connectionString": "SECRET",
|
||||||
"applicationCacheTopicName": "SECRET"
|
"applicationCacheTopicName": "SECRET"
|
||||||
},
|
},
|
||||||
"sentry": {
|
|
||||||
"dsn": "SECRET"
|
|
||||||
},
|
|
||||||
"notificationHub": {
|
"notificationHub": {
|
||||||
"connectionString": "SECRET",
|
"connectionString": "SECRET",
|
||||||
"hubName": "SECRET"
|
"hubName": "SECRET"
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
// FIXME: Update this file to be null safe and then delete the line below
|
using System.Security.Claims;
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
using System.Security.Claims;
|
|
||||||
using Bit.Core;
|
using Bit.Core;
|
||||||
using Bit.Core.AdminConsole.Entities;
|
using Bit.Core.AdminConsole.Entities;
|
||||||
using Bit.Core.AdminConsole.Enums;
|
using Bit.Core.AdminConsole.Enums;
|
||||||
@@ -167,6 +164,8 @@ public class AccountController : Controller
|
|||||||
{
|
{
|
||||||
var context = await _interaction.GetAuthorizationContextAsync(returnUrl);
|
var context = await _interaction.GetAuthorizationContextAsync(returnUrl);
|
||||||
|
|
||||||
|
// FIXME: Update this file to be null safe and then delete the line below
|
||||||
|
#nullable disable
|
||||||
if (!context.Parameters.AllKeys.Contains("domain_hint") ||
|
if (!context.Parameters.AllKeys.Contains("domain_hint") ||
|
||||||
string.IsNullOrWhiteSpace(context.Parameters["domain_hint"]))
|
string.IsNullOrWhiteSpace(context.Parameters["domain_hint"]))
|
||||||
{
|
{
|
||||||
@@ -182,6 +181,7 @@ public class AccountController : Controller
|
|||||||
|
|
||||||
var domainHint = context.Parameters["domain_hint"];
|
var domainHint = context.Parameters["domain_hint"];
|
||||||
var organization = await _organizationRepository.GetByIdentifierAsync(domainHint);
|
var organization = await _organizationRepository.GetByIdentifierAsync(domainHint);
|
||||||
|
#nullable restore
|
||||||
|
|
||||||
if (organization == null)
|
if (organization == null)
|
||||||
{
|
{
|
||||||
@@ -263,30 +263,33 @@ public class AccountController : Controller
|
|||||||
|
|
||||||
// See if the user has logged in with this SSO provider before and has already been provisioned.
|
// See if the user has logged in with this SSO provider before and has already been provisioned.
|
||||||
// This is signified by the user existing in the User table and the SSOUser table for the SSO provider they're using.
|
// This is signified by the user existing in the User table and the SSOUser table for the SSO provider they're using.
|
||||||
var (user, provider, providerUserId, claims, ssoConfigData) = await FindUserFromExternalProviderAsync(result);
|
var (possibleSsoLinkedUser, provider, providerUserId, claims, ssoConfigData) = await FindUserFromExternalProviderAsync(result);
|
||||||
|
|
||||||
// We will look these up as required (lazy resolution) to avoid multiple DB hits.
|
// We will look these up as required (lazy resolution) to avoid multiple DB hits.
|
||||||
Organization organization = null;
|
Organization? organization = null;
|
||||||
OrganizationUser orgUser = null;
|
OrganizationUser? orgUser = null;
|
||||||
|
|
||||||
// The user has not authenticated with this SSO provider before.
|
// The user has not authenticated with this SSO provider before.
|
||||||
// They could have an existing Bitwarden account in the User table though.
|
// They could have an existing Bitwarden account in the User table though.
|
||||||
if (user == null)
|
if (possibleSsoLinkedUser == null)
|
||||||
{
|
{
|
||||||
|
// FIXME: Update this file to be null safe and then delete the line below
|
||||||
|
#nullable disable
|
||||||
// If we're manually linking to SSO, the user's external identifier will be passed as query string parameter.
|
// If we're manually linking to SSO, the user's external identifier will be passed as query string parameter.
|
||||||
var userIdentifier = result.Properties.Items.Keys.Contains("user_identifier")
|
var userIdentifier = result.Properties.Items.Keys.Contains("user_identifier")
|
||||||
? result.Properties.Items["user_identifier"]
|
? result.Properties.Items["user_identifier"]
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
var (provisionedUser, foundOrganization, foundOrCreatedOrgUser) =
|
var (resolvedUser, foundOrganization, foundOrCreatedOrgUser) =
|
||||||
await AutoProvisionUserAsync(
|
await CreateUserAndOrgUserConditionallyAsync(
|
||||||
provider,
|
provider,
|
||||||
providerUserId,
|
providerUserId,
|
||||||
claims,
|
claims,
|
||||||
userIdentifier,
|
userIdentifier,
|
||||||
ssoConfigData);
|
ssoConfigData);
|
||||||
|
#nullable restore
|
||||||
|
|
||||||
user = provisionedUser;
|
possibleSsoLinkedUser = resolvedUser;
|
||||||
|
|
||||||
if (preventOrgUserLoginIfStatusInvalid)
|
if (preventOrgUserLoginIfStatusInvalid)
|
||||||
{
|
{
|
||||||
@@ -297,9 +300,10 @@ public class AccountController : Controller
|
|||||||
|
|
||||||
if (preventOrgUserLoginIfStatusInvalid)
|
if (preventOrgUserLoginIfStatusInvalid)
|
||||||
{
|
{
|
||||||
if (user == null) throw new Exception(_i18nService.T("UserShouldBeFound"));
|
User resolvedSsoLinkedUser = possibleSsoLinkedUser
|
||||||
|
?? throw new Exception(_i18nService.T("UserShouldBeFound"));
|
||||||
|
|
||||||
await PreventOrgUserLoginIfStatusInvalidAsync(organization, provider, orgUser, user);
|
await PreventOrgUserLoginIfStatusInvalidAsync(organization, provider, orgUser, resolvedSsoLinkedUser);
|
||||||
|
|
||||||
// This allows us to collect any additional claims or properties
|
// This allows us to collect any additional claims or properties
|
||||||
// for the specific protocols used and store them in the local auth cookie.
|
// for the specific protocols used and store them in the local auth cookie.
|
||||||
@@ -314,19 +318,20 @@ public class AccountController : Controller
|
|||||||
|
|
||||||
// Issue authentication cookie for user
|
// Issue authentication cookie for user
|
||||||
await HttpContext.SignInAsync(
|
await HttpContext.SignInAsync(
|
||||||
new IdentityServerUser(user.Id.ToString())
|
new IdentityServerUser(resolvedSsoLinkedUser.Id.ToString())
|
||||||
{
|
{
|
||||||
DisplayName = user.Email,
|
DisplayName = resolvedSsoLinkedUser.Email,
|
||||||
IdentityProvider = provider,
|
IdentityProvider = provider,
|
||||||
AdditionalClaims = additionalLocalClaims.ToArray()
|
AdditionalClaims = additionalLocalClaims.ToArray()
|
||||||
}, localSignInProps);
|
}, localSignInProps);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
// PM-24579: remove this else block with feature flag removal.
|
||||||
// Either the user already authenticated with the SSO provider, or we've just provisioned them.
|
// Either the user already authenticated with the SSO provider, or we've just provisioned them.
|
||||||
// Either way, we have associated the SSO login with a Bitwarden user.
|
// Either way, we have associated the SSO login with a Bitwarden user.
|
||||||
// We will now sign the Bitwarden user in.
|
// We will now sign the Bitwarden user in.
|
||||||
if (user != null)
|
if (possibleSsoLinkedUser != null)
|
||||||
{
|
{
|
||||||
// This allows us to collect any additional claims or properties
|
// This allows us to collect any additional claims or properties
|
||||||
// for the specific protocols used and store them in the local auth cookie.
|
// for the specific protocols used and store them in the local auth cookie.
|
||||||
@@ -341,9 +346,9 @@ public class AccountController : Controller
|
|||||||
|
|
||||||
// Issue authentication cookie for user
|
// Issue authentication cookie for user
|
||||||
await HttpContext.SignInAsync(
|
await HttpContext.SignInAsync(
|
||||||
new IdentityServerUser(user.Id.ToString())
|
new IdentityServerUser(possibleSsoLinkedUser.Id.ToString())
|
||||||
{
|
{
|
||||||
DisplayName = user.Email,
|
DisplayName = possibleSsoLinkedUser.Email,
|
||||||
IdentityProvider = provider,
|
IdentityProvider = provider,
|
||||||
AdditionalClaims = additionalLocalClaims.ToArray()
|
AdditionalClaims = additionalLocalClaims.ToArray()
|
||||||
}, localSignInProps);
|
}, localSignInProps);
|
||||||
@@ -353,8 +358,11 @@ public class AccountController : Controller
|
|||||||
// Delete temporary cookie used during external authentication
|
// Delete temporary cookie used during external authentication
|
||||||
await HttpContext.SignOutAsync(AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme);
|
await HttpContext.SignOutAsync(AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme);
|
||||||
|
|
||||||
|
// FIXME: Update this file to be null safe and then delete the line below
|
||||||
|
#nullable disable
|
||||||
// Retrieve return URL
|
// Retrieve return URL
|
||||||
var returnUrl = result.Properties.Items["return_url"] ?? "~/";
|
var returnUrl = result.Properties.Items["return_url"] ?? "~/";
|
||||||
|
#nullable restore
|
||||||
|
|
||||||
// Check if external login is in the context of an OIDC request
|
// Check if external login is in the context of an OIDC request
|
||||||
var context = await _interaction.GetAuthorizationContextAsync(returnUrl);
|
var context = await _interaction.GetAuthorizationContextAsync(returnUrl);
|
||||||
@@ -373,6 +381,8 @@ public class AccountController : Controller
|
|||||||
return Redirect(returnUrl);
|
return Redirect(returnUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FIXME: Update this file to be null safe and then delete the line below
|
||||||
|
#nullable disable
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IActionResult> LogoutAsync(string logoutId)
|
public async Task<IActionResult> LogoutAsync(string logoutId)
|
||||||
{
|
{
|
||||||
@@ -407,15 +417,22 @@ public class AccountController : Controller
|
|||||||
return Redirect("~/");
|
return Redirect("~/");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#nullable restore
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Attempts to map the external identity to a Bitwarden user, through the SsoUser table, which holds the `externalId`.
|
/// Attempts to map the external identity to a Bitwarden user, through the SsoUser table, which holds the `externalId`.
|
||||||
/// The claims on the external identity are used to determine an `externalId`, and that is used to find the appropriate `SsoUser` and `User` records.
|
/// The claims on the external identity are used to determine an `externalId`, and that is used to find the appropriate `SsoUser` and `User` records.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<(User user, string provider, string providerUserId, IEnumerable<Claim> claims,
|
private async Task<(
|
||||||
SsoConfigurationData config)>
|
User? possibleSsoUser,
|
||||||
FindUserFromExternalProviderAsync(AuthenticateResult result)
|
string provider,
|
||||||
|
string providerUserId,
|
||||||
|
IEnumerable<Claim> claims,
|
||||||
|
SsoConfigurationData config
|
||||||
|
)> FindUserFromExternalProviderAsync(AuthenticateResult result)
|
||||||
{
|
{
|
||||||
|
// FIXME: Update this file to be null safe and then delete the line below
|
||||||
|
#nullable disable
|
||||||
var provider = result.Properties.Items["scheme"];
|
var provider = result.Properties.Items["scheme"];
|
||||||
var orgId = new Guid(provider);
|
var orgId = new Guid(provider);
|
||||||
var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(orgId);
|
var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(orgId);
|
||||||
@@ -458,6 +475,7 @@ public class AccountController : Controller
|
|||||||
externalUser.FindFirst("upn") ??
|
externalUser.FindFirst("upn") ??
|
||||||
externalUser.FindFirst("eppn") ??
|
externalUser.FindFirst("eppn") ??
|
||||||
throw new Exception(_i18nService.T("UnknownUserId"));
|
throw new Exception(_i18nService.T("UnknownUserId"));
|
||||||
|
#nullable restore
|
||||||
|
|
||||||
// Remove the user id claim so we don't include it as an extra claim if/when we provision the user
|
// Remove the user id claim so we don't include it as an extra claim if/when we provision the user
|
||||||
var claims = externalUser.Claims.ToList();
|
var claims = externalUser.Claims.ToList();
|
||||||
@@ -466,13 +484,15 @@ public class AccountController : Controller
|
|||||||
// find external user
|
// find external user
|
||||||
var providerUserId = userIdClaim.Value;
|
var providerUserId = userIdClaim.Value;
|
||||||
|
|
||||||
var user = await _userRepository.GetBySsoUserAsync(providerUserId, orgId);
|
var possibleSsoUser = await _userRepository.GetBySsoUserAsync(providerUserId, orgId);
|
||||||
|
|
||||||
return (user, provider, providerUserId, claims, ssoConfigData);
|
return (possibleSsoUser, provider, providerUserId, claims, ssoConfigData);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Provision an SSO-linked Bitwarden user.
|
/// This function seeks to set up the org user record or create a new user record based on the conditions
|
||||||
|
/// below.
|
||||||
|
///
|
||||||
/// This handles three different scenarios:
|
/// This handles three different scenarios:
|
||||||
/// 1. Creating an SsoUser link for an existing User and OrganizationUser
|
/// 1. Creating an SsoUser link for an existing User and OrganizationUser
|
||||||
/// - User is a member of the organization, but hasn't authenticated with the org's SSO provider before.
|
/// - User is a member of the organization, but hasn't authenticated with the org's SSO provider before.
|
||||||
@@ -488,8 +508,7 @@ public class AccountController : Controller
|
|||||||
/// <param name="ssoConfigData">The SSO configuration for the organization.</param>
|
/// <param name="ssoConfigData">The SSO configuration for the organization.</param>
|
||||||
/// <returns>Guaranteed to return the user to sign in as well as the found organization and org user.</returns>
|
/// <returns>Guaranteed to return the user to sign in as well as the found organization and org user.</returns>
|
||||||
/// <exception cref="Exception">An exception if the user cannot be provisioned as requested.</exception>
|
/// <exception cref="Exception">An exception if the user cannot be provisioned as requested.</exception>
|
||||||
private async Task<(User user, Organization foundOrganization, OrganizationUser foundOrgUser)>
|
private async Task<(User resolvedUser, Organization foundOrganization, OrganizationUser foundOrgUser)> CreateUserAndOrgUserConditionallyAsync(
|
||||||
AutoProvisionUserAsync(
|
|
||||||
string provider,
|
string provider,
|
||||||
string providerUserId,
|
string providerUserId,
|
||||||
IEnumerable<Claim> claims,
|
IEnumerable<Claim> claims,
|
||||||
@@ -497,10 +516,11 @@ public class AccountController : Controller
|
|||||||
SsoConfigurationData ssoConfigData
|
SsoConfigurationData ssoConfigData
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
|
// Try to get the email from the claims as we don't know if we have a user record yet.
|
||||||
var name = GetName(claims, ssoConfigData.GetAdditionalNameClaimTypes());
|
var name = GetName(claims, ssoConfigData.GetAdditionalNameClaimTypes());
|
||||||
var email = TryGetEmailAddress(claims, ssoConfigData, providerUserId);
|
var email = TryGetEmailAddress(claims, ssoConfigData, providerUserId);
|
||||||
|
|
||||||
User existingUser = null;
|
User? possibleExistingUser;
|
||||||
if (string.IsNullOrWhiteSpace(userIdentifier))
|
if (string.IsNullOrWhiteSpace(userIdentifier))
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(email))
|
if (string.IsNullOrWhiteSpace(email))
|
||||||
@@ -508,51 +528,74 @@ public class AccountController : Controller
|
|||||||
throw new Exception(_i18nService.T("CannotFindEmailClaim"));
|
throw new Exception(_i18nService.T("CannotFindEmailClaim"));
|
||||||
}
|
}
|
||||||
|
|
||||||
existingUser = await _userRepository.GetByEmailAsync(email);
|
possibleExistingUser = await _userRepository.GetByEmailAsync(email);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
existingUser = await GetUserFromManualLinkingDataAsync(userIdentifier);
|
possibleExistingUser = await GetUserFromManualLinkingDataAsync(userIdentifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to find the org (we error if we can't find an org)
|
// Find the org (we error if we can't find an org because no org is not valid)
|
||||||
var organization = await TryGetOrganizationByProviderAsync(provider);
|
var organization = await GetOrganizationByProviderAsync(provider);
|
||||||
|
|
||||||
// Try to find an org user (null org user possible and valid here)
|
// Try to find an org user (null org user possible and valid here)
|
||||||
var orgUser = await TryGetOrganizationUserByUserAndOrgOrEmail(existingUser, organization.Id, email);
|
var possibleOrgUser = await GetOrganizationUserByUserAndOrgIdOrEmailAsync(possibleExistingUser, organization.Id, email);
|
||||||
|
|
||||||
//----------------------------------------------------
|
//----------------------------------------------------
|
||||||
// Scenario 1: We've found the user in the User table
|
// Scenario 1: We've found the user in the User table
|
||||||
//----------------------------------------------------
|
//----------------------------------------------------
|
||||||
if (existingUser != null)
|
if (possibleExistingUser != null)
|
||||||
{
|
{
|
||||||
if (existingUser.UsesKeyConnector &&
|
User guaranteedExistingUser = possibleExistingUser;
|
||||||
(orgUser == null || orgUser.Status == OrganizationUserStatusType.Invited))
|
|
||||||
|
if (guaranteedExistingUser.UsesKeyConnector &&
|
||||||
|
(possibleOrgUser == null || possibleOrgUser.Status == OrganizationUserStatusType.Invited))
|
||||||
{
|
{
|
||||||
throw new Exception(_i18nService.T("UserAlreadyExistsKeyConnector"));
|
throw new Exception(_i18nService.T("UserAlreadyExistsKeyConnector"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the user already exists in Bitwarden, we require that the user already be in the org,
|
OrganizationUser guaranteedOrgUser = possibleOrgUser ?? throw new Exception(_i18nService.T("UserAlreadyExistsInviteProcess"));
|
||||||
// and that they are either Accepted or Confirmed.
|
|
||||||
if (orgUser == null)
|
/*
|
||||||
|
* ----------------------------------------------------
|
||||||
|
* Critical Code Check Here
|
||||||
|
*
|
||||||
|
* We want to ensure a user is not in the invited state
|
||||||
|
* explicitly. User's in the invited state should not
|
||||||
|
* be able to authenticate via SSO.
|
||||||
|
*
|
||||||
|
* See internal doc called "Added Context for SSO Login
|
||||||
|
* Flows" for further details.
|
||||||
|
* ----------------------------------------------------
|
||||||
|
*/
|
||||||
|
if (guaranteedOrgUser.Status == OrganizationUserStatusType.Invited)
|
||||||
{
|
{
|
||||||
// Org User is not created - no invite has been sent
|
// Org User is invited – must accept via email first
|
||||||
throw new Exception(_i18nService.T("UserAlreadyExistsInviteProcess"));
|
throw new Exception(
|
||||||
|
_i18nService.T("AcceptInviteBeforeUsingSSO", organization.DisplayName()));
|
||||||
}
|
}
|
||||||
|
|
||||||
EnsureAcceptedOrConfirmedOrgUserStatus(orgUser.Status, organization.DisplayName());
|
// If the user already exists in Bitwarden, we require that the user already be in the org,
|
||||||
|
// and that they are either Accepted or Confirmed.
|
||||||
|
EnforceAllowedOrgUserStatus(
|
||||||
|
guaranteedOrgUser.Status,
|
||||||
|
allowedStatuses: [
|
||||||
|
OrganizationUserStatusType.Accepted,
|
||||||
|
OrganizationUserStatusType.Confirmed
|
||||||
|
],
|
||||||
|
organization.DisplayName());
|
||||||
|
|
||||||
// Since we're in the auto-provisioning logic, this means that the user exists, but they have not
|
// Since we're in the auto-provisioning logic, this means that the user exists, but they have not
|
||||||
// authenticated with the org's SSO provider before now (otherwise we wouldn't be auto-provisioning them).
|
// authenticated with the org's SSO provider before now (otherwise we wouldn't be auto-provisioning them).
|
||||||
// We've verified that the user is Accepted or Confnirmed, so we can create an SsoUser link and proceed
|
// We've verified that the user is Accepted or Confnirmed, so we can create an SsoUser link and proceed
|
||||||
// with authentication.
|
// with authentication.
|
||||||
await CreateSsoUserRecordAsync(providerUserId, existingUser.Id, organization.Id, orgUser);
|
await CreateSsoUserRecordAsync(providerUserId, guaranteedExistingUser.Id, organization.Id, guaranteedOrgUser);
|
||||||
|
|
||||||
return (existingUser, organization, orgUser);
|
return (guaranteedExistingUser, organization, guaranteedOrgUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Before any user creation - if Org User doesn't exist at this point - make sure there are enough seats to add one
|
// Before any user creation - if Org User doesn't exist at this point - make sure there are enough seats to add one
|
||||||
if (orgUser == null && organization.Seats.HasValue)
|
if (possibleOrgUser == null && organization.Seats.HasValue)
|
||||||
{
|
{
|
||||||
var occupiedSeats =
|
var occupiedSeats =
|
||||||
await _organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
|
await _organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
|
||||||
@@ -584,6 +627,11 @@ public class AccountController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If the email domain is verified, we can mark the email as verified
|
// If the email domain is verified, we can mark the email as verified
|
||||||
|
if (string.IsNullOrWhiteSpace(email))
|
||||||
|
{
|
||||||
|
throw new Exception(_i18nService.T("CannotFindEmailClaim"));
|
||||||
|
}
|
||||||
|
|
||||||
var emailVerified = false;
|
var emailVerified = false;
|
||||||
var emailDomain = CoreHelpers.GetEmailDomain(email);
|
var emailDomain = CoreHelpers.GetEmailDomain(email);
|
||||||
if (!string.IsNullOrWhiteSpace(emailDomain))
|
if (!string.IsNullOrWhiteSpace(emailDomain))
|
||||||
@@ -596,29 +644,45 @@ public class AccountController : Controller
|
|||||||
//--------------------------------------------------
|
//--------------------------------------------------
|
||||||
// Scenarios 2 and 3: We need to register a new user
|
// Scenarios 2 and 3: We need to register a new user
|
||||||
//--------------------------------------------------
|
//--------------------------------------------------
|
||||||
var user = new User
|
var newUser = new User
|
||||||
{
|
{
|
||||||
Name = name,
|
Name = name,
|
||||||
Email = email,
|
Email = email,
|
||||||
EmailVerified = emailVerified,
|
EmailVerified = emailVerified,
|
||||||
ApiKey = CoreHelpers.SecureRandomString(30)
|
ApiKey = CoreHelpers.SecureRandomString(30)
|
||||||
};
|
};
|
||||||
await _registerUserCommand.RegisterUser(user);
|
|
||||||
|
/*
|
||||||
|
The feature flag is checked here so that we can send the new MJML welcome email templates.
|
||||||
|
The other organization invites flows have an OrganizationUser allowing the RegisterUserCommand the ability
|
||||||
|
to fetch the Organization. The old method RegisterUser(User) here does not have that context, so we need
|
||||||
|
to use a new method RegisterSSOAutoProvisionedUserAsync(User, Organization) to send the correct email.
|
||||||
|
[PM-28057]: Prefer RegisterSSOAutoProvisionedUserAsync for SSO auto-provisioned users.
|
||||||
|
TODO: Remove Feature flag: PM-28221
|
||||||
|
*/
|
||||||
|
if (_featureService.IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates))
|
||||||
|
{
|
||||||
|
await _registerUserCommand.RegisterSSOAutoProvisionedUserAsync(newUser, organization);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await _registerUserCommand.RegisterUser(newUser);
|
||||||
|
}
|
||||||
|
|
||||||
// If the organization has 2fa policy enabled, make sure to default jit user 2fa to email
|
// If the organization has 2fa policy enabled, make sure to default jit user 2fa to email
|
||||||
var twoFactorPolicy =
|
var twoFactorPolicy =
|
||||||
await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.TwoFactorAuthentication);
|
await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.TwoFactorAuthentication);
|
||||||
if (twoFactorPolicy != null && twoFactorPolicy.Enabled)
|
if (twoFactorPolicy != null && twoFactorPolicy.Enabled)
|
||||||
{
|
{
|
||||||
user.SetTwoFactorProviders(new Dictionary<TwoFactorProviderType, TwoFactorProvider>
|
newUser.SetTwoFactorProviders(new Dictionary<TwoFactorProviderType, TwoFactorProvider>
|
||||||
{
|
{
|
||||||
[TwoFactorProviderType.Email] = new TwoFactorProvider
|
[TwoFactorProviderType.Email] = new TwoFactorProvider
|
||||||
{
|
{
|
||||||
MetaData = new Dictionary<string, object> { ["Email"] = user.Email.ToLowerInvariant() },
|
MetaData = new Dictionary<string, object> { ["Email"] = newUser.Email.ToLowerInvariant() },
|
||||||
Enabled = true
|
Enabled = true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
await _userService.UpdateTwoFactorProviderAsync(user, TwoFactorProviderType.Email);
|
await _userService.UpdateTwoFactorProviderAsync(newUser, TwoFactorProviderType.Email);
|
||||||
}
|
}
|
||||||
|
|
||||||
//-----------------------------------------------------------------
|
//-----------------------------------------------------------------
|
||||||
@@ -626,16 +690,16 @@ public class AccountController : Controller
|
|||||||
// This means that an invitation was not sent for this user and we
|
// This means that an invitation was not sent for this user and we
|
||||||
// need to establish their invited status now.
|
// need to establish their invited status now.
|
||||||
//-----------------------------------------------------------------
|
//-----------------------------------------------------------------
|
||||||
if (orgUser == null)
|
if (possibleOrgUser == null)
|
||||||
{
|
{
|
||||||
orgUser = new OrganizationUser
|
possibleOrgUser = new OrganizationUser
|
||||||
{
|
{
|
||||||
OrganizationId = organization.Id,
|
OrganizationId = organization.Id,
|
||||||
UserId = user.Id,
|
UserId = newUser.Id,
|
||||||
Type = OrganizationUserType.User,
|
Type = OrganizationUserType.User,
|
||||||
Status = OrganizationUserStatusType.Invited
|
Status = OrganizationUserStatusType.Invited
|
||||||
};
|
};
|
||||||
await _organizationUserRepository.CreateAsync(orgUser);
|
await _organizationUserRepository.CreateAsync(possibleOrgUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
//-----------------------------------------------------------------
|
//-----------------------------------------------------------------
|
||||||
@@ -645,14 +709,14 @@ public class AccountController : Controller
|
|||||||
//-----------------------------------------------------------------
|
//-----------------------------------------------------------------
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
orgUser.UserId = user.Id;
|
possibleOrgUser.UserId = newUser.Id;
|
||||||
await _organizationUserRepository.ReplaceAsync(orgUser);
|
await _organizationUserRepository.ReplaceAsync(possibleOrgUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the SsoUser record to link the user to the SSO provider.
|
// Create the SsoUser record to link the user to the SSO provider.
|
||||||
await CreateSsoUserRecordAsync(providerUserId, user.Id, organization.Id, orgUser);
|
await CreateSsoUserRecordAsync(providerUserId, newUser.Id, organization.Id, possibleOrgUser);
|
||||||
|
|
||||||
return (user, organization, orgUser);
|
return (newUser, organization, possibleOrgUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -666,23 +730,31 @@ public class AccountController : Controller
|
|||||||
/// <exception cref="Exception">Thrown if the organization cannot be resolved from provider;
|
/// <exception cref="Exception">Thrown if the organization cannot be resolved from provider;
|
||||||
/// the organization user cannot be found; or the organization user status is not allowed.</exception>
|
/// the organization user cannot be found; or the organization user status is not allowed.</exception>
|
||||||
private async Task PreventOrgUserLoginIfStatusInvalidAsync(
|
private async Task PreventOrgUserLoginIfStatusInvalidAsync(
|
||||||
Organization organization,
|
Organization? organization,
|
||||||
string provider,
|
string provider,
|
||||||
OrganizationUser orgUser,
|
OrganizationUser? orgUser,
|
||||||
User user)
|
User user)
|
||||||
{
|
{
|
||||||
// Lazily get organization if not already known
|
// Lazily get organization if not already known
|
||||||
organization ??= await TryGetOrganizationByProviderAsync(provider);
|
organization ??= await GetOrganizationByProviderAsync(provider);
|
||||||
|
|
||||||
// Lazily get the org user if not already known
|
// Lazily get the org user if not already known
|
||||||
orgUser ??= await TryGetOrganizationUserByUserAndOrgOrEmail(
|
orgUser ??= await GetOrganizationUserByUserAndOrgIdOrEmailAsync(
|
||||||
user,
|
user,
|
||||||
organization.Id,
|
organization.Id,
|
||||||
user.Email);
|
user.Email);
|
||||||
|
|
||||||
if (orgUser != null)
|
if (orgUser != null)
|
||||||
{
|
{
|
||||||
EnsureAcceptedOrConfirmedOrgUserStatus(orgUser.Status, organization.DisplayName());
|
// Invited is allowed at this point because we know the user is trying to accept an org invite.
|
||||||
|
EnforceAllowedOrgUserStatus(
|
||||||
|
orgUser.Status,
|
||||||
|
allowedStatuses: [
|
||||||
|
OrganizationUserStatusType.Invited,
|
||||||
|
OrganizationUserStatusType.Accepted,
|
||||||
|
OrganizationUserStatusType.Confirmed,
|
||||||
|
],
|
||||||
|
organization.DisplayName());
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -690,9 +762,9 @@ public class AccountController : Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<User> GetUserFromManualLinkingDataAsync(string userIdentifier)
|
private async Task<User?> GetUserFromManualLinkingDataAsync(string userIdentifier)
|
||||||
{
|
{
|
||||||
User user = null;
|
User? user = null;
|
||||||
var split = userIdentifier.Split(",");
|
var split = userIdentifier.Split(",");
|
||||||
if (split.Length < 2)
|
if (split.Length < 2)
|
||||||
{
|
{
|
||||||
@@ -728,7 +800,7 @@ public class AccountController : Controller
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="provider">Org id string from SSO scheme property</param>
|
/// <param name="provider">Org id string from SSO scheme property</param>
|
||||||
/// <exception cref="Exception">Errors if the provider string is not a valid org id guid or if the org cannot be found by the id.</exception>
|
/// <exception cref="Exception">Errors if the provider string is not a valid org id guid or if the org cannot be found by the id.</exception>
|
||||||
private async Task<Organization> TryGetOrganizationByProviderAsync(string provider)
|
private async Task<Organization> GetOrganizationByProviderAsync(string provider)
|
||||||
{
|
{
|
||||||
if (!Guid.TryParse(provider, out var organizationId))
|
if (!Guid.TryParse(provider, out var organizationId))
|
||||||
{
|
{
|
||||||
@@ -755,12 +827,12 @@ public class AccountController : Controller
|
|||||||
/// <param name="organizationId">Organization id from the provider data.</param>
|
/// <param name="organizationId">Organization id from the provider data.</param>
|
||||||
/// <param name="email">Email to use as a fallback in case of an invited user not in the Org Users
|
/// <param name="email">Email to use as a fallback in case of an invited user not in the Org Users
|
||||||
/// table yet.</param>
|
/// table yet.</param>
|
||||||
private async Task<OrganizationUser> TryGetOrganizationUserByUserAndOrgOrEmail(
|
private async Task<OrganizationUser?> GetOrganizationUserByUserAndOrgIdOrEmailAsync(
|
||||||
User user,
|
User? user,
|
||||||
Guid organizationId,
|
Guid organizationId,
|
||||||
string email)
|
string? email)
|
||||||
{
|
{
|
||||||
OrganizationUser orgUser = null;
|
OrganizationUser? orgUser = null;
|
||||||
|
|
||||||
// Try to find OrgUser via existing User Id.
|
// Try to find OrgUser via existing User Id.
|
||||||
// This covers any OrganizationUser state after they have accepted an invite.
|
// This covers any OrganizationUser state after they have accepted an invite.
|
||||||
@@ -772,44 +844,40 @@ public class AccountController : Controller
|
|||||||
|
|
||||||
// If no Org User found by Existing User Id - search all the organization's users via email.
|
// If no Org User found by Existing User Id - search all the organization's users via email.
|
||||||
// This covers users who are Invited but haven't accepted their invite yet.
|
// This covers users who are Invited but haven't accepted their invite yet.
|
||||||
orgUser ??= await _organizationUserRepository.GetByOrganizationEmailAsync(organizationId, email);
|
if (email != null)
|
||||||
|
{
|
||||||
|
orgUser ??= await _organizationUserRepository.GetByOrganizationEmailAsync(organizationId, email);
|
||||||
|
}
|
||||||
|
|
||||||
return orgUser;
|
return orgUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void EnsureAcceptedOrConfirmedOrgUserStatus(
|
private void EnforceAllowedOrgUserStatus(
|
||||||
OrganizationUserStatusType status,
|
OrganizationUserStatusType statusToCheckAgainst,
|
||||||
string organizationDisplayName)
|
OrganizationUserStatusType[] allowedStatuses,
|
||||||
|
string organizationDisplayNameForLogging)
|
||||||
{
|
{
|
||||||
// The only permissible org user statuses allowed.
|
|
||||||
OrganizationUserStatusType[] allowedStatuses =
|
|
||||||
[OrganizationUserStatusType.Accepted, OrganizationUserStatusType.Confirmed];
|
|
||||||
|
|
||||||
// if this status is one of the allowed ones, just return
|
// if this status is one of the allowed ones, just return
|
||||||
if (allowedStatuses.Contains(status))
|
if (allowedStatuses.Contains(statusToCheckAgainst))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// otherwise throw the appropriate exception
|
// otherwise throw the appropriate exception
|
||||||
switch (status)
|
switch (statusToCheckAgainst)
|
||||||
{
|
{
|
||||||
case OrganizationUserStatusType.Invited:
|
|
||||||
// Org User is invited – must accept via email first
|
|
||||||
throw new Exception(
|
|
||||||
_i18nService.T("AcceptInviteBeforeUsingSSO", organizationDisplayName));
|
|
||||||
case OrganizationUserStatusType.Revoked:
|
case OrganizationUserStatusType.Revoked:
|
||||||
// Revoked users may not be (auto)‑provisioned
|
// Revoked users may not be (auto)‑provisioned
|
||||||
throw new Exception(
|
throw new Exception(
|
||||||
_i18nService.T("OrganizationUserAccessRevoked", organizationDisplayName));
|
_i18nService.T("OrganizationUserAccessRevoked", organizationDisplayNameForLogging));
|
||||||
default:
|
default:
|
||||||
// anything else is “unknown”
|
// anything else is “unknown”
|
||||||
throw new Exception(
|
throw new Exception(
|
||||||
_i18nService.T("OrganizationUserUnknownStatus", organizationDisplayName));
|
_i18nService.T("OrganizationUserUnknownStatus", organizationDisplayNameForLogging));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private IActionResult InvalidJson(string errorMessageKey, Exception ex = null)
|
private IActionResult InvalidJson(string errorMessageKey, Exception? ex = null)
|
||||||
{
|
{
|
||||||
Response.StatusCode = ex == null ? 400 : 500;
|
Response.StatusCode = ex == null ? 400 : 500;
|
||||||
return Json(new ErrorResponseModel(_i18nService.T(errorMessageKey))
|
return Json(new ErrorResponseModel(_i18nService.T(errorMessageKey))
|
||||||
@@ -820,7 +888,7 @@ public class AccountController : Controller
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private string TryGetEmailAddressFromClaims(IEnumerable<Claim> claims, IEnumerable<string> additionalClaimTypes)
|
private string? TryGetEmailAddressFromClaims(IEnumerable<Claim> claims, IEnumerable<string> additionalClaimTypes)
|
||||||
{
|
{
|
||||||
var filteredClaims = claims.Where(c => !string.IsNullOrWhiteSpace(c.Value) && c.Value.Contains("@"));
|
var filteredClaims = claims.Where(c => !string.IsNullOrWhiteSpace(c.Value) && c.Value.Contains("@"));
|
||||||
|
|
||||||
@@ -842,6 +910,8 @@ public class AccountController : Controller
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FIXME: Update this file to be null safe and then delete the line below
|
||||||
|
#nullable disable
|
||||||
private string GetName(IEnumerable<Claim> claims, IEnumerable<string> additionalClaimTypes)
|
private string GetName(IEnumerable<Claim> claims, IEnumerable<string> additionalClaimTypes)
|
||||||
{
|
{
|
||||||
var filteredClaims = claims.Where(c => !string.IsNullOrWhiteSpace(c.Value));
|
var filteredClaims = claims.Where(c => !string.IsNullOrWhiteSpace(c.Value));
|
||||||
@@ -865,6 +935,7 @@ public class AccountController : Controller
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
#nullable restore
|
||||||
|
|
||||||
private async Task CreateSsoUserRecordAsync(string providerUserId, Guid userId, Guid orgId,
|
private async Task CreateSsoUserRecordAsync(string providerUserId, Guid userId, Guid orgId,
|
||||||
OrganizationUser orgUser)
|
OrganizationUser orgUser)
|
||||||
@@ -886,6 +957,8 @@ public class AccountController : Controller
|
|||||||
await _ssoUserRepository.CreateAsync(ssoUser);
|
await _ssoUserRepository.CreateAsync(ssoUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FIXME: Update this file to be null safe and then delete the line below
|
||||||
|
#nullable disable
|
||||||
private void ProcessLoginCallback(AuthenticateResult externalResult,
|
private void ProcessLoginCallback(AuthenticateResult externalResult,
|
||||||
List<Claim> localClaims, AuthenticationProperties localSignInProps)
|
List<Claim> localClaims, AuthenticationProperties localSignInProps)
|
||||||
{
|
{
|
||||||
@@ -936,12 +1009,13 @@ public class AccountController : Controller
|
|||||||
|
|
||||||
return (logoutId, logout?.PostLogoutRedirectUri, externalAuthenticationScheme);
|
return (logoutId, logout?.PostLogoutRedirectUri, externalAuthenticationScheme);
|
||||||
}
|
}
|
||||||
|
#nullable restore
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tries to get a user's email from the claims and SSO configuration data or the provider user id if
|
* Tries to get a user's email from the claims and SSO configuration data or the provider user id if
|
||||||
* the claims email extraction returns null.
|
* the claims email extraction returns null.
|
||||||
*/
|
*/
|
||||||
private string TryGetEmailAddress(
|
private string? TryGetEmailAddress(
|
||||||
IEnumerable<Claim> claims,
|
IEnumerable<Claim> claims,
|
||||||
SsoConfigurationData config,
|
SsoConfigurationData config,
|
||||||
string providerUserId)
|
string providerUserId)
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
using Bit.Core.Utilities;
|
using Bit.Core.Utilities;
|
||||||
using Serilog;
|
|
||||||
|
|
||||||
namespace Bit.Sso;
|
namespace Bit.Sso;
|
||||||
|
|
||||||
@@ -13,19 +12,8 @@ public class Program
|
|||||||
.ConfigureWebHostDefaults(webBuilder =>
|
.ConfigureWebHostDefaults(webBuilder =>
|
||||||
{
|
{
|
||||||
webBuilder.UseStartup<Startup>();
|
webBuilder.UseStartup<Startup>();
|
||||||
webBuilder.ConfigureLogging((hostingContext, logging) =>
|
|
||||||
logging.AddSerilog(hostingContext, (e, globalSettings) =>
|
|
||||||
{
|
|
||||||
var context = e.Properties["SourceContext"].ToString();
|
|
||||||
if (e.Properties.TryGetValue("RequestPath", out var requestPath) &&
|
|
||||||
!string.IsNullOrWhiteSpace(requestPath?.ToString()) &&
|
|
||||||
(context.Contains(".Server.Kestrel") || context.Contains(".Core.IISHttpServer")))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return e.Level >= globalSettings.MinLogLevel.SsoSettings.Default;
|
|
||||||
}));
|
|
||||||
})
|
})
|
||||||
|
.AddSerilogFileLogging()
|
||||||
.Build()
|
.Build()
|
||||||
.Run();
|
.Run();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,8 +100,6 @@ public class Startup
|
|||||||
IdentityModelEventSource.ShowPII = true;
|
IdentityModelEventSource.ShowPII = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
app.UseSerilog(env, appLifetime, globalSettings);
|
|
||||||
|
|
||||||
// Add general security headers
|
// Add general security headers
|
||||||
app.UseMiddleware<SecurityHeadersMiddleware>();
|
app.UseMiddleware<SecurityHeadersMiddleware>();
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
"storage": {
|
"storage": {
|
||||||
"connectionString": "UseDevelopmentStorage=true"
|
"connectionString": "UseDevelopmentStorage=true"
|
||||||
},
|
},
|
||||||
"developmentDirectory": "../../../dev"
|
"developmentDirectory": "../../../dev",
|
||||||
|
"pricingUri": "https://billingpricing.qa.bitwarden.pw"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ using Bit.Core.Enums;
|
|||||||
using Bit.Core.Exceptions;
|
using Bit.Core.Exceptions;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
using Bit.Core.Services;
|
using Bit.Core.Services;
|
||||||
using Bit.Core.Utilities;
|
using Bit.Core.Test.Billing.Mocks;
|
||||||
using Bit.Test.Common.AutoFixture;
|
using Bit.Test.Common.AutoFixture;
|
||||||
using Bit.Test.Common.AutoFixture.Attributes;
|
using Bit.Test.Common.AutoFixture.Attributes;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
@@ -207,7 +207,7 @@ public class RemoveOrganizationFromProviderCommandTests
|
|||||||
|
|
||||||
organization.PlanType = PlanType.TeamsMonthly;
|
organization.PlanType = PlanType.TeamsMonthly;
|
||||||
|
|
||||||
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
|
var teamsMonthlyPlan = MockPlans.Get(PlanType.TeamsMonthly);
|
||||||
|
|
||||||
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(PlanType.TeamsMonthly).Returns(teamsMonthlyPlan);
|
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(PlanType.TeamsMonthly).Returns(teamsMonthlyPlan);
|
||||||
|
|
||||||
@@ -296,7 +296,7 @@ public class RemoveOrganizationFromProviderCommandTests
|
|||||||
|
|
||||||
organization.PlanType = PlanType.TeamsMonthly;
|
organization.PlanType = PlanType.TeamsMonthly;
|
||||||
|
|
||||||
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
|
var teamsMonthlyPlan = MockPlans.Get(PlanType.TeamsMonthly);
|
||||||
|
|
||||||
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(PlanType.TeamsMonthly).Returns(teamsMonthlyPlan);
|
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(PlanType.TeamsMonthly).Returns(teamsMonthlyPlan);
|
||||||
|
|
||||||
@@ -416,7 +416,7 @@ public class RemoveOrganizationFromProviderCommandTests
|
|||||||
organization.PlanType = PlanType.TeamsMonthly;
|
organization.PlanType = PlanType.TeamsMonthly;
|
||||||
organization.Enabled = false; // Start with a disabled organization
|
organization.Enabled = false; // Start with a disabled organization
|
||||||
|
|
||||||
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
|
var teamsMonthlyPlan = MockPlans.Get(PlanType.TeamsMonthly);
|
||||||
|
|
||||||
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(PlanType.TeamsMonthly).Returns(teamsMonthlyPlan);
|
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(PlanType.TeamsMonthly).Returns(teamsMonthlyPlan);
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ using Bit.Core.Models.Business;
|
|||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
using Bit.Core.Services;
|
using Bit.Core.Services;
|
||||||
using Bit.Core.Test.AutoFixture.OrganizationFixtures;
|
using Bit.Core.Test.AutoFixture.OrganizationFixtures;
|
||||||
|
using Bit.Core.Test.Billing.Mocks;
|
||||||
using Bit.Core.Tokens;
|
using Bit.Core.Tokens;
|
||||||
using Bit.Core.Utilities;
|
using Bit.Core.Utilities;
|
||||||
using Bit.Test.Common.AutoFixture;
|
using Bit.Test.Common.AutoFixture;
|
||||||
@@ -811,12 +812,12 @@ public class ProviderServiceTests
|
|||||||
organization.Plan = "Enterprise (Monthly)";
|
organization.Plan = "Enterprise (Monthly)";
|
||||||
|
|
||||||
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(organization.PlanType)
|
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(organization.PlanType)
|
||||||
.Returns(StaticStore.GetPlan(organization.PlanType));
|
.Returns(MockPlans.Get(organization.PlanType));
|
||||||
|
|
||||||
var expectedPlanType = PlanType.EnterpriseMonthly2020;
|
var expectedPlanType = PlanType.EnterpriseMonthly2020;
|
||||||
|
|
||||||
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(expectedPlanType)
|
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(expectedPlanType)
|
||||||
.Returns(StaticStore.GetPlan(expectedPlanType));
|
.Returns(MockPlans.Get(expectedPlanType));
|
||||||
|
|
||||||
var expectedPlanId = "2020-enterprise-org-seat-monthly";
|
var expectedPlanId = "2020-enterprise-org-seat-monthly";
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ using Bit.Core.Enums;
|
|||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
using Bit.Core.Services;
|
using Bit.Core.Services;
|
||||||
using Bit.Core.Settings;
|
using Bit.Core.Settings;
|
||||||
|
using Bit.Core.Test.Billing.Mocks;
|
||||||
using Bit.Core.Utilities;
|
using Bit.Core.Utilities;
|
||||||
using Bit.Test.Common.AutoFixture.Attributes;
|
using Bit.Test.Common.AutoFixture.Attributes;
|
||||||
using Microsoft.AspNetCore.DataProtection;
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
@@ -72,7 +73,7 @@ public class BusinessUnitConverterTests
|
|||||||
{
|
{
|
||||||
organization.PlanType = PlanType.EnterpriseAnnually2020;
|
organization.PlanType = PlanType.EnterpriseAnnually2020;
|
||||||
|
|
||||||
var enterpriseAnnually2020 = StaticStore.GetPlan(PlanType.EnterpriseAnnually2020);
|
var enterpriseAnnually2020 = MockPlans.Get(PlanType.EnterpriseAnnually2020);
|
||||||
|
|
||||||
var subscription = new Subscription
|
var subscription = new Subscription
|
||||||
{
|
{
|
||||||
@@ -134,7 +135,7 @@ public class BusinessUnitConverterTests
|
|||||||
_pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually2020)
|
_pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually2020)
|
||||||
.Returns(enterpriseAnnually2020);
|
.Returns(enterpriseAnnually2020);
|
||||||
|
|
||||||
var enterpriseAnnually = StaticStore.GetPlan(PlanType.EnterpriseAnnually);
|
var enterpriseAnnually = MockPlans.Get(PlanType.EnterpriseAnnually);
|
||||||
|
|
||||||
_pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually)
|
_pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually)
|
||||||
.Returns(enterpriseAnnually);
|
.Returns(enterpriseAnnually);
|
||||||
@@ -242,7 +243,7 @@ public class BusinessUnitConverterTests
|
|||||||
argument.Status == ProviderStatusType.Pending &&
|
argument.Status == ProviderStatusType.Pending &&
|
||||||
argument.Type == ProviderType.BusinessUnit)).Returns(provider);
|
argument.Type == ProviderType.BusinessUnit)).Returns(provider);
|
||||||
|
|
||||||
var plan = StaticStore.GetPlan(organization.PlanType);
|
var plan = MockPlans.Get(organization.PlanType);
|
||||||
|
|
||||||
_pricingClient.GetPlanOrThrow(organization.PlanType).Returns(plan);
|
_pricingClient.GetPlanOrThrow(organization.PlanType).Returns(plan);
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ using Bit.Core.Exceptions;
|
|||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
using Bit.Core.Services;
|
using Bit.Core.Services;
|
||||||
using Bit.Core.Settings;
|
using Bit.Core.Settings;
|
||||||
using Bit.Core.Utilities;
|
using Bit.Core.Test.Billing.Mocks;
|
||||||
using Bit.Test.Common.AutoFixture;
|
using Bit.Test.Common.AutoFixture;
|
||||||
using Bit.Test.Common.AutoFixture.Attributes;
|
using Bit.Test.Common.AutoFixture.Attributes;
|
||||||
using Braintree;
|
using Braintree;
|
||||||
@@ -140,7 +140,7 @@ public class ProviderBillingServiceTests
|
|||||||
.Returns(existingPlan);
|
.Returns(existingPlan);
|
||||||
|
|
||||||
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(existingPlan.PlanType)
|
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(existingPlan.PlanType)
|
||||||
.Returns(StaticStore.GetPlan(existingPlan.PlanType));
|
.Returns(MockPlans.Get(existingPlan.PlanType));
|
||||||
|
|
||||||
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider)
|
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider)
|
||||||
.Returns(new Subscription
|
.Returns(new Subscription
|
||||||
@@ -155,7 +155,7 @@ public class ProviderBillingServiceTests
|
|||||||
Id = "si_ent_annual",
|
Id = "si_ent_annual",
|
||||||
Price = new Price
|
Price = new Price
|
||||||
{
|
{
|
||||||
Id = StaticStore.GetPlan(PlanType.EnterpriseAnnually).PasswordManager
|
Id = MockPlans.Get(PlanType.EnterpriseAnnually).PasswordManager
|
||||||
.StripeProviderPortalSeatPlanId
|
.StripeProviderPortalSeatPlanId
|
||||||
},
|
},
|
||||||
Quantity = 10
|
Quantity = 10
|
||||||
@@ -168,7 +168,7 @@ public class ProviderBillingServiceTests
|
|||||||
new ChangeProviderPlanCommand(provider, providerPlanId, PlanType.EnterpriseMonthly);
|
new ChangeProviderPlanCommand(provider, providerPlanId, PlanType.EnterpriseMonthly);
|
||||||
|
|
||||||
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(command.NewPlan)
|
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(command.NewPlan)
|
||||||
.Returns(StaticStore.GetPlan(command.NewPlan));
|
.Returns(MockPlans.Get(command.NewPlan));
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await sutProvider.Sut.ChangePlan(command);
|
await sutProvider.Sut.ChangePlan(command);
|
||||||
@@ -185,7 +185,7 @@ public class ProviderBillingServiceTests
|
|||||||
Arg.Is<SubscriptionUpdateOptions>(p =>
|
Arg.Is<SubscriptionUpdateOptions>(p =>
|
||||||
p.Items.Count(si => si.Id == "si_ent_annual" && si.Deleted == true) == 1));
|
p.Items.Count(si => si.Id == "si_ent_annual" && si.Deleted == true) == 1));
|
||||||
|
|
||||||
var newPlanCfg = StaticStore.GetPlan(command.NewPlan);
|
var newPlanCfg = MockPlans.Get(command.NewPlan);
|
||||||
await stripeAdapter.Received(1)
|
await stripeAdapter.Received(1)
|
||||||
.SubscriptionUpdateAsync(
|
.SubscriptionUpdateAsync(
|
||||||
Arg.Is(provider.GatewaySubscriptionId),
|
Arg.Is(provider.GatewaySubscriptionId),
|
||||||
@@ -491,7 +491,7 @@ public class ProviderBillingServiceTests
|
|||||||
foreach (var plan in providerPlans)
|
foreach (var plan in providerPlans)
|
||||||
{
|
{
|
||||||
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
|
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
|
||||||
.Returns(StaticStore.GetPlan(plan.PlanType));
|
.Returns(MockPlans.Get(plan.PlanType));
|
||||||
}
|
}
|
||||||
|
|
||||||
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id).Returns(providerPlans);
|
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id).Returns(providerPlans);
|
||||||
@@ -514,7 +514,7 @@ public class ProviderBillingServiceTests
|
|||||||
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider).Returns(subscription);
|
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider).Returns(subscription);
|
||||||
|
|
||||||
// 50 seats currently assigned with a seat minimum of 100
|
// 50 seats currently assigned with a seat minimum of 100
|
||||||
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
|
var teamsMonthlyPlan = MockPlans.Get(PlanType.TeamsMonthly);
|
||||||
|
|
||||||
sutProvider.GetDependency<IProviderOrganizationRepository>().GetManyDetailsByProviderAsync(provider.Id).Returns(
|
sutProvider.GetDependency<IProviderOrganizationRepository>().GetManyDetailsByProviderAsync(provider.Id).Returns(
|
||||||
[
|
[
|
||||||
@@ -573,7 +573,7 @@ public class ProviderBillingServiceTests
|
|||||||
foreach (var plan in providerPlans)
|
foreach (var plan in providerPlans)
|
||||||
{
|
{
|
||||||
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
|
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
|
||||||
.Returns(StaticStore.GetPlan(plan.PlanType));
|
.Returns(MockPlans.Get(plan.PlanType));
|
||||||
}
|
}
|
||||||
|
|
||||||
var providerPlan = providerPlans.First();
|
var providerPlan = providerPlans.First();
|
||||||
@@ -598,7 +598,7 @@ public class ProviderBillingServiceTests
|
|||||||
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider).Returns(subscription);
|
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider).Returns(subscription);
|
||||||
|
|
||||||
// 95 seats currently assigned with a seat minimum of 100
|
// 95 seats currently assigned with a seat minimum of 100
|
||||||
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
|
var teamsMonthlyPlan = MockPlans.Get(PlanType.TeamsMonthly);
|
||||||
|
|
||||||
sutProvider.GetDependency<IProviderOrganizationRepository>().GetManyDetailsByProviderAsync(provider.Id).Returns(
|
sutProvider.GetDependency<IProviderOrganizationRepository>().GetManyDetailsByProviderAsync(provider.Id).Returns(
|
||||||
[
|
[
|
||||||
@@ -661,7 +661,7 @@ public class ProviderBillingServiceTests
|
|||||||
foreach (var plan in providerPlans)
|
foreach (var plan in providerPlans)
|
||||||
{
|
{
|
||||||
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
|
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
|
||||||
.Returns(StaticStore.GetPlan(plan.PlanType));
|
.Returns(MockPlans.Get(plan.PlanType));
|
||||||
}
|
}
|
||||||
|
|
||||||
var providerPlan = providerPlans.First();
|
var providerPlan = providerPlans.First();
|
||||||
@@ -686,7 +686,7 @@ public class ProviderBillingServiceTests
|
|||||||
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider).Returns(subscription);
|
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider).Returns(subscription);
|
||||||
|
|
||||||
// 110 seats currently assigned with a seat minimum of 100
|
// 110 seats currently assigned with a seat minimum of 100
|
||||||
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
|
var teamsMonthlyPlan = MockPlans.Get(PlanType.TeamsMonthly);
|
||||||
|
|
||||||
sutProvider.GetDependency<IProviderOrganizationRepository>().GetManyDetailsByProviderAsync(provider.Id).Returns(
|
sutProvider.GetDependency<IProviderOrganizationRepository>().GetManyDetailsByProviderAsync(provider.Id).Returns(
|
||||||
[
|
[
|
||||||
@@ -749,7 +749,7 @@ public class ProviderBillingServiceTests
|
|||||||
foreach (var plan in providerPlans)
|
foreach (var plan in providerPlans)
|
||||||
{
|
{
|
||||||
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
|
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
|
||||||
.Returns(StaticStore.GetPlan(plan.PlanType));
|
.Returns(MockPlans.Get(plan.PlanType));
|
||||||
}
|
}
|
||||||
|
|
||||||
var providerPlan = providerPlans.First();
|
var providerPlan = providerPlans.First();
|
||||||
@@ -774,7 +774,7 @@ public class ProviderBillingServiceTests
|
|||||||
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider).Returns(subscription);
|
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider).Returns(subscription);
|
||||||
|
|
||||||
// 110 seats currently assigned with a seat minimum of 100
|
// 110 seats currently assigned with a seat minimum of 100
|
||||||
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
|
var teamsMonthlyPlan = MockPlans.Get(PlanType.TeamsMonthly);
|
||||||
|
|
||||||
sutProvider.GetDependency<IProviderOrganizationRepository>().GetManyDetailsByProviderAsync(provider.Id).Returns(
|
sutProvider.GetDependency<IProviderOrganizationRepository>().GetManyDetailsByProviderAsync(provider.Id).Returns(
|
||||||
[
|
[
|
||||||
@@ -827,13 +827,13 @@ public class ProviderBillingServiceTests
|
|||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
|
||||||
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(planType).Returns(StaticStore.GetPlan(planType));
|
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(planType).Returns(MockPlans.Get(planType));
|
||||||
|
|
||||||
sutProvider.GetDependency<IProviderOrganizationRepository>().GetManyDetailsByProviderAsync(provider.Id).Returns(
|
sutProvider.GetDependency<IProviderOrganizationRepository>().GetManyDetailsByProviderAsync(provider.Id).Returns(
|
||||||
[
|
[
|
||||||
new ProviderOrganizationOrganizationDetails
|
new ProviderOrganizationOrganizationDetails
|
||||||
{
|
{
|
||||||
Plan = StaticStore.GetPlan(planType).Name,
|
Plan = MockPlans.Get(planType).Name,
|
||||||
Status = OrganizationStatusType.Managed,
|
Status = OrganizationStatusType.Managed,
|
||||||
Seats = 5
|
Seats = 5
|
||||||
}
|
}
|
||||||
@@ -865,13 +865,13 @@ public class ProviderBillingServiceTests
|
|||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
|
||||||
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(planType).Returns(StaticStore.GetPlan(planType));
|
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(planType).Returns(MockPlans.Get(planType));
|
||||||
|
|
||||||
sutProvider.GetDependency<IProviderOrganizationRepository>().GetManyDetailsByProviderAsync(provider.Id).Returns(
|
sutProvider.GetDependency<IProviderOrganizationRepository>().GetManyDetailsByProviderAsync(provider.Id).Returns(
|
||||||
[
|
[
|
||||||
new ProviderOrganizationOrganizationDetails
|
new ProviderOrganizationOrganizationDetails
|
||||||
{
|
{
|
||||||
Plan = StaticStore.GetPlan(planType).Name,
|
Plan = MockPlans.Get(planType).Name,
|
||||||
Status = OrganizationStatusType.Managed,
|
Status = OrganizationStatusType.Managed,
|
||||||
Seats = 15
|
Seats = 15
|
||||||
}
|
}
|
||||||
@@ -1238,7 +1238,7 @@ public class ProviderBillingServiceTests
|
|||||||
.Returns(providerPlans);
|
.Returns(providerPlans);
|
||||||
|
|
||||||
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(PlanType.EnterpriseMonthly)
|
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(PlanType.EnterpriseMonthly)
|
||||||
.Returns(StaticStore.GetPlan(PlanType.EnterpriseMonthly));
|
.Returns(MockPlans.Get(PlanType.EnterpriseMonthly));
|
||||||
|
|
||||||
await ThrowsBillingExceptionAsync(() => sutProvider.Sut.SetupSubscription(provider));
|
await ThrowsBillingExceptionAsync(() => sutProvider.Sut.SetupSubscription(provider));
|
||||||
|
|
||||||
@@ -1266,7 +1266,7 @@ public class ProviderBillingServiceTests
|
|||||||
.Returns(providerPlans);
|
.Returns(providerPlans);
|
||||||
|
|
||||||
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(PlanType.TeamsMonthly)
|
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(PlanType.TeamsMonthly)
|
||||||
.Returns(StaticStore.GetPlan(PlanType.TeamsMonthly));
|
.Returns(MockPlans.Get(PlanType.TeamsMonthly));
|
||||||
|
|
||||||
await ThrowsBillingExceptionAsync(() => sutProvider.Sut.SetupSubscription(provider));
|
await ThrowsBillingExceptionAsync(() => sutProvider.Sut.SetupSubscription(provider));
|
||||||
|
|
||||||
@@ -1317,7 +1317,7 @@ public class ProviderBillingServiceTests
|
|||||||
foreach (var plan in providerPlans)
|
foreach (var plan in providerPlans)
|
||||||
{
|
{
|
||||||
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
|
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
|
||||||
.Returns(StaticStore.GetPlan(plan.PlanType));
|
.Returns(MockPlans.Get(plan.PlanType));
|
||||||
}
|
}
|
||||||
|
|
||||||
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
|
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
|
||||||
@@ -1373,7 +1373,7 @@ public class ProviderBillingServiceTests
|
|||||||
foreach (var plan in providerPlans)
|
foreach (var plan in providerPlans)
|
||||||
{
|
{
|
||||||
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
|
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
|
||||||
.Returns(StaticStore.GetPlan(plan.PlanType));
|
.Returns(MockPlans.Get(plan.PlanType));
|
||||||
}
|
}
|
||||||
|
|
||||||
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
|
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
|
||||||
@@ -1449,7 +1449,7 @@ public class ProviderBillingServiceTests
|
|||||||
foreach (var plan in providerPlans)
|
foreach (var plan in providerPlans)
|
||||||
{
|
{
|
||||||
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
|
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
|
||||||
.Returns(StaticStore.GetPlan(plan.PlanType));
|
.Returns(MockPlans.Get(plan.PlanType));
|
||||||
}
|
}
|
||||||
|
|
||||||
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
|
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
|
||||||
@@ -1525,7 +1525,7 @@ public class ProviderBillingServiceTests
|
|||||||
foreach (var plan in providerPlans)
|
foreach (var plan in providerPlans)
|
||||||
{
|
{
|
||||||
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
|
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
|
||||||
.Returns(StaticStore.GetPlan(plan.PlanType));
|
.Returns(MockPlans.Get(plan.PlanType));
|
||||||
}
|
}
|
||||||
|
|
||||||
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
|
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
|
||||||
@@ -1626,7 +1626,7 @@ public class ProviderBillingServiceTests
|
|||||||
foreach (var plan in providerPlans)
|
foreach (var plan in providerPlans)
|
||||||
{
|
{
|
||||||
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
|
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
|
||||||
.Returns(StaticStore.GetPlan(plan.PlanType));
|
.Returns(MockPlans.Get(plan.PlanType));
|
||||||
}
|
}
|
||||||
|
|
||||||
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
|
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
|
||||||
@@ -1704,7 +1704,7 @@ public class ProviderBillingServiceTests
|
|||||||
foreach (var plan in providerPlans)
|
foreach (var plan in providerPlans)
|
||||||
{
|
{
|
||||||
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
|
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
|
||||||
.Returns(StaticStore.GetPlan(plan.PlanType));
|
.Returns(MockPlans.Get(plan.PlanType));
|
||||||
}
|
}
|
||||||
|
|
||||||
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
|
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
|
||||||
@@ -1772,8 +1772,8 @@ public class ProviderBillingServiceTests
|
|||||||
const string enterpriseLineItemId = "enterprise_line_item_id";
|
const string enterpriseLineItemId = "enterprise_line_item_id";
|
||||||
const string teamsLineItemId = "teams_line_item_id";
|
const string teamsLineItemId = "teams_line_item_id";
|
||||||
|
|
||||||
var enterprisePriceId = StaticStore.GetPlan(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
|
var enterprisePriceId = MockPlans.Get(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
|
||||||
var teamsPriceId = StaticStore.GetPlan(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
|
var teamsPriceId = MockPlans.Get(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
|
||||||
|
|
||||||
var subscription = new Subscription
|
var subscription = new Subscription
|
||||||
{
|
{
|
||||||
@@ -1806,7 +1806,7 @@ public class ProviderBillingServiceTests
|
|||||||
foreach (var plan in providerPlans)
|
foreach (var plan in providerPlans)
|
||||||
{
|
{
|
||||||
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
|
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
|
||||||
.Returns(StaticStore.GetPlan(plan.PlanType));
|
.Returns(MockPlans.Get(plan.PlanType));
|
||||||
}
|
}
|
||||||
|
|
||||||
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
|
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
|
||||||
@@ -1852,8 +1852,8 @@ public class ProviderBillingServiceTests
|
|||||||
const string enterpriseLineItemId = "enterprise_line_item_id";
|
const string enterpriseLineItemId = "enterprise_line_item_id";
|
||||||
const string teamsLineItemId = "teams_line_item_id";
|
const string teamsLineItemId = "teams_line_item_id";
|
||||||
|
|
||||||
var enterprisePriceId = StaticStore.GetPlan(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
|
var enterprisePriceId = MockPlans.Get(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
|
||||||
var teamsPriceId = StaticStore.GetPlan(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
|
var teamsPriceId = MockPlans.Get(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
|
||||||
|
|
||||||
var subscription = new Subscription
|
var subscription = new Subscription
|
||||||
{
|
{
|
||||||
@@ -1886,7 +1886,7 @@ public class ProviderBillingServiceTests
|
|||||||
foreach (var plan in providerPlans)
|
foreach (var plan in providerPlans)
|
||||||
{
|
{
|
||||||
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
|
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
|
||||||
.Returns(StaticStore.GetPlan(plan.PlanType));
|
.Returns(MockPlans.Get(plan.PlanType));
|
||||||
}
|
}
|
||||||
|
|
||||||
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
|
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
|
||||||
@@ -1932,8 +1932,8 @@ public class ProviderBillingServiceTests
|
|||||||
const string enterpriseLineItemId = "enterprise_line_item_id";
|
const string enterpriseLineItemId = "enterprise_line_item_id";
|
||||||
const string teamsLineItemId = "teams_line_item_id";
|
const string teamsLineItemId = "teams_line_item_id";
|
||||||
|
|
||||||
var enterprisePriceId = StaticStore.GetPlan(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
|
var enterprisePriceId = MockPlans.Get(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
|
||||||
var teamsPriceId = StaticStore.GetPlan(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
|
var teamsPriceId = MockPlans.Get(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
|
||||||
|
|
||||||
var subscription = new Subscription
|
var subscription = new Subscription
|
||||||
{
|
{
|
||||||
@@ -1966,7 +1966,7 @@ public class ProviderBillingServiceTests
|
|||||||
foreach (var plan in providerPlans)
|
foreach (var plan in providerPlans)
|
||||||
{
|
{
|
||||||
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
|
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
|
||||||
.Returns(StaticStore.GetPlan(plan.PlanType));
|
.Returns(MockPlans.Get(plan.PlanType));
|
||||||
}
|
}
|
||||||
|
|
||||||
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
|
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
|
||||||
@@ -2006,8 +2006,8 @@ public class ProviderBillingServiceTests
|
|||||||
const string enterpriseLineItemId = "enterprise_line_item_id";
|
const string enterpriseLineItemId = "enterprise_line_item_id";
|
||||||
const string teamsLineItemId = "teams_line_item_id";
|
const string teamsLineItemId = "teams_line_item_id";
|
||||||
|
|
||||||
var enterprisePriceId = StaticStore.GetPlan(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
|
var enterprisePriceId = MockPlans.Get(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
|
||||||
var teamsPriceId = StaticStore.GetPlan(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
|
var teamsPriceId = MockPlans.Get(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
|
||||||
|
|
||||||
var subscription = new Subscription
|
var subscription = new Subscription
|
||||||
{
|
{
|
||||||
@@ -2040,7 +2040,7 @@ public class ProviderBillingServiceTests
|
|||||||
foreach (var plan in providerPlans)
|
foreach (var plan in providerPlans)
|
||||||
{
|
{
|
||||||
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
|
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
|
||||||
.Returns(StaticStore.GetPlan(plan.PlanType));
|
.Returns(MockPlans.Get(plan.PlanType));
|
||||||
}
|
}
|
||||||
|
|
||||||
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
|
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
|
||||||
@@ -2086,8 +2086,8 @@ public class ProviderBillingServiceTests
|
|||||||
const string enterpriseLineItemId = "enterprise_line_item_id";
|
const string enterpriseLineItemId = "enterprise_line_item_id";
|
||||||
const string teamsLineItemId = "teams_line_item_id";
|
const string teamsLineItemId = "teams_line_item_id";
|
||||||
|
|
||||||
var enterprisePriceId = StaticStore.GetPlan(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
|
var enterprisePriceId = MockPlans.Get(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
|
||||||
var teamsPriceId = StaticStore.GetPlan(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
|
var teamsPriceId = MockPlans.Get(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
|
||||||
|
|
||||||
var subscription = new Subscription
|
var subscription = new Subscription
|
||||||
{
|
{
|
||||||
@@ -2120,7 +2120,7 @@ public class ProviderBillingServiceTests
|
|||||||
foreach (var plan in providerPlans)
|
foreach (var plan in providerPlans)
|
||||||
{
|
{
|
||||||
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
|
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
|
||||||
.Returns(StaticStore.GetPlan(plan.PlanType));
|
.Returns(MockPlans.Get(plan.PlanType));
|
||||||
}
|
}
|
||||||
|
|
||||||
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
|
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ using Bit.Core.Exceptions;
|
|||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
using Bit.Core.SecretsManager.Repositories;
|
using Bit.Core.SecretsManager.Repositories;
|
||||||
using Bit.Core.Settings;
|
using Bit.Core.Settings;
|
||||||
using Bit.Core.Utilities;
|
using Bit.Core.Test.Billing.Mocks;
|
||||||
using Bit.Test.Common.AutoFixture;
|
using Bit.Test.Common.AutoFixture;
|
||||||
using Bit.Test.Common.AutoFixture.Attributes;
|
using Bit.Test.Common.AutoFixture.Attributes;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
@@ -69,7 +69,7 @@ public class MaxProjectsQueryTests
|
|||||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
|
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
|
||||||
|
|
||||||
sutProvider.GetDependency<IPricingClient>().GetPlan(organization.PlanType)
|
sutProvider.GetDependency<IPricingClient>().GetPlan(organization.PlanType)
|
||||||
.Returns(StaticStore.GetPlan(organization.PlanType));
|
.Returns(MockPlans.Get(organization.PlanType));
|
||||||
|
|
||||||
var (limit, overLimit) = await sutProvider.Sut.GetByOrgIdAsync(organization.Id, 1);
|
var (limit, overLimit) = await sutProvider.Sut.GetByOrgIdAsync(organization.Id, 1);
|
||||||
|
|
||||||
@@ -114,7 +114,7 @@ public class MaxProjectsQueryTests
|
|||||||
.Returns(projects);
|
.Returns(projects);
|
||||||
|
|
||||||
sutProvider.GetDependency<IPricingClient>().GetPlan(organization.PlanType)
|
sutProvider.GetDependency<IPricingClient>().GetPlan(organization.PlanType)
|
||||||
.Returns(StaticStore.GetPlan(organization.PlanType));
|
.Returns(MockPlans.Get(organization.PlanType));
|
||||||
|
|
||||||
var (max, overMax) = await sutProvider.Sut.GetByOrgIdAsync(organization.Id, projectsToAdd);
|
var (max, overMax) = await sutProvider.Sut.GetByOrgIdAsync(organization.Id, projectsToAdd);
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using Bit.Core.AdminConsole.Entities;
|
|||||||
using Bit.Core.Auth.Entities;
|
using Bit.Core.Auth.Entities;
|
||||||
using Bit.Core.Auth.Models.Data;
|
using Bit.Core.Auth.Models.Data;
|
||||||
using Bit.Core.Auth.Repositories;
|
using Bit.Core.Auth.Repositories;
|
||||||
|
using Bit.Core.Auth.UserFeatures.Registration;
|
||||||
using Bit.Core.Entities;
|
using Bit.Core.Entities;
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
@@ -18,6 +19,7 @@ using Duende.IdentityServer.Models;
|
|||||||
using Duende.IdentityServer.Services;
|
using Duende.IdentityServer.Services;
|
||||||
using Microsoft.AspNetCore.Authentication;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
@@ -74,17 +76,6 @@ public class AccountControllerTest
|
|||||||
return resolvedAuthService;
|
return resolvedAuthService;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void InvokeEnsureOrgUserStatusAllowed(
|
|
||||||
AccountController controller,
|
|
||||||
OrganizationUserStatusType status)
|
|
||||||
{
|
|
||||||
var method = typeof(AccountController).GetMethod(
|
|
||||||
"EnsureAcceptedOrConfirmedOrgUserStatus",
|
|
||||||
BindingFlags.Instance | BindingFlags.NonPublic);
|
|
||||||
Assert.NotNull(method);
|
|
||||||
method.Invoke(controller, [status, "Org"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static AuthenticateResult BuildSuccessfulExternalAuth(Guid orgId, string providerUserId, string email)
|
private static AuthenticateResult BuildSuccessfulExternalAuth(Guid orgId, string providerUserId, string email)
|
||||||
{
|
{
|
||||||
var claims = new[]
|
var claims = new[]
|
||||||
@@ -241,82 +232,6 @@ public class AccountControllerTest
|
|||||||
return counts;
|
return counts;
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
|
||||||
public void EnsureOrgUserStatusAllowed_AllowsAcceptedAndConfirmed(
|
|
||||||
SutProvider<AccountController> sutProvider)
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
sutProvider.GetDependency<II18nService>()
|
|
||||||
.T(Arg.Any<string>(), Arg.Any<object?[]>())
|
|
||||||
.Returns(ci => (string)ci[0]!);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var ex1 = Record.Exception(() =>
|
|
||||||
InvokeEnsureOrgUserStatusAllowed(sutProvider.Sut, OrganizationUserStatusType.Accepted));
|
|
||||||
var ex2 = Record.Exception(() =>
|
|
||||||
InvokeEnsureOrgUserStatusAllowed(sutProvider.Sut, OrganizationUserStatusType.Confirmed));
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.Null(ex1);
|
|
||||||
Assert.Null(ex2);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
|
||||||
public void EnsureOrgUserStatusAllowed_Invited_ThrowsAcceptInvite(
|
|
||||||
SutProvider<AccountController> sutProvider)
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
sutProvider.GetDependency<II18nService>()
|
|
||||||
.T(Arg.Any<string>(), Arg.Any<object?[]>())
|
|
||||||
.Returns(ci => (string)ci[0]!);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var ex = Assert.Throws<TargetInvocationException>(() =>
|
|
||||||
InvokeEnsureOrgUserStatusAllowed(sutProvider.Sut, OrganizationUserStatusType.Invited));
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsType<Exception>(ex.InnerException);
|
|
||||||
Assert.Equal("AcceptInviteBeforeUsingSSO", ex.InnerException!.Message);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
|
||||||
public void EnsureOrgUserStatusAllowed_Revoked_ThrowsAccessRevoked(
|
|
||||||
SutProvider<AccountController> sutProvider)
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
sutProvider.GetDependency<II18nService>()
|
|
||||||
.T(Arg.Any<string>(), Arg.Any<object?[]>())
|
|
||||||
.Returns(ci => (string)ci[0]!);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var ex = Assert.Throws<TargetInvocationException>(() =>
|
|
||||||
InvokeEnsureOrgUserStatusAllowed(sutProvider.Sut, OrganizationUserStatusType.Revoked));
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsType<Exception>(ex.InnerException);
|
|
||||||
Assert.Equal("OrganizationUserAccessRevoked", ex.InnerException!.Message);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
|
||||||
public void EnsureOrgUserStatusAllowed_UnknownStatus_ThrowsUnknown(
|
|
||||||
SutProvider<AccountController> sutProvider)
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
sutProvider.GetDependency<II18nService>()
|
|
||||||
.T(Arg.Any<string>(), Arg.Any<object?[]>())
|
|
||||||
.Returns(ci => (string)ci[0]!);
|
|
||||||
|
|
||||||
var unknown = (OrganizationUserStatusType)999;
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var ex = Assert.Throws<TargetInvocationException>(() =>
|
|
||||||
InvokeEnsureOrgUserStatusAllowed(sutProvider.Sut, unknown));
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.IsType<Exception>(ex.InnerException);
|
|
||||||
Assert.Equal("OrganizationUserUnknownStatus", ex.InnerException!.Message);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
public async Task ExternalCallback_PreventNonCompliantTrue_ExistingUser_NoOrgUser_ThrowsCouldNotFindOrganizationUser(
|
public async Task ExternalCallback_PreventNonCompliantTrue_ExistingUser_NoOrgUser_ThrowsCouldNotFindOrganizationUser(
|
||||||
SutProvider<AccountController> sutProvider)
|
SutProvider<AccountController> sutProvider)
|
||||||
@@ -357,7 +272,7 @@ public class AccountControllerTest
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
public async Task ExternalCallback_PreventNonCompliantTrue_ExistingUser_OrgUserInvited_ThrowsAcceptInvite(
|
public async Task ExternalCallback_PreventNonCompliantTrue_ExistingUser_OrgUserInvited_AllowsLogin(
|
||||||
SutProvider<AccountController> sutProvider)
|
SutProvider<AccountController> sutProvider)
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
@@ -374,7 +289,7 @@ public class AccountControllerTest
|
|||||||
};
|
};
|
||||||
|
|
||||||
var authResult = BuildSuccessfulExternalAuth(orgId, providerUserId, user.Email!);
|
var authResult = BuildSuccessfulExternalAuth(orgId, providerUserId, user.Email!);
|
||||||
SetupHttpContextWithAuth(sutProvider, authResult);
|
var authService = SetupHttpContextWithAuth(sutProvider, authResult);
|
||||||
|
|
||||||
sutProvider.GetDependency<II18nService>()
|
sutProvider.GetDependency<II18nService>()
|
||||||
.T(Arg.Any<string>(), Arg.Any<object?[]>())
|
.T(Arg.Any<string>(), Arg.Any<object?[]>())
|
||||||
@@ -392,9 +307,23 @@ public class AccountControllerTest
|
|||||||
sutProvider.GetDependency<IIdentityServerInteractionService>()
|
sutProvider.GetDependency<IIdentityServerInteractionService>()
|
||||||
.GetAuthorizationContextAsync("~/").Returns((AuthorizationRequest?)null);
|
.GetAuthorizationContextAsync("~/").Returns((AuthorizationRequest?)null);
|
||||||
|
|
||||||
// Act + Assert
|
// Act
|
||||||
var ex = await Assert.ThrowsAsync<Exception>(() => sutProvider.Sut.ExternalCallback());
|
var result = await sutProvider.Sut.ExternalCallback();
|
||||||
Assert.Equal("AcceptInviteBeforeUsingSSO", ex.Message);
|
|
||||||
|
// Assert
|
||||||
|
var redirect = Assert.IsType<RedirectResult>(result);
|
||||||
|
Assert.Equal("~/", redirect.Url);
|
||||||
|
|
||||||
|
await authService.Received().SignInAsync(
|
||||||
|
Arg.Any<HttpContext>(),
|
||||||
|
Arg.Any<string?>(),
|
||||||
|
Arg.Any<ClaimsPrincipal>(),
|
||||||
|
Arg.Any<AuthenticationProperties>());
|
||||||
|
|
||||||
|
await authService.Received().SignOutAsync(
|
||||||
|
Arg.Any<HttpContext>(),
|
||||||
|
AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme,
|
||||||
|
Arg.Any<AuthenticationProperties>());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
@@ -930,13 +859,13 @@ public class AccountControllerTest
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
public async Task AutoProvisionUserAsync_WithExistingAcceptedUser_CreatesSsoLinkAndReturnsUser(
|
public async Task CreateUserAndOrgUserConditionallyAsync_WithExistingAcceptedUser_CreatesSsoLinkAndReturnsUser(
|
||||||
SutProvider<AccountController> sutProvider)
|
SutProvider<AccountController> sutProvider)
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var orgId = Guid.NewGuid();
|
var orgId = Guid.NewGuid();
|
||||||
var providerUserId = "ext-456";
|
var providerUserId = "provider-user-id";
|
||||||
var email = "jit@example.com";
|
var email = "user@example.com";
|
||||||
var existingUser = new User { Id = Guid.NewGuid(), Email = email };
|
var existingUser = new User { Id = Guid.NewGuid(), Email = email };
|
||||||
var organization = new Organization { Id = orgId, Name = "Org" };
|
var organization = new Organization { Id = orgId, Name = "Org" };
|
||||||
var orgUser = new OrganizationUser
|
var orgUser = new OrganizationUser
|
||||||
@@ -965,12 +894,12 @@ public class AccountControllerTest
|
|||||||
var config = new SsoConfigurationData();
|
var config = new SsoConfigurationData();
|
||||||
|
|
||||||
var method = typeof(AccountController).GetMethod(
|
var method = typeof(AccountController).GetMethod(
|
||||||
"AutoProvisionUserAsync",
|
"CreateUserAndOrgUserConditionallyAsync",
|
||||||
BindingFlags.Instance | BindingFlags.NonPublic);
|
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||||
Assert.NotNull(method);
|
Assert.NotNull(method);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var task = (Task<(User user, Organization organization, OrganizationUser orgUser)>)method!.Invoke(sutProvider.Sut, new object[]
|
var task = (Task<(User user, Organization organization, OrganizationUser orgUser)>)method.Invoke(sutProvider.Sut, new object[]
|
||||||
{
|
{
|
||||||
orgId.ToString(),
|
orgId.ToString(),
|
||||||
providerUserId,
|
providerUserId,
|
||||||
@@ -992,6 +921,61 @@ public class AccountControllerTest
|
|||||||
EventType.OrganizationUser_FirstSsoLogin);
|
EventType.OrganizationUser_FirstSsoLogin);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task CreateUserAndOrgUserConditionallyAsync_WithExistingInvitedUser_ThrowsAcceptInviteBeforeUsingSSO(
|
||||||
|
SutProvider<AccountController> sutProvider)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var orgId = Guid.NewGuid();
|
||||||
|
var providerUserId = "provider-user-id";
|
||||||
|
var email = "user@example.com";
|
||||||
|
var existingUser = new User { Id = Guid.NewGuid(), Email = email, UsesKeyConnector = false };
|
||||||
|
var organization = new Organization { Id = orgId, Name = "Org" };
|
||||||
|
var orgUser = new OrganizationUser
|
||||||
|
{
|
||||||
|
OrganizationId = orgId,
|
||||||
|
UserId = existingUser.Id,
|
||||||
|
Status = OrganizationUserStatusType.Invited,
|
||||||
|
Type = OrganizationUserType.User
|
||||||
|
};
|
||||||
|
|
||||||
|
// i18n returns the key so we can assert on message contents
|
||||||
|
sutProvider.GetDependency<II18nService>()
|
||||||
|
.T(Arg.Any<string>(), Arg.Any<object?[]>())
|
||||||
|
.Returns(ci => (string)ci[0]!);
|
||||||
|
|
||||||
|
// Arrange repository expectations for the flow
|
||||||
|
sutProvider.GetDependency<IUserRepository>().GetByEmailAsync(email).Returns(existingUser);
|
||||||
|
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(orgId).Returns(organization);
|
||||||
|
sutProvider.GetDependency<IOrganizationUserRepository>().GetManyByUserAsync(existingUser.Id)
|
||||||
|
.Returns(new List<OrganizationUser> { orgUser });
|
||||||
|
|
||||||
|
var claims = new[]
|
||||||
|
{
|
||||||
|
new Claim(JwtClaimTypes.Email, email),
|
||||||
|
new Claim(JwtClaimTypes.Name, "Invited User")
|
||||||
|
} as IEnumerable<Claim>;
|
||||||
|
var config = new SsoConfigurationData();
|
||||||
|
|
||||||
|
var method = typeof(AccountController).GetMethod(
|
||||||
|
"CreateUserAndOrgUserConditionallyAsync",
|
||||||
|
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||||
|
Assert.NotNull(method);
|
||||||
|
|
||||||
|
// Act + Assert
|
||||||
|
var task = (Task<(User user, Organization organization, OrganizationUser orgUser)>)method.Invoke(sutProvider.Sut, new object[]
|
||||||
|
{
|
||||||
|
orgId.ToString(),
|
||||||
|
providerUserId,
|
||||||
|
claims,
|
||||||
|
null!,
|
||||||
|
config
|
||||||
|
})!;
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<Exception>(async () => await task);
|
||||||
|
Assert.Equal("AcceptInviteBeforeUsingSSO", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// PM-24579: Temporary comparison test to ensure the feature flag ON does not
|
/// PM-24579: Temporary comparison test to ensure the feature flag ON does not
|
||||||
/// regress lookup counts compared to OFF. When removing the flag, delete this
|
/// regress lookup counts compared to OFF. When removing the flag, delete this
|
||||||
@@ -1026,4 +1010,131 @@ public class AccountControllerTest
|
|||||||
_output.WriteLine($"Scenario={scenario} | OFF: SSO={offCounts.UserGetBySso}, Email={offCounts.UserGetByEmail}, Org={offCounts.OrgGetById}, OrgUserByOrg={offCounts.OrgUserGetByOrg}, OrgUserByEmail={offCounts.OrgUserGetByEmail}");
|
_output.WriteLine($"Scenario={scenario} | OFF: SSO={offCounts.UserGetBySso}, Email={offCounts.UserGetByEmail}, Org={offCounts.OrgGetById}, OrgUserByOrg={offCounts.OrgUserGetByOrg}, OrgUserByEmail={offCounts.OrgUserGetByEmail}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task AutoProvisionUserAsync_WithFeatureFlagEnabled_CallsRegisterSSOAutoProvisionedUser(
|
||||||
|
SutProvider<AccountController> sutProvider)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var orgId = Guid.NewGuid();
|
||||||
|
var providerUserId = "ext-new-user";
|
||||||
|
var email = "newuser@example.com";
|
||||||
|
var organization = new Organization { Id = orgId, Name = "Test Org", Seats = null };
|
||||||
|
|
||||||
|
// No existing user (JIT provisioning scenario)
|
||||||
|
sutProvider.GetDependency<IUserRepository>().GetByEmailAsync(email).Returns((User?)null);
|
||||||
|
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(orgId).Returns(organization);
|
||||||
|
sutProvider.GetDependency<IOrganizationUserRepository>().GetByOrganizationEmailAsync(orgId, email)
|
||||||
|
.Returns((OrganizationUser?)null);
|
||||||
|
|
||||||
|
// Feature flag enabled
|
||||||
|
sutProvider.GetDependency<IFeatureService>()
|
||||||
|
.IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates)
|
||||||
|
.Returns(true);
|
||||||
|
|
||||||
|
// Mock the RegisterSSOAutoProvisionedUserAsync to return success
|
||||||
|
sutProvider.GetDependency<IRegisterUserCommand>()
|
||||||
|
.RegisterSSOAutoProvisionedUserAsync(Arg.Any<User>(), Arg.Any<Organization>())
|
||||||
|
.Returns(IdentityResult.Success);
|
||||||
|
|
||||||
|
var claims = new[]
|
||||||
|
{
|
||||||
|
new Claim(JwtClaimTypes.Email, email),
|
||||||
|
new Claim(JwtClaimTypes.Name, "New User")
|
||||||
|
} as IEnumerable<Claim>;
|
||||||
|
var config = new SsoConfigurationData();
|
||||||
|
|
||||||
|
var method = typeof(AccountController).GetMethod(
|
||||||
|
"CreateUserAndOrgUserConditionallyAsync",
|
||||||
|
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||||
|
Assert.NotNull(method);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var task = (Task<(User user, Organization organization, OrganizationUser orgUser)>)method!.Invoke(
|
||||||
|
sutProvider.Sut,
|
||||||
|
new object[]
|
||||||
|
{
|
||||||
|
orgId.ToString(),
|
||||||
|
providerUserId,
|
||||||
|
claims,
|
||||||
|
null!,
|
||||||
|
config
|
||||||
|
})!;
|
||||||
|
|
||||||
|
var result = await task;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await sutProvider.GetDependency<IRegisterUserCommand>().Received(1)
|
||||||
|
.RegisterSSOAutoProvisionedUserAsync(
|
||||||
|
Arg.Is<User>(u => u.Email == email && u.Name == "New User"),
|
||||||
|
Arg.Is<Organization>(o => o.Id == orgId && o.Name == "Test Org"));
|
||||||
|
|
||||||
|
Assert.NotNull(result.user);
|
||||||
|
Assert.Equal(email, result.user.Email);
|
||||||
|
Assert.Equal(organization.Id, result.organization.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData]
|
||||||
|
public async Task AutoProvisionUserAsync_WithFeatureFlagDisabled_CallsRegisterUserInstead(
|
||||||
|
SutProvider<AccountController> sutProvider)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var orgId = Guid.NewGuid();
|
||||||
|
var providerUserId = "ext-legacy-user";
|
||||||
|
var email = "legacyuser@example.com";
|
||||||
|
var organization = new Organization { Id = orgId, Name = "Test Org", Seats = null };
|
||||||
|
|
||||||
|
// No existing user (JIT provisioning scenario)
|
||||||
|
sutProvider.GetDependency<IUserRepository>().GetByEmailAsync(email).Returns((User?)null);
|
||||||
|
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(orgId).Returns(organization);
|
||||||
|
sutProvider.GetDependency<IOrganizationUserRepository>().GetByOrganizationEmailAsync(orgId, email)
|
||||||
|
.Returns((OrganizationUser?)null);
|
||||||
|
|
||||||
|
// Feature flag disabled
|
||||||
|
sutProvider.GetDependency<IFeatureService>()
|
||||||
|
.IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates)
|
||||||
|
.Returns(false);
|
||||||
|
|
||||||
|
// Mock the RegisterUser to return success
|
||||||
|
sutProvider.GetDependency<IRegisterUserCommand>()
|
||||||
|
.RegisterUser(Arg.Any<User>())
|
||||||
|
.Returns(IdentityResult.Success);
|
||||||
|
|
||||||
|
var claims = new[]
|
||||||
|
{
|
||||||
|
new Claim(JwtClaimTypes.Email, email),
|
||||||
|
new Claim(JwtClaimTypes.Name, "Legacy User")
|
||||||
|
} as IEnumerable<Claim>;
|
||||||
|
var config = new SsoConfigurationData();
|
||||||
|
|
||||||
|
var method = typeof(AccountController).GetMethod(
|
||||||
|
"CreateUserAndOrgUserConditionallyAsync",
|
||||||
|
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||||
|
Assert.NotNull(method);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var task = (Task<(User user, Organization organization, OrganizationUser orgUser)>)method!.Invoke(
|
||||||
|
sutProvider.Sut,
|
||||||
|
new object[]
|
||||||
|
{
|
||||||
|
orgId.ToString(),
|
||||||
|
providerUserId,
|
||||||
|
claims,
|
||||||
|
null!,
|
||||||
|
config
|
||||||
|
})!;
|
||||||
|
|
||||||
|
var result = await task;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await sutProvider.GetDependency<IRegisterUserCommand>().Received(1)
|
||||||
|
.RegisterUser(Arg.Is<User>(u => u.Email == email && u.Name == "Legacy User"));
|
||||||
|
|
||||||
|
// Verify the new method was NOT called
|
||||||
|
await sutProvider.GetDependency<IRegisterUserCommand>().DidNotReceive()
|
||||||
|
.RegisterSSOAutoProvisionedUserAsync(Arg.Any<User>(), Arg.Any<Organization>());
|
||||||
|
|
||||||
|
Assert.NotNull(result.user);
|
||||||
|
Assert.Equal(email, result.user.Email);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"globalSettings": {
|
||||||
|
"baseServiceUri": {
|
||||||
|
"vault": "https://localhost:8080",
|
||||||
|
"api": "http://localhost:4000",
|
||||||
|
"identity": "http://localhost:33656",
|
||||||
|
"admin": "http://localhost:62911",
|
||||||
|
"notifications": "http://localhost:61840",
|
||||||
|
"sso": "http://localhost:51822",
|
||||||
|
"internalNotifications": "http://localhost:61840",
|
||||||
|
"internalAdmin": "http://localhost:62911",
|
||||||
|
"internalIdentity": "http://localhost:33656",
|
||||||
|
"internalApi": "http://localhost:4000",
|
||||||
|
"internalVault": "https://localhost:8080",
|
||||||
|
"internalSso": "http://localhost:51822",
|
||||||
|
"internalScim": "http://localhost:44559"
|
||||||
|
},
|
||||||
|
"mail": {
|
||||||
|
"smtp": {
|
||||||
|
"host": "localhost",
|
||||||
|
"port": 10250
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"attachment": {
|
||||||
|
"connectionString": "UseDevelopmentStorage=true",
|
||||||
|
"baseUrl": "http://localhost:4000/attachments/"
|
||||||
|
},
|
||||||
|
"events": {
|
||||||
|
"connectionString": "UseDevelopmentStorage=true"
|
||||||
|
},
|
||||||
|
"storage": {
|
||||||
|
"connectionString": "UseDevelopmentStorage=true"
|
||||||
|
},
|
||||||
|
"pricingUri": "https://billingpricing.qa.bitwarden.pw"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,7 +57,6 @@ services:
|
|||||||
|
|
||||||
mysql:
|
mysql:
|
||||||
image: mysql:8.0
|
image: mysql:8.0
|
||||||
container_name: bw-mysql
|
|
||||||
ports:
|
ports:
|
||||||
- "3306:3306"
|
- "3306:3306"
|
||||||
command:
|
command:
|
||||||
@@ -88,7 +87,6 @@ services:
|
|||||||
|
|
||||||
idp:
|
idp:
|
||||||
image: kenchan0130/simplesamlphp:1.19.8
|
image: kenchan0130/simplesamlphp:1.19.8
|
||||||
container_name: idp
|
|
||||||
ports:
|
ports:
|
||||||
- "8090:8080"
|
- "8090:8080"
|
||||||
environment:
|
environment:
|
||||||
@@ -102,7 +100,6 @@ services:
|
|||||||
|
|
||||||
rabbitmq:
|
rabbitmq:
|
||||||
image: rabbitmq:4.1.3-management
|
image: rabbitmq:4.1.3-management
|
||||||
container_name: rabbitmq
|
|
||||||
ports:
|
ports:
|
||||||
- "5672:5672"
|
- "5672:5672"
|
||||||
- "15672:15672"
|
- "15672:15672"
|
||||||
@@ -116,7 +113,6 @@ services:
|
|||||||
|
|
||||||
reverse-proxy:
|
reverse-proxy:
|
||||||
image: nginx:alpine
|
image: nginx:alpine
|
||||||
container_name: reverse-proxy
|
|
||||||
volumes:
|
volumes:
|
||||||
- "./reverse-proxy.conf:/etc/nginx/conf.d/default.conf"
|
- "./reverse-proxy.conf:/etc/nginx/conf.d/default.conf"
|
||||||
ports:
|
ports:
|
||||||
@@ -126,7 +122,6 @@ services:
|
|||||||
- proxy
|
- proxy
|
||||||
|
|
||||||
service-bus:
|
service-bus:
|
||||||
container_name: service-bus
|
|
||||||
image: mcr.microsoft.com/azure-messaging/servicebus-emulator:latest
|
image: mcr.microsoft.com/azure-messaging/servicebus-emulator:latest
|
||||||
pull_policy: always
|
pull_policy: always
|
||||||
volumes:
|
volumes:
|
||||||
@@ -142,7 +137,6 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:alpine
|
image: redis:alpine
|
||||||
container_name: bw-redis
|
|
||||||
ports:
|
ports:
|
||||||
- "6379:6379"
|
- "6379:6379"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -18,11 +18,11 @@ if ($LASTEXITCODE -ne 0) {
|
|||||||
# Api internal & public
|
# Api internal & public
|
||||||
Set-Location "../../src/Api"
|
Set-Location "../../src/Api"
|
||||||
dotnet build
|
dotnet build
|
||||||
dotnet swagger tofile --output "../../api.json" --host "https://api.bitwarden.com" "./bin/Debug/net8.0/Api.dll" "internal"
|
dotnet swagger tofile --output "../../api.json" "./bin/Debug/net8.0/Api.dll" "internal"
|
||||||
if ($LASTEXITCODE -ne 0) {
|
if ($LASTEXITCODE -ne 0) {
|
||||||
exit $LASTEXITCODE
|
exit $LASTEXITCODE
|
||||||
}
|
}
|
||||||
dotnet swagger tofile --output "../../api.public.json" --host "https://api.bitwarden.com" "./bin/Debug/net8.0/Api.dll" "public"
|
dotnet swagger tofile --output "../../api.public.json" "./bin/Debug/net8.0/Api.dll" "public"
|
||||||
if ($LASTEXITCODE -ne 0) {
|
if ($LASTEXITCODE -ne 0) {
|
||||||
exit $LASTEXITCODE
|
exit $LASTEXITCODE
|
||||||
}
|
}
|
||||||
|
|||||||
132
dev/verify_migrations.ps1
Normal file
132
dev/verify_migrations.ps1
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
#!/usr/bin/env pwsh
|
||||||
|
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Validates that new database migration files follow naming conventions and chronological order.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
This script validates migration files in util/Migrator/DbScripts/ to ensure:
|
||||||
|
1. New migrations follow the naming format: YYYY-MM-DD_NN_Description.sql
|
||||||
|
2. New migrations are chronologically ordered (filename sorts after existing migrations)
|
||||||
|
3. Dates use leading zeros (e.g., 2025-01-05, not 2025-1-5)
|
||||||
|
4. A 2-digit sequence number is included (e.g., _00, _01)
|
||||||
|
|
||||||
|
.PARAMETER BaseRef
|
||||||
|
The base git reference to compare against (e.g., 'main', 'HEAD~1')
|
||||||
|
|
||||||
|
.PARAMETER CurrentRef
|
||||||
|
The current git reference (defaults to 'HEAD')
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
# For pull requests - compare against main branch
|
||||||
|
.\verify_migrations.ps1 -BaseRef main
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
# For pushes - compare against previous commit
|
||||||
|
.\verify_migrations.ps1 -BaseRef HEAD~1
|
||||||
|
#>
|
||||||
|
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$BaseRef,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[string]$CurrentRef = "HEAD"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Use invariant culture for consistent string comparison
|
||||||
|
[System.Threading.Thread]::CurrentThread.CurrentCulture = [System.Globalization.CultureInfo]::InvariantCulture
|
||||||
|
|
||||||
|
$migrationPath = "util/Migrator/DbScripts"
|
||||||
|
|
||||||
|
# Get list of migrations from base reference
|
||||||
|
try {
|
||||||
|
$baseMigrations = git ls-tree -r --name-only $BaseRef -- "$migrationPath/*.sql" 2>$null | Sort-Object
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host "Warning: Could not retrieve migrations from base reference '$BaseRef'"
|
||||||
|
$baseMigrations = @()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Host "Warning: Could not retrieve migrations from base reference '$BaseRef'"
|
||||||
|
$baseMigrations = @()
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get list of migrations from current reference
|
||||||
|
$currentMigrations = git ls-tree -r --name-only $CurrentRef -- "$migrationPath/*.sql" | Sort-Object
|
||||||
|
|
||||||
|
# Find added migrations
|
||||||
|
$addedMigrations = $currentMigrations | Where-Object { $_ -notin $baseMigrations }
|
||||||
|
|
||||||
|
if ($addedMigrations.Count -eq 0) {
|
||||||
|
Write-Host "No new migration files added."
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "New migration files detected:"
|
||||||
|
$addedMigrations | ForEach-Object { Write-Host " $_" }
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Get the last migration from base reference
|
||||||
|
if ($baseMigrations.Count -eq 0) {
|
||||||
|
Write-Host "No previous migrations found (initial commit?). Skipping validation."
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
$lastBaseMigration = Split-Path -Leaf ($baseMigrations | Select-Object -Last 1)
|
||||||
|
Write-Host "Last migration in base reference: $lastBaseMigration"
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Required format regex: YYYY-MM-DD_NN_Description.sql
|
||||||
|
$formatRegex = '^[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{2}_.+\.sql$'
|
||||||
|
|
||||||
|
$validationFailed = $false
|
||||||
|
|
||||||
|
foreach ($migration in $addedMigrations) {
|
||||||
|
$migrationName = Split-Path -Leaf $migration
|
||||||
|
|
||||||
|
# Validate NEW migration filename format
|
||||||
|
if ($migrationName -notmatch $formatRegex) {
|
||||||
|
Write-Host "ERROR: Migration '$migrationName' does not match required format"
|
||||||
|
Write-Host "Required format: YYYY-MM-DD_NN_Description.sql"
|
||||||
|
Write-Host " - YYYY: 4-digit year"
|
||||||
|
Write-Host " - MM: 2-digit month with leading zero (01-12)"
|
||||||
|
Write-Host " - DD: 2-digit day with leading zero (01-31)"
|
||||||
|
Write-Host " - NN: 2-digit sequence number (00, 01, 02, etc.)"
|
||||||
|
Write-Host "Example: 2025-01-15_00_MyMigration.sql"
|
||||||
|
$validationFailed = $true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
# Compare migration name with last base migration (using ordinal string comparison)
|
||||||
|
if ([string]::CompareOrdinal($migrationName, $lastBaseMigration) -lt 0) {
|
||||||
|
Write-Host "ERROR: New migration '$migrationName' is not chronologically after '$lastBaseMigration'"
|
||||||
|
$validationFailed = $true
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Host "OK: '$migrationName' is chronologically after '$lastBaseMigration'"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
if ($validationFailed) {
|
||||||
|
Write-Host "FAILED: One or more migrations are incorrectly named or not in chronological order"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "All new migration files must:"
|
||||||
|
Write-Host " 1. Follow the naming format: YYYY-MM-DD_NN_Description.sql"
|
||||||
|
Write-Host " 2. Use leading zeros in dates (e.g., 2025-01-05, not 2025-1-5)"
|
||||||
|
Write-Host " 3. Include a 2-digit sequence number (e.g., _00, _01)"
|
||||||
|
Write-Host " 4. Have a filename that sorts after the last migration in base"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "To fix this issue:"
|
||||||
|
Write-Host " 1. Locate your migration file(s) in util/Migrator/DbScripts/"
|
||||||
|
Write-Host " 2. Rename to follow format: YYYY-MM-DD_NN_Description.sql"
|
||||||
|
Write-Host " 3. Ensure the date is after $lastBaseMigration"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Example: 2025-01-15_00_AddNewFeature.sql"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "SUCCESS: All new migrations are correctly named and in chronological order"
|
||||||
|
exit 0
|
||||||
@@ -474,6 +474,7 @@ public class OrganizationsController : Controller
|
|||||||
organization.UseAdminSponsoredFamilies = model.UseAdminSponsoredFamilies;
|
organization.UseAdminSponsoredFamilies = model.UseAdminSponsoredFamilies;
|
||||||
organization.UseAutomaticUserConfirmation = model.UseAutomaticUserConfirmation;
|
organization.UseAutomaticUserConfirmation = model.UseAutomaticUserConfirmation;
|
||||||
organization.UseDisableSmAdsForUsers = model.UseDisableSmAdsForUsers;
|
organization.UseDisableSmAdsForUsers = model.UseDisableSmAdsForUsers;
|
||||||
|
organization.UsePhishingBlocker = model.UsePhishingBlocker;
|
||||||
|
|
||||||
//secrets
|
//secrets
|
||||||
organization.SmSeats = model.SmSeats;
|
organization.SmSeats = model.SmSeats;
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ public class OrganizationEditModel : OrganizationViewModel
|
|||||||
UseOrganizationDomains = org.UseOrganizationDomains;
|
UseOrganizationDomains = org.UseOrganizationDomains;
|
||||||
UseAutomaticUserConfirmation = org.UseAutomaticUserConfirmation;
|
UseAutomaticUserConfirmation = org.UseAutomaticUserConfirmation;
|
||||||
UseDisableSmAdsForUsers = org.UseDisableSmAdsForUsers;
|
UseDisableSmAdsForUsers = org.UseDisableSmAdsForUsers;
|
||||||
|
UsePhishingBlocker = org.UsePhishingBlocker;
|
||||||
|
|
||||||
_plans = plans;
|
_plans = plans;
|
||||||
}
|
}
|
||||||
@@ -161,6 +162,8 @@ public class OrganizationEditModel : OrganizationViewModel
|
|||||||
public new bool UseSecretsManager { get; set; }
|
public new bool UseSecretsManager { get; set; }
|
||||||
[Display(Name = "Risk Insights")]
|
[Display(Name = "Risk Insights")]
|
||||||
public new bool UseRiskInsights { get; set; }
|
public new bool UseRiskInsights { get; set; }
|
||||||
|
[Display(Name = "Phishing Blocker")]
|
||||||
|
public new bool UsePhishingBlocker { get; set; }
|
||||||
[Display(Name = "Admin Sponsored Families")]
|
[Display(Name = "Admin Sponsored Families")]
|
||||||
public bool UseAdminSponsoredFamilies { get; set; }
|
public bool UseAdminSponsoredFamilies { get; set; }
|
||||||
[Display(Name = "Self Host")]
|
[Display(Name = "Self Host")]
|
||||||
@@ -331,6 +334,7 @@ public class OrganizationEditModel : OrganizationViewModel
|
|||||||
existingOrganization.MaxAutoscaleSmServiceAccounts = MaxAutoscaleSmServiceAccounts;
|
existingOrganization.MaxAutoscaleSmServiceAccounts = MaxAutoscaleSmServiceAccounts;
|
||||||
existingOrganization.UseOrganizationDomains = UseOrganizationDomains;
|
existingOrganization.UseOrganizationDomains = UseOrganizationDomains;
|
||||||
existingOrganization.UseDisableSmAdsForUsers = UseDisableSmAdsForUsers;
|
existingOrganization.UseDisableSmAdsForUsers = UseDisableSmAdsForUsers;
|
||||||
|
existingOrganization.UsePhishingBlocker = UsePhishingBlocker;
|
||||||
return existingOrganization;
|
return existingOrganization;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ public class OrganizationViewModel
|
|||||||
public int OccupiedSmSeatsCount { get; set; }
|
public int OccupiedSmSeatsCount { get; set; }
|
||||||
public bool UseSecretsManager => Organization.UseSecretsManager;
|
public bool UseSecretsManager => Organization.UseSecretsManager;
|
||||||
public bool UseRiskInsights => Organization.UseRiskInsights;
|
public bool UseRiskInsights => Organization.UseRiskInsights;
|
||||||
|
public bool UsePhishingBlocker => Organization.UsePhishingBlocker;
|
||||||
public IEnumerable<OrganizationUserUserDetails> OwnersDetails { get; set; }
|
public IEnumerable<OrganizationUserUserDetails> OwnersDetails { get; set; }
|
||||||
public IEnumerable<OrganizationUserUserDetails> AdminsDetails { get; set; }
|
public IEnumerable<OrganizationUserUserDetails> AdminsDetails { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,6 +156,10 @@
|
|||||||
<input type="checkbox" class="form-check-input" asp-for="UseAdminSponsoredFamilies" disabled='@(canEditPlan ? null : "disabled")'>
|
<input type="checkbox" class="form-check-input" asp-for="UseAdminSponsoredFamilies" disabled='@(canEditPlan ? null : "disabled")'>
|
||||||
<label class="form-check-label" asp-for="UseAdminSponsoredFamilies"></label>
|
<label class="form-check-label" asp-for="UseAdminSponsoredFamilies"></label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="checkbox" class="form-check-input" asp-for="UsePhishingBlocker" disabled='@(canEditPlan ? null : "disabled")'>
|
||||||
|
<label class="form-check-label" asp-for="UsePhishingBlocker"></label>
|
||||||
|
</div>
|
||||||
@if(FeatureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers))
|
@if(FeatureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers))
|
||||||
{
|
{
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ public class DatabaseMigrationHostedService : IHostedService, IDisposable
|
|||||||
public virtual async Task StartAsync(CancellationToken cancellationToken)
|
public virtual async Task StartAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
// Wait 20 seconds to allow database to come online
|
// Wait 20 seconds to allow database to come online
|
||||||
await Task.Delay(20000);
|
await Task.Delay(20000, cancellationToken);
|
||||||
|
|
||||||
var maxMigrationAttempts = 10;
|
var maxMigrationAttempts = 10;
|
||||||
for (var i = 1; i <= maxMigrationAttempts; i++)
|
for (var i = 1; i <= maxMigrationAttempts; i++)
|
||||||
@@ -41,7 +41,7 @@ public class DatabaseMigrationHostedService : IHostedService, IDisposable
|
|||||||
{
|
{
|
||||||
_logger.LogError(e,
|
_logger.LogError(e,
|
||||||
"Database unavailable for migration. Trying again (attempt #{0})...", i + 1);
|
"Database unavailable for migration. Trying again (attempt #{0})...", i + 1);
|
||||||
await Task.Delay(20000);
|
await Task.Delay(20000, cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,19 +16,8 @@ public class Program
|
|||||||
o.Limits.MaxRequestLineSize = 20_000;
|
o.Limits.MaxRequestLineSize = 20_000;
|
||||||
});
|
});
|
||||||
webBuilder.UseStartup<Startup>();
|
webBuilder.UseStartup<Startup>();
|
||||||
webBuilder.ConfigureLogging((hostingContext, logging) =>
|
|
||||||
logging.AddSerilog(hostingContext, (e, globalSettings) =>
|
|
||||||
{
|
|
||||||
var context = e.Properties["SourceContext"].ToString();
|
|
||||||
if (e.Properties.TryGetValue("RequestPath", out var requestPath) &&
|
|
||||||
!string.IsNullOrWhiteSpace(requestPath?.ToString()) &&
|
|
||||||
(context.Contains(".Server.Kestrel") || context.Contains(".Core.IISHttpServer")))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return e.Level >= globalSettings.MinLogLevel.AdminSettings.Default;
|
|
||||||
}));
|
|
||||||
})
|
})
|
||||||
|
.AddSerilogFileLogging()
|
||||||
.Build()
|
.Build()
|
||||||
.Run();
|
.Run();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,11 +132,8 @@ public class Startup
|
|||||||
public void Configure(
|
public void Configure(
|
||||||
IApplicationBuilder app,
|
IApplicationBuilder app,
|
||||||
IWebHostEnvironment env,
|
IWebHostEnvironment env,
|
||||||
IHostApplicationLifetime appLifetime,
|
|
||||||
GlobalSettings globalSettings)
|
GlobalSettings globalSettings)
|
||||||
{
|
{
|
||||||
app.UseSerilog(env, appLifetime, globalSettings);
|
|
||||||
|
|
||||||
// Add general security headers
|
// Add general security headers
|
||||||
app.UseMiddleware<SecurityHeadersMiddleware>();
|
app.UseMiddleware<SecurityHeadersMiddleware>();
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
},
|
},
|
||||||
"storage": {
|
"storage": {
|
||||||
"connectionString": "UseDevelopmentStorage=true"
|
"connectionString": "UseDevelopmentStorage=true"
|
||||||
}
|
},
|
||||||
|
"pricingUri": "https://billingpricing.qa.bitwarden.pw"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using Bit.Core.AdminConsole.Utilities.v2;
|
||||||
|
using Bit.Core.AdminConsole.Utilities.v2.Results;
|
||||||
|
using Bit.Core.Models.Api;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Bit.Api.AdminConsole.Controllers;
|
||||||
|
|
||||||
|
public abstract class BaseAdminConsoleController : Controller
|
||||||
|
{
|
||||||
|
protected static IResult Handle(CommandResult commandResult) =>
|
||||||
|
commandResult.Match<IResult>(
|
||||||
|
error => error switch
|
||||||
|
{
|
||||||
|
BadRequestError badRequest => TypedResults.BadRequest(new ErrorResponseModel(badRequest.Message)),
|
||||||
|
NotFoundError notFound => TypedResults.NotFound(new ErrorResponseModel(notFound.Message)),
|
||||||
|
InternalError internalError => TypedResults.Json(
|
||||||
|
new ErrorResponseModel(internalError.Message),
|
||||||
|
statusCode: StatusCodes.Status500InternalServerError),
|
||||||
|
_ => TypedResults.Json(
|
||||||
|
new ErrorResponseModel(error.Message),
|
||||||
|
statusCode: StatusCodes.Status500InternalServerError
|
||||||
|
)
|
||||||
|
},
|
||||||
|
_ => TypedResults.NoContent()
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,16 +1,13 @@
|
|||||||
using Bit.Api.AdminConsole.Models.Request.Organizations;
|
using Bit.Api.AdminConsole.Models.Request.Organizations;
|
||||||
using Bit.Api.AdminConsole.Models.Response.Organizations;
|
using Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||||
using Bit.Core;
|
|
||||||
using Bit.Core.Context;
|
using Bit.Core.Context;
|
||||||
using Bit.Core.Exceptions;
|
using Bit.Core.Exceptions;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
using Bit.Core.Utilities;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace Bit.Api.AdminConsole.Controllers;
|
namespace Bit.Api.AdminConsole.Controllers;
|
||||||
|
|
||||||
[RequireFeature(FeatureFlagKeys.EventBasedOrganizationIntegrations)]
|
|
||||||
[Route("organizations/{organizationId:guid}/integrations/{integrationId:guid}/configurations")]
|
[Route("organizations/{organizationId:guid}/integrations/{integrationId:guid}/configurations")]
|
||||||
[Authorize("Application")]
|
[Authorize("Application")]
|
||||||
public class OrganizationIntegrationConfigurationController(
|
public class OrganizationIntegrationConfigurationController(
|
||||||
|
|||||||
@@ -1,18 +1,13 @@
|
|||||||
using Bit.Api.AdminConsole.Models.Request.Organizations;
|
using Bit.Api.AdminConsole.Models.Request.Organizations;
|
||||||
using Bit.Api.AdminConsole.Models.Response.Organizations;
|
using Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||||
using Bit.Core;
|
|
||||||
using Bit.Core.Context;
|
using Bit.Core.Context;
|
||||||
using Bit.Core.Exceptions;
|
using Bit.Core.Exceptions;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
using Bit.Core.Utilities;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
#nullable enable
|
|
||||||
|
|
||||||
namespace Bit.Api.AdminConsole.Controllers;
|
namespace Bit.Api.AdminConsole.Controllers;
|
||||||
|
|
||||||
[RequireFeature(FeatureFlagKeys.EventBasedOrganizationIntegrations)]
|
|
||||||
[Route("organizations/{organizationId:guid}/integrations")]
|
[Route("organizations/{organizationId:guid}/integrations")]
|
||||||
[Authorize("Application")]
|
[Authorize("Application")]
|
||||||
public class OrganizationIntegrationController(
|
public class OrganizationIntegrationController(
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ using Bit.Api.Models.Response;
|
|||||||
using Bit.Api.Vault.AuthorizationHandlers.Collections;
|
using Bit.Api.Vault.AuthorizationHandlers.Collections;
|
||||||
using Bit.Core;
|
using Bit.Core;
|
||||||
using Bit.Core.AdminConsole.Enums;
|
using Bit.Core.AdminConsole.Enums;
|
||||||
|
using Bit.Core.AdminConsole.Models.Data;
|
||||||
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
|
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.AccountRecovery;
|
using Bit.Core.AdminConsole.OrganizationFeatures.AccountRecovery;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount;
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
|
||||||
@@ -20,6 +22,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v
|
|||||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
|
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
|
||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
|
using Bit.Core.AdminConsole.Utilities.v2;
|
||||||
using Bit.Core.Auth.Enums;
|
using Bit.Core.Auth.Enums;
|
||||||
using Bit.Core.Auth.Repositories;
|
using Bit.Core.Auth.Repositories;
|
||||||
using Bit.Core.Billing.Pricing;
|
using Bit.Core.Billing.Pricing;
|
||||||
@@ -43,7 +46,7 @@ namespace Bit.Api.AdminConsole.Controllers;
|
|||||||
|
|
||||||
[Route("organizations/{orgId}/users")]
|
[Route("organizations/{orgId}/users")]
|
||||||
[Authorize("Application")]
|
[Authorize("Application")]
|
||||||
public class OrganizationUsersController : Controller
|
public class OrganizationUsersController : BaseAdminConsoleController
|
||||||
{
|
{
|
||||||
private readonly IOrganizationRepository _organizationRepository;
|
private readonly IOrganizationRepository _organizationRepository;
|
||||||
private readonly IOrganizationUserRepository _organizationUserRepository;
|
private readonly IOrganizationUserRepository _organizationUserRepository;
|
||||||
@@ -68,6 +71,7 @@ public class OrganizationUsersController : Controller
|
|||||||
private readonly IFeatureService _featureService;
|
private readonly IFeatureService _featureService;
|
||||||
private readonly IPricingClient _pricingClient;
|
private readonly IPricingClient _pricingClient;
|
||||||
private readonly IResendOrganizationInviteCommand _resendOrganizationInviteCommand;
|
private readonly IResendOrganizationInviteCommand _resendOrganizationInviteCommand;
|
||||||
|
private readonly IAutomaticallyConfirmOrganizationUserCommand _automaticallyConfirmOrganizationUserCommand;
|
||||||
private readonly IConfirmOrganizationUserCommand _confirmOrganizationUserCommand;
|
private readonly IConfirmOrganizationUserCommand _confirmOrganizationUserCommand;
|
||||||
private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand;
|
private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand;
|
||||||
private readonly IInitPendingOrganizationCommand _initPendingOrganizationCommand;
|
private readonly IInitPendingOrganizationCommand _initPendingOrganizationCommand;
|
||||||
@@ -101,7 +105,8 @@ public class OrganizationUsersController : Controller
|
|||||||
IInitPendingOrganizationCommand initPendingOrganizationCommand,
|
IInitPendingOrganizationCommand initPendingOrganizationCommand,
|
||||||
IRevokeOrganizationUserCommand revokeOrganizationUserCommand,
|
IRevokeOrganizationUserCommand revokeOrganizationUserCommand,
|
||||||
IResendOrganizationInviteCommand resendOrganizationInviteCommand,
|
IResendOrganizationInviteCommand resendOrganizationInviteCommand,
|
||||||
IAdminRecoverAccountCommand adminRecoverAccountCommand)
|
IAdminRecoverAccountCommand adminRecoverAccountCommand,
|
||||||
|
IAutomaticallyConfirmOrganizationUserCommand automaticallyConfirmOrganizationUserCommand)
|
||||||
{
|
{
|
||||||
_organizationRepository = organizationRepository;
|
_organizationRepository = organizationRepository;
|
||||||
_organizationUserRepository = organizationUserRepository;
|
_organizationUserRepository = organizationUserRepository;
|
||||||
@@ -126,6 +131,7 @@ public class OrganizationUsersController : Controller
|
|||||||
_featureService = featureService;
|
_featureService = featureService;
|
||||||
_pricingClient = pricingClient;
|
_pricingClient = pricingClient;
|
||||||
_resendOrganizationInviteCommand = resendOrganizationInviteCommand;
|
_resendOrganizationInviteCommand = resendOrganizationInviteCommand;
|
||||||
|
_automaticallyConfirmOrganizationUserCommand = automaticallyConfirmOrganizationUserCommand;
|
||||||
_confirmOrganizationUserCommand = confirmOrganizationUserCommand;
|
_confirmOrganizationUserCommand = confirmOrganizationUserCommand;
|
||||||
_restoreOrganizationUserCommand = restoreOrganizationUserCommand;
|
_restoreOrganizationUserCommand = restoreOrganizationUserCommand;
|
||||||
_initPendingOrganizationCommand = initPendingOrganizationCommand;
|
_initPendingOrganizationCommand = initPendingOrganizationCommand;
|
||||||
@@ -477,43 +483,10 @@ public class OrganizationUsersController : Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
[HttpPut("{id}/reset-password")]
|
[HttpPut("{id}/reset-password")]
|
||||||
[Authorize<ManageAccountRecoveryRequirement>]
|
[Authorize<ManageAccountRecoveryRequirement>]
|
||||||
public async Task<IResult> PutResetPassword(Guid orgId, Guid id, [FromBody] OrganizationUserResetPasswordRequestModel model)
|
public async Task<IResult> PutResetPassword(Guid orgId, Guid id, [FromBody] OrganizationUserResetPasswordRequestModel model)
|
||||||
{
|
|
||||||
if (_featureService.IsEnabled(FeatureFlagKeys.AccountRecoveryCommand))
|
|
||||||
{
|
|
||||||
// TODO: remove legacy implementation after feature flag is enabled.
|
|
||||||
return await PutResetPasswordNew(orgId, id, model);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the users role, since provider users aren't a member of the organization we use the owner check
|
|
||||||
var orgUserType = await _currentContext.OrganizationOwner(orgId)
|
|
||||||
? OrganizationUserType.Owner
|
|
||||||
: _currentContext.Organizations?.FirstOrDefault(o => o.Id == orgId)?.Type;
|
|
||||||
if (orgUserType == null)
|
|
||||||
{
|
|
||||||
return TypedResults.NotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
var result = await _userService.AdminResetPasswordAsync(orgUserType.Value, orgId, id, model.NewMasterPasswordHash, model.Key);
|
|
||||||
if (result.Succeeded)
|
|
||||||
{
|
|
||||||
return TypedResults.Ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var error in result.Errors)
|
|
||||||
{
|
|
||||||
ModelState.AddModelError(string.Empty, error.Description);
|
|
||||||
}
|
|
||||||
|
|
||||||
await Task.Delay(2000);
|
|
||||||
return TypedResults.BadRequest(ModelState);
|
|
||||||
}
|
|
||||||
|
|
||||||
#nullable enable
|
|
||||||
// TODO: make sure the route and authorize attributes are maintained when the legacy implementation is removed.
|
|
||||||
private async Task<IResult> PutResetPasswordNew(Guid orgId, Guid id, [FromBody] OrganizationUserResetPasswordRequestModel model)
|
|
||||||
{
|
{
|
||||||
var targetOrganizationUser = await _organizationUserRepository.GetByIdAsync(id);
|
var targetOrganizationUser = await _organizationUserRepository.GetByIdAsync(id);
|
||||||
if (targetOrganizationUser == null || targetOrganizationUser.OrganizationId != orgId)
|
if (targetOrganizationUser == null || targetOrganizationUser.OrganizationId != orgId)
|
||||||
@@ -738,6 +711,31 @@ public class OrganizationUsersController : Controller
|
|||||||
await BulkEnableSecretsManagerAsync(orgId, model);
|
await BulkEnableSecretsManagerAsync(orgId, model);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id}/auto-confirm")]
|
||||||
|
[Authorize<ManageUsersRequirement>]
|
||||||
|
[RequireFeature(FeatureFlagKeys.AutomaticConfirmUsers)]
|
||||||
|
public async Task<IResult> AutomaticallyConfirmOrganizationUserAsync([FromRoute] Guid orgId,
|
||||||
|
[FromRoute] Guid id,
|
||||||
|
[FromBody] OrganizationUserConfirmRequestModel model)
|
||||||
|
{
|
||||||
|
var userId = _userService.GetProperUserId(User);
|
||||||
|
|
||||||
|
if (userId is null || userId.Value == Guid.Empty)
|
||||||
|
{
|
||||||
|
return TypedResults.Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Handle(await _automaticallyConfirmOrganizationUserCommand.AutomaticallyConfirmOrganizationUserAsync(
|
||||||
|
new AutomaticallyConfirmOrganizationUserRequest
|
||||||
|
{
|
||||||
|
OrganizationId = orgId,
|
||||||
|
OrganizationUserId = id,
|
||||||
|
Key = model.Key,
|
||||||
|
DefaultUserCollectionName = model.DefaultUserCollectionName,
|
||||||
|
PerformedBy = new StandardUser(userId.Value, await _currentContext.OrganizationOwner(orgId)),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
private async Task RestoreOrRevokeUserAsync(
|
private async Task RestoreOrRevokeUserAsync(
|
||||||
Guid orgId,
|
Guid orgId,
|
||||||
Guid id,
|
Guid id,
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ using Bit.Api.Models.Request.Accounts;
|
|||||||
using Bit.Api.Models.Request.Organizations;
|
using Bit.Api.Models.Request.Organizations;
|
||||||
using Bit.Api.Models.Response;
|
using Bit.Api.Models.Response;
|
||||||
using Bit.Core;
|
using Bit.Core;
|
||||||
using Bit.Core.AdminConsole.Entities;
|
|
||||||
using Bit.Core.AdminConsole.Enums;
|
using Bit.Core.AdminConsole.Enums;
|
||||||
using Bit.Core.AdminConsole.Models.Business.Tokenables;
|
using Bit.Core.AdminConsole.Models.Business.Tokenables;
|
||||||
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
|
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
|
||||||
@@ -70,6 +69,7 @@ public class OrganizationsController : Controller
|
|||||||
private readonly IPolicyRequirementQuery _policyRequirementQuery;
|
private readonly IPolicyRequirementQuery _policyRequirementQuery;
|
||||||
private readonly IPricingClient _pricingClient;
|
private readonly IPricingClient _pricingClient;
|
||||||
private readonly IOrganizationUpdateKeysCommand _organizationUpdateKeysCommand;
|
private readonly IOrganizationUpdateKeysCommand _organizationUpdateKeysCommand;
|
||||||
|
private readonly IOrganizationUpdateCommand _organizationUpdateCommand;
|
||||||
|
|
||||||
public OrganizationsController(
|
public OrganizationsController(
|
||||||
IOrganizationRepository organizationRepository,
|
IOrganizationRepository organizationRepository,
|
||||||
@@ -94,7 +94,8 @@ public class OrganizationsController : Controller
|
|||||||
IOrganizationDeleteCommand organizationDeleteCommand,
|
IOrganizationDeleteCommand organizationDeleteCommand,
|
||||||
IPolicyRequirementQuery policyRequirementQuery,
|
IPolicyRequirementQuery policyRequirementQuery,
|
||||||
IPricingClient pricingClient,
|
IPricingClient pricingClient,
|
||||||
IOrganizationUpdateKeysCommand organizationUpdateKeysCommand)
|
IOrganizationUpdateKeysCommand organizationUpdateKeysCommand,
|
||||||
|
IOrganizationUpdateCommand organizationUpdateCommand)
|
||||||
{
|
{
|
||||||
_organizationRepository = organizationRepository;
|
_organizationRepository = organizationRepository;
|
||||||
_organizationUserRepository = organizationUserRepository;
|
_organizationUserRepository = organizationUserRepository;
|
||||||
@@ -119,6 +120,7 @@ public class OrganizationsController : Controller
|
|||||||
_policyRequirementQuery = policyRequirementQuery;
|
_policyRequirementQuery = policyRequirementQuery;
|
||||||
_pricingClient = pricingClient;
|
_pricingClient = pricingClient;
|
||||||
_organizationUpdateKeysCommand = organizationUpdateKeysCommand;
|
_organizationUpdateKeysCommand = organizationUpdateKeysCommand;
|
||||||
|
_organizationUpdateCommand = organizationUpdateCommand;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id}")]
|
[HttpGet("{id}")]
|
||||||
@@ -224,36 +226,31 @@ public class OrganizationsController : Controller
|
|||||||
return new OrganizationResponseModel(result.Organization, plan);
|
return new OrganizationResponseModel(result.Organization, plan);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id}")]
|
[HttpPut("{organizationId:guid}")]
|
||||||
public async Task<OrganizationResponseModel> Put(string id, [FromBody] OrganizationUpdateRequestModel model)
|
public async Task<IResult> Put(Guid organizationId, [FromBody] OrganizationUpdateRequestModel model)
|
||||||
{
|
{
|
||||||
var orgIdGuid = new Guid(id);
|
// If billing email is being changed, require subscription editing permissions.
|
||||||
|
// Otherwise, organization owner permissions are sufficient.
|
||||||
|
var requiresBillingPermission = model.BillingEmail is not null;
|
||||||
|
var authorized = requiresBillingPermission
|
||||||
|
? await _currentContext.EditSubscription(organizationId)
|
||||||
|
: await _currentContext.OrganizationOwner(organizationId);
|
||||||
|
|
||||||
var organization = await _organizationRepository.GetByIdAsync(orgIdGuid);
|
if (!authorized)
|
||||||
if (organization == null)
|
|
||||||
{
|
{
|
||||||
throw new NotFoundException();
|
return TypedResults.Unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
var updateBilling = ShouldUpdateBilling(model, organization);
|
var commandRequest = model.ToCommandRequest(organizationId);
|
||||||
|
var updatedOrganization = await _organizationUpdateCommand.UpdateAsync(commandRequest);
|
||||||
|
|
||||||
var hasRequiredPermissions = updateBilling
|
var plan = await _pricingClient.GetPlan(updatedOrganization.PlanType);
|
||||||
? await _currentContext.EditSubscription(orgIdGuid)
|
return TypedResults.Ok(new OrganizationResponseModel(updatedOrganization, plan));
|
||||||
: await _currentContext.OrganizationOwner(orgIdGuid);
|
|
||||||
|
|
||||||
if (!hasRequiredPermissions)
|
|
||||||
{
|
|
||||||
throw new NotFoundException();
|
|
||||||
}
|
|
||||||
|
|
||||||
await _organizationService.UpdateAsync(model.ToOrganization(organization, _globalSettings), updateBilling);
|
|
||||||
var plan = await _pricingClient.GetPlan(organization.PlanType);
|
|
||||||
return new OrganizationResponseModel(organization, plan);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{id}")]
|
[HttpPost("{id}")]
|
||||||
[Obsolete("This endpoint is deprecated. Use PUT method instead")]
|
[Obsolete("This endpoint is deprecated. Use PUT method instead")]
|
||||||
public async Task<OrganizationResponseModel> PostPut(string id, [FromBody] OrganizationUpdateRequestModel model)
|
public async Task<IResult> PostPut(Guid id, [FromBody] OrganizationUpdateRequestModel model)
|
||||||
{
|
{
|
||||||
return await Put(id, model);
|
return await Put(id, model);
|
||||||
}
|
}
|
||||||
@@ -588,11 +585,4 @@ public class OrganizationsController : Controller
|
|||||||
|
|
||||||
return organization.PlanType;
|
return organization.PlanType;
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool ShouldUpdateBilling(OrganizationUpdateRequestModel model, Organization organization)
|
|
||||||
{
|
|
||||||
var organizationNameChanged = model.Name != organization.Name;
|
|
||||||
var billingEmailChanged = model.BillingEmail != organization.BillingEmail;
|
|
||||||
return !_globalSettings.SelfHosted && (organizationNameChanged || billingEmailChanged);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -209,23 +209,17 @@ public class PoliciesController : Controller
|
|||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type != model.Type)
|
var policyUpdate = await model.ToPolicyUpdateAsync(orgId, type, _currentContext);
|
||||||
{
|
|
||||||
throw new BadRequestException("Mismatched policy type");
|
|
||||||
}
|
|
||||||
|
|
||||||
var policyUpdate = await model.ToPolicyUpdateAsync(orgId, _currentContext);
|
|
||||||
var policy = await _savePolicyCommand.SaveAsync(policyUpdate);
|
var policy = await _savePolicyCommand.SaveAsync(policyUpdate);
|
||||||
return new PolicyResponseModel(policy);
|
return new PolicyResponseModel(policy);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
[HttpPut("{type}/vnext")]
|
[HttpPut("{type}/vnext")]
|
||||||
[RequireFeatureAttribute(FeatureFlagKeys.CreateDefaultLocation)]
|
[RequireFeatureAttribute(FeatureFlagKeys.CreateDefaultLocation)]
|
||||||
[Authorize<ManagePoliciesRequirement>]
|
[Authorize<ManagePoliciesRequirement>]
|
||||||
public async Task<PolicyResponseModel> PutVNext(Guid orgId, [FromBody] SavePolicyRequest model)
|
public async Task<PolicyResponseModel> PutVNext(Guid orgId, PolicyType type, [FromBody] SavePolicyRequest model)
|
||||||
{
|
{
|
||||||
var savePolicyRequest = await model.ToSavePolicyModelAsync(orgId, _currentContext);
|
var savePolicyRequest = await model.ToSavePolicyModelAsync(orgId, type, _currentContext);
|
||||||
|
|
||||||
var policy = _featureService.IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor) ?
|
var policy = _featureService.IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor) ?
|
||||||
await _vNextSavePolicyCommand.SaveAsync(savePolicyRequest) :
|
await _vNextSavePolicyCommand.SaveAsync(savePolicyRequest) :
|
||||||
@@ -233,5 +227,4 @@ public class PoliciesController : Controller
|
|||||||
|
|
||||||
return new PolicyResponseModel(policy);
|
return new PolicyResponseModel(policy);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Bit.Api.AdminConsole.Models.Response.Organizations;
|
using Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||||
using Bit.Core;
|
|
||||||
using Bit.Core.AdminConsole.Entities;
|
using Bit.Core.AdminConsole.Entities;
|
||||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||||
using Bit.Core.Context;
|
using Bit.Core.Context;
|
||||||
@@ -8,13 +7,11 @@ using Bit.Core.Enums;
|
|||||||
using Bit.Core.Exceptions;
|
using Bit.Core.Exceptions;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
using Bit.Core.Services;
|
using Bit.Core.Services;
|
||||||
using Bit.Core.Utilities;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace Bit.Api.AdminConsole.Controllers;
|
namespace Bit.Api.AdminConsole.Controllers;
|
||||||
|
|
||||||
[RequireFeature(FeatureFlagKeys.EventBasedOrganizationIntegrations)]
|
|
||||||
[Route("organizations")]
|
[Route("organizations")]
|
||||||
[Authorize("Application")]
|
[Authorize("Application")]
|
||||||
public class SlackIntegrationController(
|
public class SlackIntegrationController(
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Bit.Api.AdminConsole.Models.Response.Organizations;
|
using Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||||
using Bit.Core;
|
|
||||||
using Bit.Core.AdminConsole.Entities;
|
using Bit.Core.AdminConsole.Entities;
|
||||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||||
using Bit.Core.Context;
|
using Bit.Core.Context;
|
||||||
@@ -8,7 +7,6 @@ using Bit.Core.Enums;
|
|||||||
using Bit.Core.Exceptions;
|
using Bit.Core.Exceptions;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
using Bit.Core.Services;
|
using Bit.Core.Services;
|
||||||
using Bit.Core.Utilities;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Bot.Builder;
|
using Microsoft.Bot.Builder;
|
||||||
@@ -16,7 +14,6 @@ using Microsoft.Bot.Builder.Integration.AspNet.Core;
|
|||||||
|
|
||||||
namespace Bit.Api.AdminConsole.Controllers;
|
namespace Bit.Api.AdminConsole.Controllers;
|
||||||
|
|
||||||
[RequireFeature(FeatureFlagKeys.EventBasedOrganizationIntegrations)]
|
|
||||||
[Route("organizations")]
|
[Route("organizations")]
|
||||||
[Authorize("Application")]
|
[Authorize("Application")]
|
||||||
public class TeamsIntegrationController(
|
public class TeamsIntegrationController(
|
||||||
|
|||||||
@@ -1,41 +1,28 @@
|
|||||||
// FIXME: Update this file to be null safe and then delete the line below
|
using System.ComponentModel.DataAnnotations;
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using Bit.Core.AdminConsole.Entities;
|
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Update;
|
||||||
using Bit.Core.Models.Data;
|
|
||||||
using Bit.Core.Settings;
|
|
||||||
using Bit.Core.Utilities;
|
using Bit.Core.Utilities;
|
||||||
|
|
||||||
namespace Bit.Api.AdminConsole.Models.Request.Organizations;
|
namespace Bit.Api.AdminConsole.Models.Request.Organizations;
|
||||||
|
|
||||||
public class OrganizationUpdateRequestModel
|
public class OrganizationUpdateRequestModel
|
||||||
{
|
{
|
||||||
[Required]
|
|
||||||
[StringLength(50, ErrorMessage = "The field Name exceeds the maximum length.")]
|
[StringLength(50, ErrorMessage = "The field Name exceeds the maximum length.")]
|
||||||
[JsonConverter(typeof(HtmlEncodingStringConverter))]
|
[JsonConverter(typeof(HtmlEncodingStringConverter))]
|
||||||
public string Name { get; set; }
|
public string? Name { get; set; }
|
||||||
[StringLength(50, ErrorMessage = "The field Business Name exceeds the maximum length.")]
|
|
||||||
[JsonConverter(typeof(HtmlEncodingStringConverter))]
|
|
||||||
public string BusinessName { get; set; }
|
|
||||||
[EmailAddress]
|
|
||||||
[Required]
|
|
||||||
[StringLength(256)]
|
|
||||||
public string BillingEmail { get; set; }
|
|
||||||
public Permissions Permissions { get; set; }
|
|
||||||
public OrganizationKeysRequestModel Keys { get; set; }
|
|
||||||
|
|
||||||
public virtual Organization ToOrganization(Organization existingOrganization, GlobalSettings globalSettings)
|
[EmailAddress]
|
||||||
|
[StringLength(256)]
|
||||||
|
public string? BillingEmail { get; set; }
|
||||||
|
|
||||||
|
public OrganizationKeysRequestModel? Keys { get; set; }
|
||||||
|
|
||||||
|
public OrganizationUpdateRequest ToCommandRequest(Guid organizationId) => new()
|
||||||
{
|
{
|
||||||
if (!globalSettings.SelfHosted)
|
OrganizationId = organizationId,
|
||||||
{
|
Name = Name,
|
||||||
// These items come from the license file
|
BillingEmail = BillingEmail,
|
||||||
existingOrganization.Name = Name;
|
PublicKey = Keys?.PublicKey,
|
||||||
existingOrganization.BusinessName = BusinessName;
|
EncryptedPrivateKey = Keys?.EncryptedPrivateKey
|
||||||
existingOrganization.BillingEmail = BillingEmail?.ToLowerInvariant()?.Trim();
|
};
|
||||||
}
|
|
||||||
Keys?.ToOrganization(existingOrganization);
|
|
||||||
return existingOrganization;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,20 +9,18 @@ namespace Bit.Api.AdminConsole.Models.Request;
|
|||||||
|
|
||||||
public class PolicyRequestModel
|
public class PolicyRequestModel
|
||||||
{
|
{
|
||||||
[Required]
|
|
||||||
public PolicyType? Type { get; set; }
|
|
||||||
[Required]
|
[Required]
|
||||||
public bool? Enabled { get; set; }
|
public bool? Enabled { get; set; }
|
||||||
public Dictionary<string, object>? Data { get; set; }
|
public Dictionary<string, object>? Data { get; set; }
|
||||||
|
|
||||||
public async Task<PolicyUpdate> ToPolicyUpdateAsync(Guid organizationId, ICurrentContext currentContext)
|
public async Task<PolicyUpdate> ToPolicyUpdateAsync(Guid organizationId, PolicyType type, ICurrentContext currentContext)
|
||||||
{
|
{
|
||||||
var serializedData = PolicyDataValidator.ValidateAndSerialize(Data, Type!.Value);
|
var serializedData = PolicyDataValidator.ValidateAndSerialize(Data, type);
|
||||||
var performedBy = new StandardUser(currentContext.UserId!.Value, await currentContext.OrganizationOwner(organizationId));
|
var performedBy = new StandardUser(currentContext.UserId!.Value, await currentContext.OrganizationOwner(organizationId));
|
||||||
|
|
||||||
return new()
|
return new()
|
||||||
{
|
{
|
||||||
Type = Type!.Value,
|
Type = type,
|
||||||
OrganizationId = organizationId,
|
OrganizationId = organizationId,
|
||||||
Data = serializedData,
|
Data = serializedData,
|
||||||
Enabled = Enabled.GetValueOrDefault(),
|
Enabled = Enabled.GetValueOrDefault(),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using Bit.Core.AdminConsole.Enums;
|
||||||
using Bit.Core.AdminConsole.Models.Data;
|
using Bit.Core.AdminConsole.Models.Data;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
|
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
|
||||||
using Bit.Core.AdminConsole.Utilities;
|
using Bit.Core.AdminConsole.Utilities;
|
||||||
@@ -13,10 +14,10 @@ public class SavePolicyRequest
|
|||||||
|
|
||||||
public Dictionary<string, object>? Metadata { get; set; }
|
public Dictionary<string, object>? Metadata { get; set; }
|
||||||
|
|
||||||
public async Task<SavePolicyModel> ToSavePolicyModelAsync(Guid organizationId, ICurrentContext currentContext)
|
public async Task<SavePolicyModel> ToSavePolicyModelAsync(Guid organizationId, PolicyType type, ICurrentContext currentContext)
|
||||||
{
|
{
|
||||||
var policyUpdate = await Policy.ToPolicyUpdateAsync(organizationId, currentContext);
|
var policyUpdate = await Policy.ToPolicyUpdateAsync(organizationId, type, currentContext);
|
||||||
var metadata = PolicyDataValidator.ValidateAndDeserializeMetadata(Metadata, Policy.Type!.Value);
|
var metadata = PolicyDataValidator.ValidateAndDeserializeMetadata(Metadata, type);
|
||||||
var performedBy = new StandardUser(currentContext.UserId!.Value, await currentContext.OrganizationOwner(organizationId));
|
var performedBy = new StandardUser(currentContext.UserId!.Value, await currentContext.OrganizationOwner(organizationId));
|
||||||
|
|
||||||
return new SavePolicyModel(policyUpdate, performedBy, metadata);
|
return new SavePolicyModel(policyUpdate, performedBy, metadata);
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ public abstract class BaseProfileOrganizationResponseModel : ResponseModel
|
|||||||
UseAdminSponsoredFamilies = organizationDetails.UseAdminSponsoredFamilies;
|
UseAdminSponsoredFamilies = organizationDetails.UseAdminSponsoredFamilies;
|
||||||
UseAutomaticUserConfirmation = organizationDetails.UseAutomaticUserConfirmation;
|
UseAutomaticUserConfirmation = organizationDetails.UseAutomaticUserConfirmation;
|
||||||
UseSecretsManager = organizationDetails.UseSecretsManager;
|
UseSecretsManager = organizationDetails.UseSecretsManager;
|
||||||
|
UsePhishingBlocker = organizationDetails.UsePhishingBlocker;
|
||||||
UsePasswordManager = organizationDetails.UsePasswordManager;
|
UsePasswordManager = organizationDetails.UsePasswordManager;
|
||||||
SelfHost = organizationDetails.SelfHost;
|
SelfHost = organizationDetails.SelfHost;
|
||||||
Seats = organizationDetails.Seats;
|
Seats = organizationDetails.Seats;
|
||||||
@@ -99,6 +100,7 @@ public abstract class BaseProfileOrganizationResponseModel : ResponseModel
|
|||||||
public bool UseOrganizationDomains { get; set; }
|
public bool UseOrganizationDomains { get; set; }
|
||||||
public bool UseAdminSponsoredFamilies { get; set; }
|
public bool UseAdminSponsoredFamilies { get; set; }
|
||||||
public bool UseAutomaticUserConfirmation { get; set; }
|
public bool UseAutomaticUserConfirmation { get; set; }
|
||||||
|
public bool UsePhishingBlocker { get; set; }
|
||||||
public bool SelfHost { get; set; }
|
public bool SelfHost { get; set; }
|
||||||
public int? Seats { get; set; }
|
public int? Seats { get; set; }
|
||||||
public short? MaxCollections { get; set; }
|
public short? MaxCollections { get; set; }
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.Models.Api;
|
using Bit.Core.Models.Api;
|
||||||
|
|
||||||
#nullable enable
|
|
||||||
|
|
||||||
namespace Bit.Api.AdminConsole.Models.Response.Organizations;
|
namespace Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||||
|
|
||||||
public class OrganizationIntegrationConfigurationResponseModel : ResponseModel
|
public class OrganizationIntegrationConfigurationResponseModel : ResponseModel
|
||||||
@@ -11,8 +9,6 @@ public class OrganizationIntegrationConfigurationResponseModel : ResponseModel
|
|||||||
public OrganizationIntegrationConfigurationResponseModel(OrganizationIntegrationConfiguration organizationIntegrationConfiguration, string obj = "organizationIntegrationConfiguration")
|
public OrganizationIntegrationConfigurationResponseModel(OrganizationIntegrationConfiguration organizationIntegrationConfiguration, string obj = "organizationIntegrationConfiguration")
|
||||||
: base(obj)
|
: base(obj)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(organizationIntegrationConfiguration);
|
|
||||||
|
|
||||||
Id = organizationIntegrationConfiguration.Id;
|
Id = organizationIntegrationConfiguration.Id;
|
||||||
Configuration = organizationIntegrationConfiguration.Configuration;
|
Configuration = organizationIntegrationConfiguration.Configuration;
|
||||||
CreationDate = organizationIntegrationConfiguration.CreationDate;
|
CreationDate = organizationIntegrationConfiguration.CreationDate;
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ public class OrganizationResponseModel : ResponseModel
|
|||||||
UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies;
|
UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies;
|
||||||
UseAutomaticUserConfirmation = organization.UseAutomaticUserConfirmation;
|
UseAutomaticUserConfirmation = organization.UseAutomaticUserConfirmation;
|
||||||
UseDisableSmAdsForUsers = organization.UseDisableSmAdsForUsers;
|
UseDisableSmAdsForUsers = organization.UseDisableSmAdsForUsers;
|
||||||
|
UsePhishingBlocker = organization.UsePhishingBlocker;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
@@ -122,6 +123,7 @@ public class OrganizationResponseModel : ResponseModel
|
|||||||
public bool UseAdminSponsoredFamilies { get; set; }
|
public bool UseAdminSponsoredFamilies { get; set; }
|
||||||
public bool UseAutomaticUserConfirmation { get; set; }
|
public bool UseAutomaticUserConfirmation { get; set; }
|
||||||
public bool UseDisableSmAdsForUsers { get; set; }
|
public bool UseDisableSmAdsForUsers { get; set; }
|
||||||
|
public bool UsePhishingBlocker { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class OrganizationSubscriptionResponseModel : OrganizationResponseModel
|
public class OrganizationSubscriptionResponseModel : OrganizationResponseModel
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ public class PolicyResponseModel : ResponseModel
|
|||||||
{
|
{
|
||||||
Data = JsonSerializer.Deserialize<Dictionary<string, object>>(policy.Data);
|
Data = JsonSerializer.Deserialize<Dictionary<string, object>>(policy.Data);
|
||||||
}
|
}
|
||||||
|
RevisionDate = policy.RevisionDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
@@ -37,4 +38,5 @@ public class PolicyResponseModel : ResponseModel
|
|||||||
public PolicyType Type { get; set; }
|
public PolicyType Type { get; set; }
|
||||||
public Dictionary<string, object> Data { get; set; }
|
public Dictionary<string, object> Data { get; set; }
|
||||||
public bool Enabled { get; set; }
|
public bool Enabled { get; set; }
|
||||||
|
public DateTime RevisionDate { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Bit.Core.Enums;
|
using Bit.Core.Billing.Models;
|
||||||
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.Models.Data;
|
using Bit.Core.Models.Data;
|
||||||
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||||
using Bit.Core.Utilities;
|
using Bit.Core.Utilities;
|
||||||
@@ -27,7 +28,7 @@ public class ProfileOrganizationResponseModel : BaseProfileOrganizationResponseM
|
|||||||
FamilySponsorshipToDelete = organizationDetails.FamilySponsorshipToDelete;
|
FamilySponsorshipToDelete = organizationDetails.FamilySponsorshipToDelete;
|
||||||
FamilySponsorshipValidUntil = organizationDetails.FamilySponsorshipValidUntil;
|
FamilySponsorshipValidUntil = organizationDetails.FamilySponsorshipValidUntil;
|
||||||
FamilySponsorshipAvailable = (organizationDetails.FamilySponsorshipFriendlyName == null || IsAdminInitiated) &&
|
FamilySponsorshipAvailable = (organizationDetails.FamilySponsorshipFriendlyName == null || IsAdminInitiated) &&
|
||||||
StaticStore.GetSponsoredPlan(PlanSponsorshipType.FamiliesForEnterprise)
|
SponsoredPlans.Get(PlanSponsorshipType.FamiliesForEnterprise)
|
||||||
.UsersCanSponsor(organizationDetails);
|
.UsersCanSponsor(organizationDetails);
|
||||||
AccessSecretsManager = organizationDetails.AccessSecretsManager;
|
AccessSecretsManager = organizationDetails.AccessSecretsManager;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
// FIXME: Update this file to be null safe and then delete the line below
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using Bit.Api.Models.Public.Request;
|
using Bit.Api.Models.Public.Request;
|
||||||
using Bit.Api.Models.Public.Response;
|
using Bit.Api.Models.Public.Response;
|
||||||
@@ -8,6 +6,7 @@ using Bit.Api.Utilities.DiagnosticTools;
|
|||||||
using Bit.Core.Context;
|
using Bit.Core.Context;
|
||||||
using Bit.Core.Models.Data;
|
using Bit.Core.Models.Data;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
|
using Bit.Core.SecretsManager.Repositories;
|
||||||
using Bit.Core.Services;
|
using Bit.Core.Services;
|
||||||
using Bit.Core.Vault.Repositories;
|
using Bit.Core.Vault.Repositories;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
@@ -22,6 +21,9 @@ public class EventsController : Controller
|
|||||||
private readonly IEventRepository _eventRepository;
|
private readonly IEventRepository _eventRepository;
|
||||||
private readonly ICipherRepository _cipherRepository;
|
private readonly ICipherRepository _cipherRepository;
|
||||||
private readonly ICurrentContext _currentContext;
|
private readonly ICurrentContext _currentContext;
|
||||||
|
private readonly ISecretRepository _secretRepository;
|
||||||
|
private readonly IProjectRepository _projectRepository;
|
||||||
|
private readonly IUserService _userService;
|
||||||
private readonly ILogger<EventsController> _logger;
|
private readonly ILogger<EventsController> _logger;
|
||||||
private readonly IFeatureService _featureService;
|
private readonly IFeatureService _featureService;
|
||||||
|
|
||||||
@@ -29,12 +31,18 @@ public class EventsController : Controller
|
|||||||
IEventRepository eventRepository,
|
IEventRepository eventRepository,
|
||||||
ICipherRepository cipherRepository,
|
ICipherRepository cipherRepository,
|
||||||
ICurrentContext currentContext,
|
ICurrentContext currentContext,
|
||||||
|
ISecretRepository secretRepository,
|
||||||
|
IProjectRepository projectRepository,
|
||||||
|
IUserService userService,
|
||||||
ILogger<EventsController> logger,
|
ILogger<EventsController> logger,
|
||||||
IFeatureService featureService)
|
IFeatureService featureService)
|
||||||
{
|
{
|
||||||
_eventRepository = eventRepository;
|
_eventRepository = eventRepository;
|
||||||
_cipherRepository = cipherRepository;
|
_cipherRepository = cipherRepository;
|
||||||
_currentContext = currentContext;
|
_currentContext = currentContext;
|
||||||
|
_secretRepository = secretRepository;
|
||||||
|
_projectRepository = projectRepository;
|
||||||
|
_userService = userService;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_featureService = featureService;
|
_featureService = featureService;
|
||||||
}
|
}
|
||||||
@@ -50,35 +58,76 @@ public class EventsController : Controller
|
|||||||
[ProducesResponseType(typeof(PagedListResponseModel<EventResponseModel>), (int)HttpStatusCode.OK)]
|
[ProducesResponseType(typeof(PagedListResponseModel<EventResponseModel>), (int)HttpStatusCode.OK)]
|
||||||
public async Task<IActionResult> List([FromQuery] EventFilterRequestModel request)
|
public async Task<IActionResult> List([FromQuery] EventFilterRequestModel request)
|
||||||
{
|
{
|
||||||
|
if (!_currentContext.OrganizationId.HasValue)
|
||||||
|
{
|
||||||
|
return new JsonResult(new PagedListResponseModel<EventResponseModel>([], ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
var organizationId = _currentContext.OrganizationId.Value;
|
||||||
var dateRange = request.ToDateRange();
|
var dateRange = request.ToDateRange();
|
||||||
var result = new PagedResult<IEvent>();
|
var result = new PagedResult<IEvent>();
|
||||||
if (request.ActingUserId.HasValue)
|
if (request.ActingUserId.HasValue)
|
||||||
{
|
{
|
||||||
result = await _eventRepository.GetManyByOrganizationActingUserAsync(
|
result = await _eventRepository.GetManyByOrganizationActingUserAsync(
|
||||||
_currentContext.OrganizationId.Value, request.ActingUserId.Value, dateRange.Item1, dateRange.Item2,
|
organizationId, request.ActingUserId.Value, dateRange.Item1, dateRange.Item2,
|
||||||
new PageOptions { ContinuationToken = request.ContinuationToken });
|
new PageOptions { ContinuationToken = request.ContinuationToken });
|
||||||
}
|
}
|
||||||
else if (request.ItemId.HasValue)
|
else if (request.ItemId.HasValue)
|
||||||
{
|
{
|
||||||
var cipher = await _cipherRepository.GetByIdAsync(request.ItemId.Value);
|
var cipher = await _cipherRepository.GetByIdAsync(request.ItemId.Value);
|
||||||
if (cipher != null && cipher.OrganizationId == _currentContext.OrganizationId.Value)
|
if (cipher != null && cipher.OrganizationId == organizationId)
|
||||||
{
|
{
|
||||||
result = await _eventRepository.GetManyByCipherAsync(
|
result = await _eventRepository.GetManyByCipherAsync(
|
||||||
cipher, dateRange.Item1, dateRange.Item2,
|
cipher, dateRange.Item1, dateRange.Item2,
|
||||||
new PageOptions { ContinuationToken = request.ContinuationToken });
|
new PageOptions { ContinuationToken = request.ContinuationToken });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else if (request.SecretId.HasValue)
|
||||||
|
{
|
||||||
|
var secret = await _secretRepository.GetByIdAsync(request.SecretId.Value);
|
||||||
|
|
||||||
|
if (secret == null)
|
||||||
|
{
|
||||||
|
secret = new Core.SecretsManager.Entities.Secret { Id = request.SecretId.Value, OrganizationId = organizationId };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (secret.OrganizationId == organizationId)
|
||||||
|
{
|
||||||
|
result = await _eventRepository.GetManyBySecretAsync(
|
||||||
|
secret, dateRange.Item1, dateRange.Item2,
|
||||||
|
new PageOptions { ContinuationToken = request.ContinuationToken });
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return new JsonResult(new PagedListResponseModel<EventResponseModel>([], ""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (request.ProjectId.HasValue)
|
||||||
|
{
|
||||||
|
var project = await _projectRepository.GetByIdAsync(request.ProjectId.Value);
|
||||||
|
if (project != null && project.OrganizationId == organizationId)
|
||||||
|
{
|
||||||
|
result = await _eventRepository.GetManyByProjectAsync(
|
||||||
|
project, dateRange.Item1, dateRange.Item2,
|
||||||
|
new PageOptions { ContinuationToken = request.ContinuationToken });
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return new JsonResult(new PagedListResponseModel<EventResponseModel>([], ""));
|
||||||
|
}
|
||||||
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
result = await _eventRepository.GetManyByOrganizationAsync(
|
result = await _eventRepository.GetManyByOrganizationAsync(
|
||||||
_currentContext.OrganizationId.Value, dateRange.Item1, dateRange.Item2,
|
organizationId, dateRange.Item1, dateRange.Item2,
|
||||||
new PageOptions { ContinuationToken = request.ContinuationToken });
|
new PageOptions { ContinuationToken = request.ContinuationToken });
|
||||||
}
|
}
|
||||||
|
|
||||||
var eventResponses = result.Data.Select(e => new EventResponseModel(e));
|
var eventResponses = result.Data.Select(e => new EventResponseModel(e));
|
||||||
var response = new PagedListResponseModel<EventResponseModel>(eventResponses, result.ContinuationToken);
|
var response = new PagedListResponseModel<EventResponseModel>(eventResponses, result.ContinuationToken ?? "");
|
||||||
|
|
||||||
|
_logger.LogAggregateData(_featureService, organizationId, response, request);
|
||||||
|
|
||||||
_logger.LogAggregateData(_featureService, _currentContext.OrganizationId!.Value, response, request);
|
|
||||||
return new JsonResult(response);
|
return new JsonResult(response);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,14 @@ public class EventFilterRequestModel
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public Guid? ItemId { get; set; }
|
public Guid? ItemId { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
/// The unique identifier of the related secret that the event describes.
|
||||||
|
/// </summary>
|
||||||
|
public Guid? SecretId { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// The unique identifier of the related project that the event describes.
|
||||||
|
/// </summary>
|
||||||
|
public Guid? ProjectId { get; set; }
|
||||||
|
/// <summary>
|
||||||
/// A cursor for use in pagination.
|
/// A cursor for use in pagination.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string ContinuationToken { get; set; }
|
public string ContinuationToken { get; set; }
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="AspNetCore.HealthChecks.SqlServer" Version="8.0.2" />
|
<PackageReference Include="AspNetCore.HealthChecks.SqlServer" Version="8.0.2" />
|
||||||
<PackageReference Include="AspNetCore.HealthChecks.Uris" Version="8.0.1" />
|
<PackageReference Include="AspNetCore.HealthChecks.Uris" Version="8.0.1" />
|
||||||
<PackageReference Include="Azure.Messaging.EventGrid" Version="4.25.0" />
|
<PackageReference Include="Azure.Messaging.EventGrid" Version="4.31.0" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.4" />
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.4" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using Bit.Api.Models.Request;
|
|||||||
using Bit.Api.Models.Request.Accounts;
|
using Bit.Api.Models.Request.Accounts;
|
||||||
using Bit.Api.Models.Response;
|
using Bit.Api.Models.Response;
|
||||||
using Bit.Api.Utilities;
|
using Bit.Api.Utilities;
|
||||||
|
using Bit.Core;
|
||||||
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
||||||
using Bit.Core.Billing.Models;
|
using Bit.Core.Billing.Models;
|
||||||
using Bit.Core.Billing.Models.Business;
|
using Bit.Core.Billing.Models.Business;
|
||||||
@@ -24,7 +25,8 @@ namespace Bit.Api.Billing.Controllers;
|
|||||||
public class AccountsController(
|
public class AccountsController(
|
||||||
IUserService userService,
|
IUserService userService,
|
||||||
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
|
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
|
||||||
IUserAccountKeysQuery userAccountKeysQuery) : Controller
|
IUserAccountKeysQuery userAccountKeysQuery,
|
||||||
|
IFeatureService featureService) : Controller
|
||||||
{
|
{
|
||||||
[HttpPost("premium")]
|
[HttpPost("premium")]
|
||||||
public async Task<PaymentResponseModel> PostPremiumAsync(
|
public async Task<PaymentResponseModel> PostPremiumAsync(
|
||||||
@@ -84,16 +86,24 @@ public class AccountsController(
|
|||||||
throw new UnauthorizedAccessException();
|
throw new UnauthorizedAccessException();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!globalSettings.SelfHosted && user.Gateway != null)
|
// Only cloud-hosted users with payment gateways have subscription and discount information
|
||||||
|
if (!globalSettings.SelfHosted)
|
||||||
{
|
{
|
||||||
var subscriptionInfo = await paymentService.GetSubscriptionAsync(user);
|
if (user.Gateway != null)
|
||||||
var license = await userService.GenerateLicenseAsync(user, subscriptionInfo);
|
{
|
||||||
return new SubscriptionResponseModel(user, subscriptionInfo, license);
|
// Note: PM23341_Milestone_2 is the feature flag for the overall Milestone 2 initiative (PM-23341).
|
||||||
}
|
// This specific implementation (PM-26682) adds discount display functionality as part of that initiative.
|
||||||
else if (!globalSettings.SelfHosted)
|
// The feature flag controls the broader Milestone 2 feature set, not just this specific task.
|
||||||
{
|
var includeMilestone2Discount = featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2);
|
||||||
var license = await userService.GenerateLicenseAsync(user);
|
var subscriptionInfo = await paymentService.GetSubscriptionAsync(user);
|
||||||
return new SubscriptionResponseModel(user, license);
|
var license = await userService.GenerateLicenseAsync(user, subscriptionInfo);
|
||||||
|
return new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var license = await userService.GenerateLicenseAsync(user);
|
||||||
|
return new SubscriptionResponseModel(user, license);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using Bit.Api.AdminConsole.Authorization;
|
||||||
|
using Bit.Api.AdminConsole.Authorization.Requirements;
|
||||||
|
using Bit.Api.Billing.Attributes;
|
||||||
|
using Bit.Core;
|
||||||
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Bit.Core.Billing.Organizations.Queries;
|
||||||
|
using Bit.Core.Utilities;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||||
|
|
||||||
|
namespace Bit.Api.Billing.Controllers.VNext;
|
||||||
|
|
||||||
|
[Authorize("Application")]
|
||||||
|
[Route("organizations/{organizationId:guid}/billing/vnext/self-host")]
|
||||||
|
[SelfHosted(SelfHostedOnly = true)]
|
||||||
|
public class SelfHostedBillingController(
|
||||||
|
IGetOrganizationMetadataQuery getOrganizationMetadataQuery) : BaseBillingController
|
||||||
|
{
|
||||||
|
[Authorize<MemberOrProviderRequirement>]
|
||||||
|
[HttpGet("metadata")]
|
||||||
|
[RequireFeature(FeatureFlagKeys.PM25379_UseNewOrganizationMetadataStructure)]
|
||||||
|
[InjectOrganization]
|
||||||
|
public async Task<IResult> GetMetadataAsync([BindNever] Organization organization)
|
||||||
|
{
|
||||||
|
var metadata = await getOrganizationMetadataQuery.Run(organization);
|
||||||
|
|
||||||
|
if (metadata == null)
|
||||||
|
{
|
||||||
|
return TypedResults.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return TypedResults.Ok(metadata);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,7 +66,10 @@ public class HibpController : Controller
|
|||||||
}
|
}
|
||||||
else if (response.StatusCode == HttpStatusCode.NotFound)
|
else if (response.StatusCode == HttpStatusCode.NotFound)
|
||||||
{
|
{
|
||||||
return new NotFoundResult();
|
/* 12/1/2025 - Per the HIBP API, If the domain does not have any email addresses in any breaches,
|
||||||
|
an HTTP 404 response will be returned. API also specifies that "404 Not found is the account could
|
||||||
|
not be found and has therefore not been pwned". Per REST semantics we will return 200 OK with empty array. */
|
||||||
|
return Content("[]", "application/json");
|
||||||
}
|
}
|
||||||
else if (response.StatusCode == HttpStatusCode.TooManyRequests && retry)
|
else if (response.StatusCode == HttpStatusCode.TooManyRequests && retry)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using Bit.Core.KeyManagement.Models.Api.Request;
|
||||||
|
|
||||||
namespace Bit.Api.KeyManagement.Models.Requests;
|
namespace Bit.Api.KeyManagement.Models.Requests;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
// FIXME: Update this file to be null safe and then delete the line below
|
using Bit.Core.Billing.Constants;
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
using Bit.Core.Billing.Models.Business;
|
using Bit.Core.Billing.Models.Business;
|
||||||
using Bit.Core.Entities;
|
using Bit.Core.Entities;
|
||||||
using Bit.Core.Models.Api;
|
using Bit.Core.Models.Api;
|
||||||
@@ -11,7 +9,17 @@ namespace Bit.Api.Models.Response;
|
|||||||
|
|
||||||
public class SubscriptionResponseModel : ResponseModel
|
public class SubscriptionResponseModel : ResponseModel
|
||||||
{
|
{
|
||||||
public SubscriptionResponseModel(User user, SubscriptionInfo subscription, UserLicense license)
|
|
||||||
|
/// <param name="user">The user entity containing storage and premium subscription information</param>
|
||||||
|
/// <param name="subscription">Subscription information retrieved from the payment provider (Stripe/Braintree)</param>
|
||||||
|
/// <param name="license">The user's license containing expiration and feature entitlements</param>
|
||||||
|
/// <param name="includeMilestone2Discount">
|
||||||
|
/// Whether to include discount information in the response.
|
||||||
|
/// Set to true when the PM23341_Milestone_2 feature flag is enabled AND
|
||||||
|
/// you want to expose Milestone 2 discount information to the client.
|
||||||
|
/// The discount will only be included if it matches the specific Milestone 2 coupon ID.
|
||||||
|
/// </param>
|
||||||
|
public SubscriptionResponseModel(User user, SubscriptionInfo subscription, UserLicense license, bool includeMilestone2Discount = false)
|
||||||
: base("subscription")
|
: base("subscription")
|
||||||
{
|
{
|
||||||
Subscription = subscription.Subscription != null ? new BillingSubscription(subscription.Subscription) : null;
|
Subscription = subscription.Subscription != null ? new BillingSubscription(subscription.Subscription) : null;
|
||||||
@@ -22,9 +30,14 @@ public class SubscriptionResponseModel : ResponseModel
|
|||||||
MaxStorageGb = user.MaxStorageGb;
|
MaxStorageGb = user.MaxStorageGb;
|
||||||
License = license;
|
License = license;
|
||||||
Expiration = License.Expires;
|
Expiration = License.Expires;
|
||||||
|
|
||||||
|
// Only display the Milestone 2 subscription discount on the subscription page.
|
||||||
|
CustomerDiscount = ShouldIncludeMilestone2Discount(includeMilestone2Discount, subscription.CustomerDiscount)
|
||||||
|
? new BillingCustomerDiscount(subscription.CustomerDiscount!)
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public SubscriptionResponseModel(User user, UserLicense license = null)
|
public SubscriptionResponseModel(User user, UserLicense? license = null)
|
||||||
: base("subscription")
|
: base("subscription")
|
||||||
{
|
{
|
||||||
StorageName = user.Storage.HasValue ? CoreHelpers.ReadableBytesSize(user.Storage.Value) : null;
|
StorageName = user.Storage.HasValue ? CoreHelpers.ReadableBytesSize(user.Storage.Value) : null;
|
||||||
@@ -38,21 +51,109 @@ public class SubscriptionResponseModel : ResponseModel
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public string StorageName { get; set; }
|
public string? StorageName { get; set; }
|
||||||
public double? StorageGb { get; set; }
|
public double? StorageGb { get; set; }
|
||||||
public short? MaxStorageGb { get; set; }
|
public short? MaxStorageGb { get; set; }
|
||||||
public BillingSubscriptionUpcomingInvoice UpcomingInvoice { get; set; }
|
public BillingSubscriptionUpcomingInvoice? UpcomingInvoice { get; set; }
|
||||||
public BillingSubscription Subscription { get; set; }
|
public BillingSubscription? Subscription { get; set; }
|
||||||
public UserLicense License { get; set; }
|
/// <summary>
|
||||||
|
/// Customer discount information from Stripe for the Milestone 2 subscription discount.
|
||||||
|
/// Only includes the specific Milestone 2 coupon (cm3nHfO1) when it's a perpetual discount (no expiration).
|
||||||
|
/// This is for display purposes only and does not affect Stripe's automatic discount application.
|
||||||
|
/// Other discounts may still apply in Stripe billing but are not included in this response.
|
||||||
|
/// <para>
|
||||||
|
/// Null when:
|
||||||
|
/// - The PM23341_Milestone_2 feature flag is disabled
|
||||||
|
/// - There is no active discount
|
||||||
|
/// - The discount coupon ID doesn't match the Milestone 2 coupon (cm3nHfO1)
|
||||||
|
/// - The instance is self-hosted
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public BillingCustomerDiscount? CustomerDiscount { get; set; }
|
||||||
|
public UserLicense? License { get; set; }
|
||||||
public DateTime? Expiration { get; set; }
|
public DateTime? Expiration { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines whether the Milestone 2 discount should be included in the response.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="includeMilestone2Discount">Whether the feature flag is enabled and discount should be considered.</param>
|
||||||
|
/// <param name="customerDiscount">The customer discount from subscription info, if any.</param>
|
||||||
|
/// <returns>True if the discount should be included; false otherwise.</returns>
|
||||||
|
private static bool ShouldIncludeMilestone2Discount(
|
||||||
|
bool includeMilestone2Discount,
|
||||||
|
SubscriptionInfo.BillingCustomerDiscount? customerDiscount)
|
||||||
|
{
|
||||||
|
return includeMilestone2Discount &&
|
||||||
|
customerDiscount != null &&
|
||||||
|
customerDiscount.Id == StripeConstants.CouponIDs.Milestone2SubscriptionDiscount &&
|
||||||
|
customerDiscount.Active;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class BillingCustomerDiscount(SubscriptionInfo.BillingCustomerDiscount discount)
|
/// <summary>
|
||||||
|
/// Customer discount information from Stripe billing.
|
||||||
|
/// </summary>
|
||||||
|
public class BillingCustomerDiscount
|
||||||
{
|
{
|
||||||
public string Id { get; } = discount.Id;
|
/// <summary>
|
||||||
public bool Active { get; } = discount.Active;
|
/// The Stripe coupon ID (e.g., "cm3nHfO1").
|
||||||
public decimal? PercentOff { get; } = discount.PercentOff;
|
/// </summary>
|
||||||
public List<string> AppliesTo { get; } = discount.AppliesTo;
|
public string? Id { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the discount is a recurring/perpetual discount with no expiration date.
|
||||||
|
/// <para>
|
||||||
|
/// This property is true only when the discount has no end date, meaning it applies
|
||||||
|
/// indefinitely to all future renewals. This is a product decision for Milestone 2
|
||||||
|
/// to only display perpetual discounts in the UI.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Note: This does NOT indicate whether the discount is "currently active" in the billing sense.
|
||||||
|
/// A discount with a future end date is functionally active and will be applied by Stripe,
|
||||||
|
/// but this property will be false because it has an expiration date.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public bool Active { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Percentage discount applied to the subscription (e.g., 20.0 for 20% off).
|
||||||
|
/// Null if this is an amount-based discount.
|
||||||
|
/// </summary>
|
||||||
|
public decimal? PercentOff { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fixed amount discount in USD (e.g., 14.00 for $14 off).
|
||||||
|
/// Converted from Stripe's cent-based values (1400 cents → $14.00).
|
||||||
|
/// Null if this is a percentage-based discount.
|
||||||
|
/// Note: Stripe stores amounts in the smallest currency unit. This value is always in USD.
|
||||||
|
/// </summary>
|
||||||
|
public decimal? AmountOff { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// List of Stripe product IDs that this discount applies to (e.g., ["prod_premium", "prod_families"]).
|
||||||
|
/// <para>
|
||||||
|
/// Null: discount applies to all products with no restrictions (AppliesTo not specified in Stripe).
|
||||||
|
/// Empty list: discount restricted to zero products (edge case - AppliesTo.Products = [] in Stripe).
|
||||||
|
/// Non-empty list: discount applies only to the specified product IDs.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<string>? AppliesTo { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a BillingCustomerDiscount from a SubscriptionInfo.BillingCustomerDiscount.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="discount">The discount to convert. Must not be null.</param>
|
||||||
|
/// <exception cref="ArgumentNullException">Thrown when discount is null.</exception>
|
||||||
|
public BillingCustomerDiscount(SubscriptionInfo.BillingCustomerDiscount discount)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(discount);
|
||||||
|
|
||||||
|
Id = discount.Id;
|
||||||
|
Active = discount.Active;
|
||||||
|
PercentOff = discount.PercentOff;
|
||||||
|
AmountOff = discount.AmountOff;
|
||||||
|
AppliesTo = discount.AppliesTo;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class BillingSubscription
|
public class BillingSubscription
|
||||||
@@ -83,10 +184,10 @@ public class BillingSubscription
|
|||||||
public DateTime? PeriodEndDate { get; set; }
|
public DateTime? PeriodEndDate { get; set; }
|
||||||
public DateTime? CancelledDate { get; set; }
|
public DateTime? CancelledDate { get; set; }
|
||||||
public bool CancelAtEndDate { get; set; }
|
public bool CancelAtEndDate { get; set; }
|
||||||
public string Status { get; set; }
|
public string? Status { get; set; }
|
||||||
public bool Cancelled { get; set; }
|
public bool Cancelled { get; set; }
|
||||||
public IEnumerable<BillingSubscriptionItem> Items { get; set; } = new List<BillingSubscriptionItem>();
|
public IEnumerable<BillingSubscriptionItem> Items { get; set; } = new List<BillingSubscriptionItem>();
|
||||||
public string CollectionMethod { get; set; }
|
public string? CollectionMethod { get; set; }
|
||||||
public DateTime? SuspensionDate { get; set; }
|
public DateTime? SuspensionDate { get; set; }
|
||||||
public DateTime? UnpaidPeriodEndDate { get; set; }
|
public DateTime? UnpaidPeriodEndDate { get; set; }
|
||||||
public int? GracePeriod { get; set; }
|
public int? GracePeriod { get; set; }
|
||||||
@@ -104,11 +205,11 @@ public class BillingSubscription
|
|||||||
AddonSubscriptionItem = item.AddonSubscriptionItem;
|
AddonSubscriptionItem = item.AddonSubscriptionItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
public string ProductId { get; set; }
|
public string? ProductId { get; set; }
|
||||||
public string Name { get; set; }
|
public string? Name { get; set; }
|
||||||
public decimal Amount { get; set; }
|
public decimal Amount { get; set; }
|
||||||
public int Quantity { get; set; }
|
public int Quantity { get; set; }
|
||||||
public string Interval { get; set; }
|
public string? Interval { get; set; }
|
||||||
public bool SponsoredSubscriptionItem { get; set; }
|
public bool SponsoredSubscriptionItem { get; set; }
|
||||||
public bool AddonSubscriptionItem { get; set; }
|
public bool AddonSubscriptionItem { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
// FIXME: Update this file to be null safe and then delete the line below
|
using Bit.Core.Utilities;
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
using AspNetCoreRateLimit;
|
|
||||||
using Bit.Core.Utilities;
|
|
||||||
using Microsoft.IdentityModel.Tokens;
|
|
||||||
|
|
||||||
namespace Bit.Api;
|
namespace Bit.Api;
|
||||||
|
|
||||||
@@ -17,32 +12,8 @@ public class Program
|
|||||||
.ConfigureWebHostDefaults(webBuilder =>
|
.ConfigureWebHostDefaults(webBuilder =>
|
||||||
{
|
{
|
||||||
webBuilder.UseStartup<Startup>();
|
webBuilder.UseStartup<Startup>();
|
||||||
webBuilder.ConfigureLogging((hostingContext, logging) =>
|
|
||||||
logging.AddSerilog(hostingContext, (e, globalSettings) =>
|
|
||||||
{
|
|
||||||
var context = e.Properties["SourceContext"].ToString();
|
|
||||||
if (e.Exception != null &&
|
|
||||||
(e.Exception.GetType() == typeof(SecurityTokenValidationException) ||
|
|
||||||
e.Exception.Message == "Bad security stamp."))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
context.Contains(typeof(IpRateLimitMiddleware).FullName))
|
|
||||||
{
|
|
||||||
return e.Level >= globalSettings.MinLogLevel.ApiSettings.IpRateLimit;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (context.Contains("Duende.IdentityServer.Validation.TokenValidator") ||
|
|
||||||
context.Contains("Duende.IdentityServer.Validation.TokenRequestValidator"))
|
|
||||||
{
|
|
||||||
return e.Level >= globalSettings.MinLogLevel.ApiSettings.IdentityToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
return e.Level >= globalSettings.MinLogLevel.ApiSettings.Default;
|
|
||||||
}));
|
|
||||||
})
|
})
|
||||||
|
.AddSerilogFileLogging()
|
||||||
.Build()
|
.Build()
|
||||||
.Run();
|
.Run();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -216,7 +216,7 @@ public class Startup
|
|||||||
config.Conventions.Add(new PublicApiControllersModelConvention());
|
config.Conventions.Add(new PublicApiControllersModelConvention());
|
||||||
});
|
});
|
||||||
|
|
||||||
services.AddSwagger(globalSettings, Environment);
|
services.AddSwaggerGen(globalSettings, Environment);
|
||||||
Jobs.JobsHostedService.AddJobsServices(services, globalSettings.SelfHosted);
|
Jobs.JobsHostedService.AddJobsServices(services, globalSettings.SelfHosted);
|
||||||
services.AddHostedService<Jobs.JobsHostedService>();
|
services.AddHostedService<Jobs.JobsHostedService>();
|
||||||
|
|
||||||
@@ -234,12 +234,10 @@ public class Startup
|
|||||||
public void Configure(
|
public void Configure(
|
||||||
IApplicationBuilder app,
|
IApplicationBuilder app,
|
||||||
IWebHostEnvironment env,
|
IWebHostEnvironment env,
|
||||||
IHostApplicationLifetime appLifetime,
|
|
||||||
GlobalSettings globalSettings,
|
GlobalSettings globalSettings,
|
||||||
ILogger<Startup> logger)
|
ILogger<Startup> logger)
|
||||||
{
|
{
|
||||||
IdentityModelEventSource.ShowPII = true;
|
IdentityModelEventSource.ShowPII = true;
|
||||||
app.UseSerilog(env, appLifetime, globalSettings);
|
|
||||||
|
|
||||||
// Add general security headers
|
// Add general security headers
|
||||||
app.UseMiddleware<SecurityHeadersMiddleware>();
|
app.UseMiddleware<SecurityHeadersMiddleware>();
|
||||||
@@ -294,17 +292,59 @@ public class Startup
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Add Swagger
|
// Add Swagger
|
||||||
|
// Note that the swagger.json generation is configured in the call to AddSwaggerGen above.
|
||||||
if (Environment.IsDevelopment() || globalSettings.SelfHosted)
|
if (Environment.IsDevelopment() || globalSettings.SelfHosted)
|
||||||
{
|
{
|
||||||
|
// adds the middleware to serve the swagger.json while the server is running
|
||||||
app.UseSwagger(config =>
|
app.UseSwagger(config =>
|
||||||
{
|
{
|
||||||
config.RouteTemplate = "specs/{documentName}/swagger.json";
|
config.RouteTemplate = "specs/{documentName}/swagger.json";
|
||||||
|
|
||||||
|
// Remove all Bitwarden cloud servers and only register the local server
|
||||||
config.PreSerializeFilters.Add((swaggerDoc, httpReq) =>
|
config.PreSerializeFilters.Add((swaggerDoc, httpReq) =>
|
||||||
swaggerDoc.Servers = new List<OpenApiServer>
|
{
|
||||||
|
swaggerDoc.Servers.Clear();
|
||||||
|
swaggerDoc.Servers.Add(new OpenApiServer
|
||||||
{
|
{
|
||||||
new OpenApiServer { Url = globalSettings.BaseServiceUri.Api }
|
Url = globalSettings.BaseServiceUri.Api,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
swaggerDoc.Components.SecuritySchemes.Clear();
|
||||||
|
swaggerDoc.Components.SecuritySchemes.Add("oauth2-client-credentials", new OpenApiSecurityScheme
|
||||||
|
{
|
||||||
|
Type = SecuritySchemeType.OAuth2,
|
||||||
|
Flows = new OpenApiOAuthFlows
|
||||||
|
{
|
||||||
|
ClientCredentials = new OpenApiOAuthFlow
|
||||||
|
{
|
||||||
|
TokenUrl = new Uri($"{globalSettings.BaseServiceUri.Identity}/connect/token"),
|
||||||
|
Scopes = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
{ ApiScopes.ApiOrganization, "Organization APIs" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
swaggerDoc.SecurityRequirements.Clear();
|
||||||
|
swaggerDoc.SecurityRequirements.Add(new OpenApiSecurityRequirement
|
||||||
|
{
|
||||||
|
{
|
||||||
|
new OpenApiSecurityScheme
|
||||||
|
{
|
||||||
|
Reference = new OpenApiReference
|
||||||
|
{
|
||||||
|
Type = ReferenceType.SecurityScheme,
|
||||||
|
Id = "oauth2-client-credentials"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[ApiScopes.ApiOrganization]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// adds the middleware to display the web UI
|
||||||
app.UseSwaggerUI(config =>
|
app.UseSwaggerUI(config =>
|
||||||
{
|
{
|
||||||
config.DocumentTitle = "Bitwarden API Documentation";
|
config.DocumentTitle = "Bitwarden API Documentation";
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
using Bit.Api.AdminConsole.Authorization;
|
using Bit.Api.AdminConsole.Authorization;
|
||||||
using Bit.Api.Tools.Authorization;
|
using Bit.Api.Tools.Authorization;
|
||||||
using Bit.Core.Auth.IdentityServer;
|
|
||||||
using Bit.Core.PhishingDomainFeatures;
|
using Bit.Core.PhishingDomainFeatures;
|
||||||
using Bit.Core.PhishingDomainFeatures.Interfaces;
|
using Bit.Core.PhishingDomainFeatures.Interfaces;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
@@ -10,6 +9,7 @@ using Bit.Core.Utilities;
|
|||||||
using Bit.Core.Vault.Authorization.SecurityTasks;
|
using Bit.Core.Vault.Authorization.SecurityTasks;
|
||||||
using Bit.SharedWeb.Health;
|
using Bit.SharedWeb.Health;
|
||||||
using Bit.SharedWeb.Swagger;
|
using Bit.SharedWeb.Swagger;
|
||||||
|
using Bit.SharedWeb.Utilities;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.OpenApi.Models;
|
using Microsoft.OpenApi.Models;
|
||||||
|
|
||||||
@@ -17,7 +17,10 @@ namespace Bit.Api.Utilities;
|
|||||||
|
|
||||||
public static class ServiceCollectionExtensions
|
public static class ServiceCollectionExtensions
|
||||||
{
|
{
|
||||||
public static void AddSwagger(this IServiceCollection services, GlobalSettings globalSettings, IWebHostEnvironment environment)
|
/// <summary>
|
||||||
|
/// Configures the generation of swagger.json OpenAPI spec.
|
||||||
|
/// </summary>
|
||||||
|
public static void AddSwaggerGen(this IServiceCollection services, GlobalSettings globalSettings, IWebHostEnvironment environment)
|
||||||
{
|
{
|
||||||
services.AddSwaggerGen(config =>
|
services.AddSwaggerGen(config =>
|
||||||
{
|
{
|
||||||
@@ -36,6 +39,8 @@ public static class ServiceCollectionExtensions
|
|||||||
organizations tools for managing members, collections, groups, event logs, and policies.
|
organizations tools for managing members, collections, groups, event logs, and policies.
|
||||||
If you are looking for the Vault Management API, refer instead to
|
If you are looking for the Vault Management API, refer instead to
|
||||||
[this document](https://bitwarden.com/help/vault-management-api/).
|
[this document](https://bitwarden.com/help/vault-management-api/).
|
||||||
|
|
||||||
|
**Note:** your authorization must match the server you have selected.
|
||||||
""",
|
""",
|
||||||
License = new OpenApiLicense
|
License = new OpenApiLicense
|
||||||
{
|
{
|
||||||
@@ -46,36 +51,20 @@ public static class ServiceCollectionExtensions
|
|||||||
|
|
||||||
config.SwaggerDoc("internal", new OpenApiInfo { Title = "Bitwarden Internal API", Version = "latest" });
|
config.SwaggerDoc("internal", new OpenApiInfo { Title = "Bitwarden Internal API", Version = "latest" });
|
||||||
|
|
||||||
config.AddSecurityDefinition("oauth2-client-credentials", new OpenApiSecurityScheme
|
// Configure Bitwarden cloud US and EU servers. These will appear in the swagger.json build artifact
|
||||||
{
|
// used for our help center. These are overwritten with the local server when running in self-hosted
|
||||||
Type = SecuritySchemeType.OAuth2,
|
// or dev mode (see Api Startup.cs).
|
||||||
Flows = new OpenApiOAuthFlows
|
config.AddSwaggerServerWithSecurity(
|
||||||
{
|
serverId: "US_server",
|
||||||
ClientCredentials = new OpenApiOAuthFlow
|
serverUrl: "https://api.bitwarden.com",
|
||||||
{
|
identityTokenUrl: "https://identity.bitwarden.com/connect/token",
|
||||||
TokenUrl = new Uri($"{globalSettings.BaseServiceUri.Identity}/connect/token"),
|
serverDescription: "US server");
|
||||||
Scopes = new Dictionary<string, string>
|
|
||||||
{
|
|
||||||
{ ApiScopes.ApiOrganization, "Organization APIs" },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
config.AddSecurityRequirement(new OpenApiSecurityRequirement
|
config.AddSwaggerServerWithSecurity(
|
||||||
{
|
serverId: "EU_server",
|
||||||
{
|
serverUrl: "https://api.bitwarden.eu",
|
||||||
new OpenApiSecurityScheme
|
identityTokenUrl: "https://identity.bitwarden.eu/connect/token",
|
||||||
{
|
serverDescription: "EU server");
|
||||||
Reference = new OpenApiReference
|
|
||||||
{
|
|
||||||
Type = ReferenceType.SecurityScheme,
|
|
||||||
Id = "oauth2-client-credentials"
|
|
||||||
},
|
|
||||||
},
|
|
||||||
new[] { ApiScopes.ApiOrganization }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
config.DescribeAllParametersInCamelCase();
|
config.DescribeAllParametersInCamelCase();
|
||||||
// config.UseReferencedDefinitionsForEnums();
|
// config.UseReferencedDefinitionsForEnums();
|
||||||
|
|||||||
@@ -402,8 +402,9 @@ public class CiphersController : Controller
|
|||||||
{
|
{
|
||||||
var org = _currentContext.GetOrganization(organizationId);
|
var org = _currentContext.GetOrganization(organizationId);
|
||||||
|
|
||||||
// If we're not an "admin" or if we're not a provider user we don't need to check the ciphers
|
// If we're not an "admin" we don't need to check the ciphers
|
||||||
if (org is not ({ Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or { Permissions.EditAnyCollection: true }) || await _currentContext.ProviderUserForOrgAsync(organizationId))
|
if (org is not ({ Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or
|
||||||
|
{ Permissions.EditAnyCollection: true }))
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -416,8 +417,9 @@ public class CiphersController : Controller
|
|||||||
{
|
{
|
||||||
var org = _currentContext.GetOrganization(organizationId);
|
var org = _currentContext.GetOrganization(organizationId);
|
||||||
|
|
||||||
// If we're not an "admin" or if we're a provider user we don't need to check the ciphers
|
// If we're not an "admin" we don't need to check the ciphers
|
||||||
if (org is not ({ Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or { Permissions.EditAnyCollection: true }) || await _currentContext.ProviderUserForOrgAsync(organizationId))
|
if (org is not ({ Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or
|
||||||
|
{ Permissions.EditAnyCollection: true }))
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -755,11 +757,6 @@ public class CiphersController : Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cipher.ArchivedDate.HasValue)
|
|
||||||
{
|
|
||||||
throw new BadRequestException("Cannot move an archived item to an organization.");
|
|
||||||
}
|
|
||||||
|
|
||||||
ValidateClientVersionForFido2CredentialSupport(cipher);
|
ValidateClientVersionForFido2CredentialSupport(cipher);
|
||||||
|
|
||||||
var original = cipher.Clone();
|
var original = cipher.Clone();
|
||||||
@@ -1269,11 +1266,6 @@ public class CiphersController : Controller
|
|||||||
_logger.LogError("Cipher was not encrypted for the current user. CipherId: {CipherId}, CurrentUser: {CurrentUserId}, EncryptedFor: {EncryptedFor}", cipher.Id, userId, cipher.EncryptedFor);
|
_logger.LogError("Cipher was not encrypted for the current user. CipherId: {CipherId}, CurrentUser: {CurrentUserId}, EncryptedFor: {EncryptedFor}", cipher.Id, userId, cipher.EncryptedFor);
|
||||||
throw new BadRequestException("Cipher was not encrypted for the current user. Please try again.");
|
throw new BadRequestException("Cipher was not encrypted for the current user. Please try again.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cipher.ArchivedDate.HasValue)
|
|
||||||
{
|
|
||||||
throw new BadRequestException("Cannot move archived items to an organization.");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var shareCiphers = new List<(CipherDetails, DateTime?)>();
|
var shareCiphers = new List<(CipherDetails, DateTime?)>();
|
||||||
@@ -1286,11 +1278,6 @@ public class CiphersController : Controller
|
|||||||
|
|
||||||
ValidateClientVersionForFido2CredentialSupport(existingCipher);
|
ValidateClientVersionForFido2CredentialSupport(existingCipher);
|
||||||
|
|
||||||
if (existingCipher.ArchivedDate.HasValue)
|
|
||||||
{
|
|
||||||
throw new BadRequestException("Cannot move archived items to an organization.");
|
|
||||||
}
|
|
||||||
|
|
||||||
shareCiphers.Add((cipher.ToCipherDetails(existingCipher), cipher.LastKnownRevisionDate));
|
shareCiphers.Add((cipher.ToCipherDetails(existingCipher), cipher.LastKnownRevisionDate));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1420,11 +1407,9 @@ public class CiphersController : Controller
|
|||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract lastKnownRevisionDate from form data if present
|
|
||||||
DateTime? lastKnownRevisionDate = GetLastKnownRevisionDateFromForm();
|
|
||||||
await Request.GetFileAsync(async (stream) =>
|
await Request.GetFileAsync(async (stream) =>
|
||||||
{
|
{
|
||||||
await _cipherService.UploadFileForExistingAttachmentAsync(stream, cipher, attachmentData, lastKnownRevisionDate);
|
await _cipherService.UploadFileForExistingAttachmentAsync(stream, cipher, attachmentData);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1523,13 +1508,10 @@ public class CiphersController : Controller
|
|||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract lastKnownRevisionDate from form data if present
|
|
||||||
DateTime? lastKnownRevisionDate = GetLastKnownRevisionDateFromForm();
|
|
||||||
|
|
||||||
await Request.GetFileAsync(async (stream, fileName, key) =>
|
await Request.GetFileAsync(async (stream, fileName, key) =>
|
||||||
{
|
{
|
||||||
await _cipherService.CreateAttachmentShareAsync(cipher, stream, fileName, key,
|
await _cipherService.CreateAttachmentShareAsync(cipher, stream, fileName, key,
|
||||||
Request.ContentLength.GetValueOrDefault(0), attachmentId, organizationId, lastKnownRevisionDate);
|
Request.ContentLength.GetValueOrDefault(0), attachmentId, organizationId);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
"phishingDomain": {
|
"phishingDomain": {
|
||||||
"updateUrl": "https://phish.co.za/latest/phishing-domains-ACTIVE.txt",
|
"updateUrl": "https://phish.co.za/latest/phishing-domains-ACTIVE.txt",
|
||||||
"checksumUrl": "https://raw.githubusercontent.com/Phishing-Database/checksums/refs/heads/master/phishing-domains-ACTIVE.txt.sha256"
|
"checksumUrl": "https://raw.githubusercontent.com/Phishing-Database/checksums/refs/heads/master/phishing-domains-ACTIVE.txt.sha256"
|
||||||
}
|
},
|
||||||
|
"pricingUri": "https://billingpricing.qa.bitwarden.pw"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,9 +32,6 @@
|
|||||||
"send": {
|
"send": {
|
||||||
"connectionString": "SECRET"
|
"connectionString": "SECRET"
|
||||||
},
|
},
|
||||||
"sentry": {
|
|
||||||
"dsn": "SECRET"
|
|
||||||
},
|
|
||||||
"notificationHub": {
|
"notificationHub": {
|
||||||
"connectionString": "SECRET",
|
"connectionString": "SECRET",
|
||||||
"hubName": "SECRET"
|
"hubName": "SECRET"
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ public class FreshsalesController : Controller
|
|||||||
planName = "Free";
|
planName = "Free";
|
||||||
return true;
|
return true;
|
||||||
case PlanType.FamiliesAnnually:
|
case PlanType.FamiliesAnnually:
|
||||||
|
case PlanType.FamiliesAnnually2025:
|
||||||
case PlanType.FamiliesAnnually2019:
|
case PlanType.FamiliesAnnually2019:
|
||||||
planName = "Families";
|
planName = "Families";
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
36
src/Billing/Controllers/JobsController.cs
Normal file
36
src/Billing/Controllers/JobsController.cs
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
using Bit.Billing.Jobs;
|
||||||
|
using Bit.Core.Utilities;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Bit.Billing.Controllers;
|
||||||
|
|
||||||
|
[Route("jobs")]
|
||||||
|
[SelfHosted(NotSelfHostedOnly = true)]
|
||||||
|
[RequireLowerEnvironment]
|
||||||
|
public class JobsController(
|
||||||
|
JobsHostedService jobsHostedService) : Controller
|
||||||
|
{
|
||||||
|
[HttpPost("run/{jobName}")]
|
||||||
|
public async Task<IActionResult> RunJobAsync(string jobName)
|
||||||
|
{
|
||||||
|
if (jobName == nameof(ReconcileAdditionalStorageJob))
|
||||||
|
{
|
||||||
|
await jobsHostedService.RunJobAdHocAsync<ReconcileAdditionalStorageJob>();
|
||||||
|
return Ok(new { message = $"Job {jobName} scheduled successfully" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return BadRequest(new { error = $"Unknown job name: {jobName}" });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("stop/{jobName}")]
|
||||||
|
public async Task<IActionResult> StopJobAsync(string jobName)
|
||||||
|
{
|
||||||
|
if (jobName == nameof(ReconcileAdditionalStorageJob))
|
||||||
|
{
|
||||||
|
await jobsHostedService.InterruptAdHocJobAsync<ReconcileAdditionalStorageJob>();
|
||||||
|
return Ok(new { message = $"Job {jobName} queued for cancellation" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return BadRequest(new { error = $"Unknown job name: {jobName}" });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,4 +10,13 @@ public class AliveJob(ILogger<AliveJob> logger) : BaseJob(logger)
|
|||||||
_logger.LogInformation(Core.Constants.BypassFiltersEventId, null, "Billing service is alive!");
|
_logger.LogInformation(Core.Constants.BypassFiltersEventId, null, "Billing service is alive!");
|
||||||
return Task.FromResult(0);
|
return Task.FromResult(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static ITrigger GetTrigger()
|
||||||
|
{
|
||||||
|
return TriggerBuilder.Create()
|
||||||
|
.WithIdentity("EveryTopOfTheHourTrigger")
|
||||||
|
.StartNow()
|
||||||
|
.WithCronSchedule("0 0 * * * ?")
|
||||||
|
.Build();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,27 @@
|
|||||||
using Bit.Core.Jobs;
|
using Bit.Core.Exceptions;
|
||||||
|
using Bit.Core.Jobs;
|
||||||
using Bit.Core.Settings;
|
using Bit.Core.Settings;
|
||||||
using Quartz;
|
using Quartz;
|
||||||
|
|
||||||
namespace Bit.Billing.Jobs;
|
namespace Bit.Billing.Jobs;
|
||||||
|
|
||||||
public class JobsHostedService : BaseJobsHostedService
|
public class JobsHostedService(
|
||||||
|
GlobalSettings globalSettings,
|
||||||
|
IServiceProvider serviceProvider,
|
||||||
|
ILogger<JobsHostedService> logger,
|
||||||
|
ILogger<JobListener> listenerLogger,
|
||||||
|
ISchedulerFactory schedulerFactory)
|
||||||
|
: BaseJobsHostedService(globalSettings, serviceProvider, logger, listenerLogger)
|
||||||
{
|
{
|
||||||
public JobsHostedService(
|
private List<JobKey> AdHocJobKeys { get; } = [];
|
||||||
GlobalSettings globalSettings,
|
private IScheduler? _adHocScheduler;
|
||||||
IServiceProvider serviceProvider,
|
|
||||||
ILogger<JobsHostedService> logger,
|
|
||||||
ILogger<JobListener> listenerLogger)
|
|
||||||
: base(globalSettings, serviceProvider, logger, listenerLogger) { }
|
|
||||||
|
|
||||||
public override async Task StartAsync(CancellationToken cancellationToken)
|
public override async Task StartAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var everyTopOfTheHourTrigger = TriggerBuilder.Create()
|
|
||||||
.WithIdentity("EveryTopOfTheHourTrigger")
|
|
||||||
.StartNow()
|
|
||||||
.WithCronSchedule("0 0 * * * ?")
|
|
||||||
.Build();
|
|
||||||
|
|
||||||
Jobs = new List<Tuple<Type, ITrigger>>
|
Jobs = new List<Tuple<Type, ITrigger>>
|
||||||
{
|
{
|
||||||
new Tuple<Type, ITrigger>(typeof(AliveJob), everyTopOfTheHourTrigger)
|
new(typeof(AliveJob), AliveJob.GetTrigger()),
|
||||||
|
new(typeof(ReconcileAdditionalStorageJob), ReconcileAdditionalStorageJob.GetTrigger())
|
||||||
};
|
};
|
||||||
|
|
||||||
await base.StartAsync(cancellationToken);
|
await base.StartAsync(cancellationToken);
|
||||||
@@ -33,5 +31,54 @@ public class JobsHostedService : BaseJobsHostedService
|
|||||||
{
|
{
|
||||||
services.AddTransient<AliveJob>();
|
services.AddTransient<AliveJob>();
|
||||||
services.AddTransient<SubscriptionCancellationJob>();
|
services.AddTransient<SubscriptionCancellationJob>();
|
||||||
|
services.AddTransient<ReconcileAdditionalStorageJob>();
|
||||||
|
// add this service as a singleton so we can inject it where needed
|
||||||
|
services.AddSingleton<JobsHostedService>();
|
||||||
|
services.AddHostedService(sp => sp.GetRequiredService<JobsHostedService>());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InterruptAdHocJobAsync<T>(CancellationToken cancellationToken = default) where T : class, IJob
|
||||||
|
{
|
||||||
|
if (_adHocScheduler == null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("AdHocScheduler is null, cannot interrupt ad-hoc job.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var jobKey = AdHocJobKeys.FirstOrDefault(j => j.Name == typeof(T).ToString());
|
||||||
|
if (jobKey == null)
|
||||||
|
{
|
||||||
|
throw new NotFoundException($"Cannot find job key: {typeof(T)}, not running?");
|
||||||
|
}
|
||||||
|
logger.LogInformation("CANCELLING ad-hoc job with key: {JobKey}", jobKey);
|
||||||
|
AdHocJobKeys.Remove(jobKey);
|
||||||
|
await _adHocScheduler.Interrupt(jobKey, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RunJobAdHocAsync<T>(CancellationToken cancellationToken = default) where T : class, IJob
|
||||||
|
{
|
||||||
|
_adHocScheduler ??= await schedulerFactory.GetScheduler(cancellationToken);
|
||||||
|
|
||||||
|
var jobKey = new JobKey(typeof(T).ToString());
|
||||||
|
|
||||||
|
var currentlyExecuting = await _adHocScheduler.GetCurrentlyExecutingJobs(cancellationToken);
|
||||||
|
if (currentlyExecuting.Any(j => j.JobDetail.Key.Equals(jobKey)))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Job {jobKey} is already running");
|
||||||
|
}
|
||||||
|
|
||||||
|
AdHocJobKeys.Add(jobKey);
|
||||||
|
|
||||||
|
var job = JobBuilder.Create<T>()
|
||||||
|
.WithIdentity(jobKey)
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var trigger = TriggerBuilder.Create()
|
||||||
|
.WithIdentity(typeof(T).ToString())
|
||||||
|
.StartNow()
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
logger.LogInformation("Scheduling ad-hoc job with key: {JobKey}", jobKey);
|
||||||
|
|
||||||
|
await _adHocScheduler.ScheduleJob(job, trigger, cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
207
src/Billing/Jobs/ReconcileAdditionalStorageJob.cs
Normal file
207
src/Billing/Jobs/ReconcileAdditionalStorageJob.cs
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Bit.Billing.Services;
|
||||||
|
using Bit.Core;
|
||||||
|
using Bit.Core.Billing.Constants;
|
||||||
|
using Bit.Core.Jobs;
|
||||||
|
using Bit.Core.Services;
|
||||||
|
using Quartz;
|
||||||
|
using Stripe;
|
||||||
|
|
||||||
|
namespace Bit.Billing.Jobs;
|
||||||
|
|
||||||
|
public class ReconcileAdditionalStorageJob(
|
||||||
|
IStripeFacade stripeFacade,
|
||||||
|
ILogger<ReconcileAdditionalStorageJob> logger,
|
||||||
|
IFeatureService featureService) : BaseJob(logger)
|
||||||
|
{
|
||||||
|
private const string _storageGbMonthlyPriceId = "storage-gb-monthly";
|
||||||
|
private const string _storageGbAnnuallyPriceId = "storage-gb-annually";
|
||||||
|
private const string _personalStorageGbAnnuallyPriceId = "personal-storage-gb-annually";
|
||||||
|
private const int _storageGbToRemove = 4;
|
||||||
|
|
||||||
|
protected override async Task ExecuteJobAsync(IJobExecutionContext context)
|
||||||
|
{
|
||||||
|
if (!featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob))
|
||||||
|
{
|
||||||
|
logger.LogInformation("Skipping ReconcileAdditionalStorageJob, feature flag off.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var liveMode = featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode);
|
||||||
|
|
||||||
|
// Execution tracking
|
||||||
|
var subscriptionsFound = 0;
|
||||||
|
var subscriptionsUpdated = 0;
|
||||||
|
var subscriptionsWithErrors = 0;
|
||||||
|
var failures = new List<string>();
|
||||||
|
|
||||||
|
logger.LogInformation("Starting ReconcileAdditionalStorageJob (live mode: {LiveMode})", liveMode);
|
||||||
|
|
||||||
|
var priceIds = new[] { _storageGbMonthlyPriceId, _storageGbAnnuallyPriceId, _personalStorageGbAnnuallyPriceId };
|
||||||
|
|
||||||
|
foreach (var priceId in priceIds)
|
||||||
|
{
|
||||||
|
var options = new SubscriptionListOptions
|
||||||
|
{
|
||||||
|
Limit = 100,
|
||||||
|
Status = StripeConstants.SubscriptionStatus.Active,
|
||||||
|
Price = priceId
|
||||||
|
};
|
||||||
|
|
||||||
|
await foreach (var subscription in stripeFacade.ListSubscriptionsAutoPagingAsync(options))
|
||||||
|
{
|
||||||
|
if (context.CancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"Job cancelled!! Exiting. Progress at time of cancellation: Subscriptions found: {SubscriptionsFound}, " +
|
||||||
|
"Updated: {SubscriptionsUpdated}, Errors: {SubscriptionsWithErrors}{Failures}",
|
||||||
|
subscriptionsFound,
|
||||||
|
liveMode
|
||||||
|
? subscriptionsUpdated
|
||||||
|
: $"(In live mode, would have updated) {subscriptionsUpdated}",
|
||||||
|
subscriptionsWithErrors,
|
||||||
|
failures.Count > 0
|
||||||
|
? $", Failures: {Environment.NewLine}{string.Join(Environment.NewLine, failures)}"
|
||||||
|
: string.Empty
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subscription == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogInformation("Processing subscription: {SubscriptionId}", subscription.Id);
|
||||||
|
subscriptionsFound++;
|
||||||
|
|
||||||
|
if (subscription.Metadata?.TryGetValue(StripeConstants.MetadataKeys.StorageReconciled2025, out var dateString) == true)
|
||||||
|
{
|
||||||
|
if (DateTime.TryParse(dateString, null, DateTimeStyles.RoundtripKind, out var dateProcessed))
|
||||||
|
{
|
||||||
|
logger.LogInformation("Skipping subscription {SubscriptionId} - already processed on {Date}",
|
||||||
|
subscription.Id,
|
||||||
|
dateProcessed.ToString("f"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var updateOptions = BuildSubscriptionUpdateOptions(subscription, priceId);
|
||||||
|
|
||||||
|
if (updateOptions == null)
|
||||||
|
{
|
||||||
|
logger.LogInformation("Skipping subscription {SubscriptionId} - no updates needed", subscription.Id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
subscriptionsUpdated++;
|
||||||
|
|
||||||
|
if (!liveMode)
|
||||||
|
{
|
||||||
|
logger.LogInformation(
|
||||||
|
"Not live mode (dry-run): Would have updated subscription {SubscriptionId} with item changes: {NewLine}{UpdateOptions}",
|
||||||
|
subscription.Id,
|
||||||
|
Environment.NewLine,
|
||||||
|
JsonSerializer.Serialize(updateOptions));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await stripeFacade.UpdateSubscription(subscription.Id, updateOptions);
|
||||||
|
logger.LogInformation("Successfully updated subscription: {SubscriptionId}", subscription.Id);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
subscriptionsWithErrors++;
|
||||||
|
failures.Add($"Subscription {subscription.Id}: {ex.Message}");
|
||||||
|
logger.LogError(ex, "Failed to update subscription {SubscriptionId}: {ErrorMessage}",
|
||||||
|
subscription.Id, ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogInformation(
|
||||||
|
"ReconcileAdditionalStorageJob completed. Subscriptions found: {SubscriptionsFound}, " +
|
||||||
|
"Updated: {SubscriptionsUpdated}, Errors: {SubscriptionsWithErrors}{Failures}",
|
||||||
|
subscriptionsFound,
|
||||||
|
liveMode
|
||||||
|
? subscriptionsUpdated
|
||||||
|
: $"(In live mode, would have updated) {subscriptionsUpdated}",
|
||||||
|
subscriptionsWithErrors,
|
||||||
|
failures.Count > 0
|
||||||
|
? $", Failures: {Environment.NewLine}{string.Join(Environment.NewLine, failures)}"
|
||||||
|
: string.Empty
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private SubscriptionUpdateOptions? BuildSubscriptionUpdateOptions(
|
||||||
|
Subscription subscription,
|
||||||
|
string targetPriceId)
|
||||||
|
{
|
||||||
|
if (subscription.Items?.Data == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var updateOptions = new SubscriptionUpdateOptions
|
||||||
|
{
|
||||||
|
ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations,
|
||||||
|
Metadata = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
[StripeConstants.MetadataKeys.StorageReconciled2025] = DateTime.UtcNow.ToString("o")
|
||||||
|
},
|
||||||
|
Items = []
|
||||||
|
};
|
||||||
|
|
||||||
|
var hasUpdates = false;
|
||||||
|
|
||||||
|
foreach (var item in subscription.Items.Data.Where(item => item?.Price?.Id == targetPriceId))
|
||||||
|
{
|
||||||
|
hasUpdates = true;
|
||||||
|
var currentQuantity = item.Quantity;
|
||||||
|
|
||||||
|
if (currentQuantity > _storageGbToRemove)
|
||||||
|
{
|
||||||
|
var newQuantity = currentQuantity - _storageGbToRemove;
|
||||||
|
logger.LogInformation(
|
||||||
|
"Subscription {SubscriptionId}: reducing quantity from {CurrentQuantity} to {NewQuantity} for price {PriceId}",
|
||||||
|
subscription.Id,
|
||||||
|
currentQuantity,
|
||||||
|
newQuantity,
|
||||||
|
item.Price.Id);
|
||||||
|
|
||||||
|
updateOptions.Items.Add(new SubscriptionItemOptions
|
||||||
|
{
|
||||||
|
Id = item.Id,
|
||||||
|
Quantity = newQuantity
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
logger.LogInformation("Subscription {SubscriptionId}: deleting storage item with quantity {CurrentQuantity} for price {PriceId}",
|
||||||
|
subscription.Id,
|
||||||
|
currentQuantity,
|
||||||
|
item.Price.Id);
|
||||||
|
|
||||||
|
updateOptions.Items.Add(new SubscriptionItemOptions
|
||||||
|
{
|
||||||
|
Id = item.Id,
|
||||||
|
Deleted = true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasUpdates ? updateOptions : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ITrigger GetTrigger()
|
||||||
|
{
|
||||||
|
return TriggerBuilder.Create()
|
||||||
|
.WithIdentity("EveryMorningTrigger")
|
||||||
|
.StartNow()
|
||||||
|
.WithCronSchedule("0 0 16 * * ?") // 10am CST daily; the pods execute in UTC time
|
||||||
|
.Build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,25 +11,8 @@ public class Program
|
|||||||
.ConfigureWebHostDefaults(webBuilder =>
|
.ConfigureWebHostDefaults(webBuilder =>
|
||||||
{
|
{
|
||||||
webBuilder.UseStartup<Startup>();
|
webBuilder.UseStartup<Startup>();
|
||||||
webBuilder.ConfigureLogging((hostingContext, logging) =>
|
|
||||||
logging.AddSerilog(hostingContext, (e, globalSettings) =>
|
|
||||||
{
|
|
||||||
var context = e.Properties["SourceContext"].ToString();
|
|
||||||
if (context.StartsWith("\"Bit.Billing.Jobs") || context.StartsWith("\"Bit.Core.Jobs"))
|
|
||||||
{
|
|
||||||
return e.Level >= globalSettings.MinLogLevel.BillingSettings.Jobs;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e.Properties.TryGetValue("RequestPath", out var requestPath) &&
|
|
||||||
!string.IsNullOrWhiteSpace(requestPath?.ToString()) &&
|
|
||||||
(context.Contains(".Server.Kestrel") || context.Contains(".Core.IISHttpServer")))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return e.Level >= globalSettings.MinLogLevel.BillingSettings.Default;
|
|
||||||
}));
|
|
||||||
})
|
})
|
||||||
|
.AddSerilogFileLogging()
|
||||||
.Build()
|
.Build()
|
||||||
.Run();
|
.Run();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,11 @@ public interface IStripeFacade
|
|||||||
RequestOptions requestOptions = null,
|
RequestOptions requestOptions = null,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
IAsyncEnumerable<Subscription> ListSubscriptionsAutoPagingAsync(
|
||||||
|
SubscriptionListOptions options = null,
|
||||||
|
RequestOptions requestOptions = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task<Subscription> GetSubscription(
|
Task<Subscription> GetSubscription(
|
||||||
string subscriptionId,
|
string subscriptionId,
|
||||||
SubscriptionGetOptions subscriptionGetOptions = null,
|
SubscriptionGetOptions subscriptionGetOptions = null,
|
||||||
@@ -111,4 +116,10 @@ public interface IStripeFacade
|
|||||||
TestClockGetOptions testClockGetOptions = null,
|
TestClockGetOptions testClockGetOptions = null,
|
||||||
RequestOptions requestOptions = null,
|
RequestOptions requestOptions = null,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<Coupon> GetCoupon(
|
||||||
|
string couponId,
|
||||||
|
CouponGetOptions couponGetOptions = null,
|
||||||
|
RequestOptions requestOptions = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,12 @@
|
|||||||
|
|
||||||
using Bit.Billing.Constants;
|
using Bit.Billing.Constants;
|
||||||
using Bit.Core.Billing.Constants;
|
using Bit.Core.Billing.Constants;
|
||||||
|
using Bit.Core.Billing.Models;
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.Exceptions;
|
using Bit.Core.Exceptions;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
using Bit.Core.Services;
|
using Bit.Core.Services;
|
||||||
using Bit.Core.Settings;
|
using Bit.Core.Settings;
|
||||||
using Bit.Core.Utilities;
|
|
||||||
using Braintree;
|
using Braintree;
|
||||||
using Stripe;
|
using Stripe;
|
||||||
using Customer = Stripe.Customer;
|
using Customer = Stripe.Customer;
|
||||||
@@ -112,7 +112,7 @@ public class StripeEventUtilityService : IStripeEventUtilityService
|
|||||||
}
|
}
|
||||||
|
|
||||||
public bool IsSponsoredSubscription(Subscription subscription) =>
|
public bool IsSponsoredSubscription(Subscription subscription) =>
|
||||||
StaticStore.SponsoredPlans
|
SponsoredPlans.All
|
||||||
.Any(p => subscription.Items
|
.Any(p => subscription.Items
|
||||||
.Any(i => i.Plan.Id == p.StripePlanId));
|
.Any(i => i.Plan.Id == p.StripePlanId));
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ public class StripeFacade : IStripeFacade
|
|||||||
private readonly DiscountService _discountService = new();
|
private readonly DiscountService _discountService = new();
|
||||||
private readonly SetupIntentService _setupIntentService = new();
|
private readonly SetupIntentService _setupIntentService = new();
|
||||||
private readonly TestClockService _testClockService = new();
|
private readonly TestClockService _testClockService = new();
|
||||||
|
private readonly CouponService _couponService = new();
|
||||||
|
|
||||||
public async Task<Charge> GetCharge(
|
public async Task<Charge> GetCharge(
|
||||||
string chargeId,
|
string chargeId,
|
||||||
@@ -98,6 +99,12 @@ public class StripeFacade : IStripeFacade
|
|||||||
CancellationToken cancellationToken = default) =>
|
CancellationToken cancellationToken = default) =>
|
||||||
await _subscriptionService.ListAsync(options, requestOptions, cancellationToken);
|
await _subscriptionService.ListAsync(options, requestOptions, cancellationToken);
|
||||||
|
|
||||||
|
public IAsyncEnumerable<Subscription> ListSubscriptionsAutoPagingAsync(
|
||||||
|
SubscriptionListOptions options = null,
|
||||||
|
RequestOptions requestOptions = null,
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
_subscriptionService.ListAutoPagingAsync(options, requestOptions, cancellationToken);
|
||||||
|
|
||||||
public async Task<Subscription> GetSubscription(
|
public async Task<Subscription> GetSubscription(
|
||||||
string subscriptionId,
|
string subscriptionId,
|
||||||
SubscriptionGetOptions subscriptionGetOptions = null,
|
SubscriptionGetOptions subscriptionGetOptions = null,
|
||||||
@@ -137,4 +144,11 @@ public class StripeFacade : IStripeFacade
|
|||||||
RequestOptions requestOptions = null,
|
RequestOptions requestOptions = null,
|
||||||
CancellationToken cancellationToken = default) =>
|
CancellationToken cancellationToken = default) =>
|
||||||
_testClockService.GetAsync(testClockId, testClockGetOptions, requestOptions, cancellationToken);
|
_testClockService.GetAsync(testClockId, testClockGetOptions, requestOptions, cancellationToken);
|
||||||
|
|
||||||
|
public Task<Coupon> GetCoupon(
|
||||||
|
string couponId,
|
||||||
|
CouponGetOptions couponGetOptions = null,
|
||||||
|
RequestOptions requestOptions = null,
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
_couponService.GetAsync(couponId, couponGetOptions, requestOptions, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
using System.Globalization;
|
using Bit.Billing.Constants;
|
||||||
using Bit.Billing.Constants;
|
|
||||||
using Bit.Billing.Jobs;
|
using Bit.Billing.Jobs;
|
||||||
using Bit.Core;
|
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
|
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
|
||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
using Bit.Core.AdminConsole.Services;
|
using Bit.Core.AdminConsole.Services;
|
||||||
@@ -134,11 +132,6 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
|
|||||||
}
|
}
|
||||||
case StripeSubscriptionStatus.Active when providerId.HasValue:
|
case StripeSubscriptionStatus.Active when providerId.HasValue:
|
||||||
{
|
{
|
||||||
var providerPortalTakeover = _featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover);
|
|
||||||
if (!providerPortalTakeover)
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
var provider = await _providerRepository.GetByIdAsync(providerId.Value);
|
var provider = await _providerRepository.GetByIdAsync(providerId.Value);
|
||||||
if (provider != null)
|
if (provider != null)
|
||||||
{
|
{
|
||||||
@@ -321,13 +314,6 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
|
|||||||
Event parsedEvent,
|
Event parsedEvent,
|
||||||
Subscription currentSubscription)
|
Subscription currentSubscription)
|
||||||
{
|
{
|
||||||
var providerPortalTakeover = _featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover);
|
|
||||||
|
|
||||||
if (!providerPortalTakeover)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var provider = await _providerRepository.GetByIdAsync(providerId);
|
var provider = await _providerRepository.GetByIdAsync(providerId);
|
||||||
if (provider == null)
|
if (provider == null)
|
||||||
{
|
{
|
||||||
@@ -343,22 +329,17 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
|
|||||||
{
|
{
|
||||||
var previousSubscription = parsedEvent.Data.PreviousAttributes.ToObject<Subscription>() as Subscription;
|
var previousSubscription = parsedEvent.Data.PreviousAttributes.ToObject<Subscription>() as Subscription;
|
||||||
|
|
||||||
var updateIsSubscriptionGoingUnpaid = previousSubscription is
|
if (previousSubscription is
|
||||||
{
|
{
|
||||||
Status:
|
Status:
|
||||||
StripeSubscriptionStatus.Trialing or
|
StripeSubscriptionStatus.Trialing or
|
||||||
StripeSubscriptionStatus.Active or
|
StripeSubscriptionStatus.Active or
|
||||||
StripeSubscriptionStatus.PastDue
|
StripeSubscriptionStatus.PastDue
|
||||||
} && currentSubscription is
|
} && currentSubscription is
|
||||||
{
|
{
|
||||||
Status: StripeSubscriptionStatus.Unpaid,
|
Status: StripeSubscriptionStatus.Unpaid,
|
||||||
LatestInvoice.BillingReason: "subscription_cycle" or "subscription_create"
|
LatestInvoice.BillingReason: "subscription_cycle" or "subscription_create"
|
||||||
};
|
})
|
||||||
|
|
||||||
var updateIsManualSuspensionViaMetadata = CheckForManualSuspensionViaMetadata(
|
|
||||||
previousSubscription, currentSubscription);
|
|
||||||
|
|
||||||
if (updateIsSubscriptionGoingUnpaid || updateIsManualSuspensionViaMetadata)
|
|
||||||
{
|
{
|
||||||
if (currentSubscription.TestClock != null)
|
if (currentSubscription.TestClock != null)
|
||||||
{
|
{
|
||||||
@@ -369,14 +350,6 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
|
|||||||
|
|
||||||
var subscriptionUpdateOptions = new SubscriptionUpdateOptions { CancelAt = now.AddDays(7) };
|
var subscriptionUpdateOptions = new SubscriptionUpdateOptions { CancelAt = now.AddDays(7) };
|
||||||
|
|
||||||
if (updateIsManualSuspensionViaMetadata)
|
|
||||||
{
|
|
||||||
subscriptionUpdateOptions.Metadata = new Dictionary<string, string>
|
|
||||||
{
|
|
||||||
["suspended_provider_via_webhook_at"] = DateTime.UtcNow.ToString(CultureInfo.InvariantCulture)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
await _stripeFacade.UpdateSubscription(currentSubscription.Id, subscriptionUpdateOptions);
|
await _stripeFacade.UpdateSubscription(currentSubscription.Id, subscriptionUpdateOptions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -399,37 +372,4 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool CheckForManualSuspensionViaMetadata(
|
|
||||||
Subscription? previousSubscription,
|
|
||||||
Subscription currentSubscription)
|
|
||||||
{
|
|
||||||
/*
|
|
||||||
* When metadata on a subscription is updated, we'll receive an event that has:
|
|
||||||
* Previous Metadata: { newlyAddedKey: null }
|
|
||||||
* Current Metadata: { newlyAddedKey: newlyAddedValue }
|
|
||||||
*
|
|
||||||
* As such, our check for a manual suspension must ensure that the 'previous_attributes' does contain the
|
|
||||||
* 'metadata' property, but also that the "suspend_provider" key in that metadata is set to null.
|
|
||||||
*
|
|
||||||
* If we don't do this and instead do a null coalescing check on 'previous_attributes?.metadata?.TryGetValue',
|
|
||||||
* we'll end up marking an event where 'previous_attributes.metadata' = null (which could be any subscription update
|
|
||||||
* that does not update the metadata) the same as a manual suspension.
|
|
||||||
*/
|
|
||||||
const string key = "suspend_provider";
|
|
||||||
|
|
||||||
if (previousSubscription is not { Metadata: not null } ||
|
|
||||||
!previousSubscription.Metadata.TryGetValue(key, out var previousValue))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (previousValue == null)
|
|
||||||
{
|
|
||||||
return !string.IsNullOrEmpty(
|
|
||||||
currentSubscription.Metadata.TryGetValue(key, out var currentValue) ? currentValue : null);
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
// FIXME: Update this file to be null safe and then delete the line below
|
using System.Globalization;
|
||||||
|
using Bit.Core;
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
using Bit.Core.AdminConsole.Entities;
|
using Bit.Core.AdminConsole.Entities;
|
||||||
using Bit.Core.AdminConsole.Entities.Provider;
|
using Bit.Core.AdminConsole.Entities.Provider;
|
||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
@@ -10,14 +8,23 @@ using Bit.Core.Billing.Enums;
|
|||||||
using Bit.Core.Billing.Extensions;
|
using Bit.Core.Billing.Extensions;
|
||||||
using Bit.Core.Billing.Payment.Queries;
|
using Bit.Core.Billing.Payment.Queries;
|
||||||
using Bit.Core.Billing.Pricing;
|
using Bit.Core.Billing.Pricing;
|
||||||
|
using Bit.Core.Entities;
|
||||||
|
using Bit.Core.Models.Mail.Billing.Renewal.Families2019Renewal;
|
||||||
|
using Bit.Core.Models.Mail.Billing.Renewal.Families2020Renewal;
|
||||||
|
using Bit.Core.Models.Mail.Billing.Renewal.Premium;
|
||||||
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
|
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
|
||||||
|
using Bit.Core.Platform.Mail.Mailer;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
using Bit.Core.Services;
|
using Bit.Core.Services;
|
||||||
using Stripe;
|
using Stripe;
|
||||||
using Event = Stripe.Event;
|
using Event = Stripe.Event;
|
||||||
|
using Plan = Bit.Core.Models.StaticStore.Plan;
|
||||||
|
using PremiumPlan = Bit.Core.Billing.Pricing.Premium.Plan;
|
||||||
|
|
||||||
namespace Bit.Billing.Services.Implementations;
|
namespace Bit.Billing.Services.Implementations;
|
||||||
|
|
||||||
|
using static StripeConstants;
|
||||||
|
|
||||||
public class UpcomingInvoiceHandler(
|
public class UpcomingInvoiceHandler(
|
||||||
IGetPaymentMethodQuery getPaymentMethodQuery,
|
IGetPaymentMethodQuery getPaymentMethodQuery,
|
||||||
ILogger<StripeEventProcessor> logger,
|
ILogger<StripeEventProcessor> logger,
|
||||||
@@ -29,7 +36,9 @@ public class UpcomingInvoiceHandler(
|
|||||||
IStripeEventService stripeEventService,
|
IStripeEventService stripeEventService,
|
||||||
IStripeEventUtilityService stripeEventUtilityService,
|
IStripeEventUtilityService stripeEventUtilityService,
|
||||||
IUserRepository userRepository,
|
IUserRepository userRepository,
|
||||||
IValidateSponsorshipCommand validateSponsorshipCommand)
|
IValidateSponsorshipCommand validateSponsorshipCommand,
|
||||||
|
IMailer mailer,
|
||||||
|
IFeatureService featureService)
|
||||||
: IUpcomingInvoiceHandler
|
: IUpcomingInvoiceHandler
|
||||||
{
|
{
|
||||||
public async Task HandleAsync(Event parsedEvent)
|
public async Task HandleAsync(Event parsedEvent)
|
||||||
@@ -37,7 +46,8 @@ public class UpcomingInvoiceHandler(
|
|||||||
var invoice = await stripeEventService.GetInvoice(parsedEvent);
|
var invoice = await stripeEventService.GetInvoice(parsedEvent);
|
||||||
|
|
||||||
var customer =
|
var customer =
|
||||||
await stripeFacade.GetCustomer(invoice.CustomerId, new CustomerGetOptions { Expand = ["subscriptions", "tax", "tax_ids"] });
|
await stripeFacade.GetCustomer(invoice.CustomerId,
|
||||||
|
new CustomerGetOptions { Expand = ["subscriptions", "tax", "tax_ids"] });
|
||||||
|
|
||||||
var subscription = customer.Subscriptions.FirstOrDefault();
|
var subscription = customer.Subscriptions.FirstOrDefault();
|
||||||
|
|
||||||
@@ -50,116 +60,436 @@ public class UpcomingInvoiceHandler(
|
|||||||
|
|
||||||
if (organizationId.HasValue)
|
if (organizationId.HasValue)
|
||||||
{
|
{
|
||||||
var organization = await organizationRepository.GetByIdAsync(organizationId.Value);
|
await HandleOrganizationUpcomingInvoiceAsync(
|
||||||
|
organizationId.Value,
|
||||||
if (organization == null)
|
parsedEvent,
|
||||||
{
|
invoice,
|
||||||
return;
|
customer,
|
||||||
}
|
subscription);
|
||||||
|
|
||||||
await AlignOrganizationTaxConcernsAsync(organization, subscription, customer, parsedEvent.Id);
|
|
||||||
|
|
||||||
var plan = await pricingClient.GetPlanOrThrow(organization.PlanType);
|
|
||||||
|
|
||||||
if (!plan.IsAnnual)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stripeEventUtilityService.IsSponsoredSubscription(subscription))
|
|
||||||
{
|
|
||||||
var sponsorshipIsValid = await validateSponsorshipCommand.ValidateSponsorshipAsync(organizationId.Value);
|
|
||||||
|
|
||||||
if (!sponsorshipIsValid)
|
|
||||||
{
|
|
||||||
/*
|
|
||||||
* If the sponsorship is invalid, then the subscription was updated to use the regular families plan
|
|
||||||
* price. Given that this is the case, we need the new invoice amount
|
|
||||||
*/
|
|
||||||
invoice = await stripeFacade.GetInvoice(subscription.LatestInvoiceId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await SendUpcomingInvoiceEmailsAsync(new List<string> { organization.BillingEmail }, invoice);
|
|
||||||
|
|
||||||
/*
|
|
||||||
* TODO: https://bitwarden.atlassian.net/browse/PM-4862
|
|
||||||
* Disabling this as part of a hot fix. It needs to check whether the organization
|
|
||||||
* belongs to a Reseller provider and only send an email to the organization owners if it does.
|
|
||||||
* It also requires a new email template as the current one contains too much billing information.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// var ownerEmails = await _organizationRepository.GetOwnerEmailAddressesById(organization.Id);
|
|
||||||
|
|
||||||
// await SendEmails(ownerEmails);
|
|
||||||
}
|
}
|
||||||
else if (userId.HasValue)
|
else if (userId.HasValue)
|
||||||
{
|
{
|
||||||
var user = await userRepository.GetByIdAsync(userId.Value);
|
await HandlePremiumUsersUpcomingInvoiceAsync(
|
||||||
|
userId.Value,
|
||||||
if (user == null)
|
parsedEvent,
|
||||||
{
|
invoice,
|
||||||
return;
|
customer,
|
||||||
}
|
subscription);
|
||||||
|
|
||||||
if (!subscription.AutomaticTax.Enabled && subscription.Customer.HasRecognizedTaxLocation())
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await stripeFacade.UpdateSubscription(subscription.Id,
|
|
||||||
new SubscriptionUpdateOptions
|
|
||||||
{
|
|
||||||
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
logger.LogError(
|
|
||||||
exception,
|
|
||||||
"Failed to set user's ({UserID}) subscription to automatic tax while processing event with ID {EventID}",
|
|
||||||
user.Id,
|
|
||||||
parsedEvent.Id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.Premium)
|
|
||||||
{
|
|
||||||
await SendUpcomingInvoiceEmailsAsync(new List<string> { user.Email }, invoice);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else if (providerId.HasValue)
|
else if (providerId.HasValue)
|
||||||
{
|
{
|
||||||
var provider = await providerRepository.GetByIdAsync(providerId.Value);
|
await HandleProviderUpcomingInvoiceAsync(
|
||||||
|
providerId.Value,
|
||||||
|
parsedEvent,
|
||||||
|
invoice,
|
||||||
|
customer,
|
||||||
|
subscription);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (provider == null)
|
#region Organizations
|
||||||
|
|
||||||
|
private async Task HandleOrganizationUpcomingInvoiceAsync(
|
||||||
|
Guid organizationId,
|
||||||
|
Event @event,
|
||||||
|
Invoice invoice,
|
||||||
|
Customer customer,
|
||||||
|
Subscription subscription)
|
||||||
|
{
|
||||||
|
var organization = await organizationRepository.GetByIdAsync(organizationId);
|
||||||
|
|
||||||
|
if (organization == null)
|
||||||
|
{
|
||||||
|
logger.LogWarning("Could not find Organization ({OrganizationID}) for '{EventType}' event ({EventID})",
|
||||||
|
organizationId, @event.Type, @event.Id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await AlignOrganizationTaxConcernsAsync(organization, subscription, customer, @event.Id);
|
||||||
|
|
||||||
|
var plan = await pricingClient.GetPlanOrThrow(organization.PlanType);
|
||||||
|
|
||||||
|
var milestone3 = featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3);
|
||||||
|
|
||||||
|
var subscriptionAligned = await AlignOrganizationSubscriptionConcernsAsync(
|
||||||
|
organization,
|
||||||
|
@event,
|
||||||
|
subscription,
|
||||||
|
plan,
|
||||||
|
milestone3);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Subscription alignment sends out a different version of our Upcoming Invoice email, so we don't need to continue
|
||||||
|
* with processing.
|
||||||
|
*/
|
||||||
|
if (subscriptionAligned)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't send the upcoming invoice email unless the organization's on an annual plan.
|
||||||
|
if (!plan.IsAnnual)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stripeEventUtilityService.IsSponsoredSubscription(subscription))
|
||||||
|
{
|
||||||
|
var sponsorshipIsValid =
|
||||||
|
await validateSponsorshipCommand.ValidateSponsorshipAsync(organizationId);
|
||||||
|
|
||||||
|
if (!sponsorshipIsValid)
|
||||||
|
{
|
||||||
|
/*
|
||||||
|
* If the sponsorship is invalid, then the subscription was updated to use the regular families plan
|
||||||
|
* price. Given that this is the case, we need the new invoice amount
|
||||||
|
*/
|
||||||
|
invoice = await stripeFacade.GetInvoice(subscription.LatestInvoiceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await SendUpcomingInvoiceEmailsAsync([organization.BillingEmail], invoice);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AlignOrganizationTaxConcernsAsync(
|
||||||
|
Organization organization,
|
||||||
|
Subscription subscription,
|
||||||
|
Customer customer,
|
||||||
|
string eventId)
|
||||||
|
{
|
||||||
|
var nonUSBusinessUse =
|
||||||
|
organization.PlanType.GetProductTier() != ProductTierType.Families &&
|
||||||
|
customer.Address.Country != Core.Constants.CountryAbbreviations.UnitedStates;
|
||||||
|
|
||||||
|
if (nonUSBusinessUse && customer.TaxExempt != TaxExempt.Reverse)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await stripeFacade.UpdateCustomer(subscription.CustomerId,
|
||||||
|
new CustomerUpdateOptions { TaxExempt = TaxExempt.Reverse });
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
logger.LogError(
|
||||||
|
exception,
|
||||||
|
"Failed to set organization's ({OrganizationID}) to reverse tax exemption while processing event with ID {EventID}",
|
||||||
|
organization.Id,
|
||||||
|
eventId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!subscription.AutomaticTax.Enabled)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await stripeFacade.UpdateSubscription(subscription.Id,
|
||||||
|
new SubscriptionUpdateOptions
|
||||||
|
{
|
||||||
|
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
logger.LogError(
|
||||||
|
exception,
|
||||||
|
"Failed to set organization's ({OrganizationID}) subscription to automatic tax while processing event with ID {EventID}",
|
||||||
|
organization.Id,
|
||||||
|
eventId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Aligns the organization's subscription details with the specified plan and milestone requirements.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="organization">The organization whose subscription is being updated.</param>
|
||||||
|
/// <param name="event">The Stripe event associated with this operation.</param>
|
||||||
|
/// <param name="subscription">The organization's subscription.</param>
|
||||||
|
/// <param name="plan">The organization's current plan.</param>
|
||||||
|
/// <param name="milestone3">A flag indicating whether the third milestone is enabled.</param>
|
||||||
|
/// <returns>Whether the operation resulted in an updated subscription.</returns>
|
||||||
|
private async Task<bool> AlignOrganizationSubscriptionConcernsAsync(
|
||||||
|
Organization organization,
|
||||||
|
Event @event,
|
||||||
|
Subscription subscription,
|
||||||
|
Plan plan,
|
||||||
|
bool milestone3)
|
||||||
|
{
|
||||||
|
// currently these are the only plans that need aligned and both require the same flag and share most of the logic
|
||||||
|
if (!milestone3 || plan.Type is not (PlanType.FamiliesAnnually2019 or PlanType.FamiliesAnnually2025))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var passwordManagerItem =
|
||||||
|
subscription.Items.FirstOrDefault(item => item.Price.Id == plan.PasswordManager.StripePlanId);
|
||||||
|
|
||||||
|
if (passwordManagerItem == null)
|
||||||
|
{
|
||||||
|
logger.LogWarning("Could not find Organization's ({OrganizationId}) password manager item while processing '{EventType}' event ({EventID})",
|
||||||
|
organization.Id, @event.Type, @event.Id);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var familiesPlan = await pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually);
|
||||||
|
|
||||||
|
organization.PlanType = familiesPlan.Type;
|
||||||
|
organization.Plan = familiesPlan.Name;
|
||||||
|
organization.UsersGetPremium = familiesPlan.UsersGetPremium;
|
||||||
|
organization.Seats = familiesPlan.PasswordManager.BaseSeats;
|
||||||
|
|
||||||
|
var options = new SubscriptionUpdateOptions
|
||||||
|
{
|
||||||
|
Items =
|
||||||
|
[
|
||||||
|
new SubscriptionItemOptions
|
||||||
|
{
|
||||||
|
Id = passwordManagerItem.Id,
|
||||||
|
Price = familiesPlan.PasswordManager.StripePlanId
|
||||||
|
}
|
||||||
|
],
|
||||||
|
ProrationBehavior = ProrationBehavior.None
|
||||||
|
};
|
||||||
|
|
||||||
|
if (plan.Type == PlanType.FamiliesAnnually2019)
|
||||||
|
{
|
||||||
|
options.Discounts =
|
||||||
|
[
|
||||||
|
new SubscriptionDiscountOptions { Coupon = CouponIDs.Milestone3SubscriptionDiscount }
|
||||||
|
];
|
||||||
|
|
||||||
|
var premiumAccessAddOnItem = subscription.Items.FirstOrDefault(item =>
|
||||||
|
item.Price.Id == plan.PasswordManager.StripePremiumAccessPlanId);
|
||||||
|
|
||||||
|
if (premiumAccessAddOnItem != null)
|
||||||
|
{
|
||||||
|
options.Items.Add(new SubscriptionItemOptions
|
||||||
|
{
|
||||||
|
Id = premiumAccessAddOnItem.Id,
|
||||||
|
Deleted = true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var seatAddOnItem = subscription.Items.FirstOrDefault(item => item.Price.Id == "personal-org-seat-annually");
|
||||||
|
|
||||||
|
if (seatAddOnItem != null)
|
||||||
|
{
|
||||||
|
options.Items.Add(new SubscriptionItemOptions
|
||||||
|
{
|
||||||
|
Id = seatAddOnItem.Id,
|
||||||
|
Deleted = true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await organizationRepository.ReplaceAsync(organization);
|
||||||
|
await stripeFacade.UpdateSubscription(subscription.Id, options);
|
||||||
|
await SendFamiliesRenewalEmailAsync(organization, familiesPlan, plan);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
logger.LogError(
|
||||||
|
exception,
|
||||||
|
"Failed to align subscription concerns for Organization ({OrganizationID}) while processing '{EventType}' event ({EventID})",
|
||||||
|
organization.Id,
|
||||||
|
@event.Type,
|
||||||
|
@event.Id);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Premium Users
|
||||||
|
|
||||||
|
private async Task HandlePremiumUsersUpcomingInvoiceAsync(
|
||||||
|
Guid userId,
|
||||||
|
Event @event,
|
||||||
|
Invoice invoice,
|
||||||
|
Customer customer,
|
||||||
|
Subscription subscription)
|
||||||
|
{
|
||||||
|
var user = await userRepository.GetByIdAsync(userId);
|
||||||
|
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
logger.LogWarning("Could not find User ({UserID}) for '{EventType}' event ({EventID})",
|
||||||
|
userId, @event.Type, @event.Id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await AlignPremiumUsersTaxConcernsAsync(user, @event, customer, subscription);
|
||||||
|
|
||||||
|
var milestone2Feature = featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2);
|
||||||
|
if (milestone2Feature)
|
||||||
|
{
|
||||||
|
var subscriptionAligned = await AlignPremiumUsersSubscriptionConcernsAsync(user, @event, subscription);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Subscription alignment sends out a different version of our Upcoming Invoice email, so we don't need to continue
|
||||||
|
* with processing.
|
||||||
|
*/
|
||||||
|
if (subscriptionAligned)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await AlignProviderTaxConcernsAsync(provider, subscription, customer, parsedEvent.Id);
|
|
||||||
|
|
||||||
await SendProviderUpcomingInvoiceEmailsAsync(new List<string> { provider.BillingEmail }, invoice, subscription, providerId.Value);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SendUpcomingInvoiceEmailsAsync(IEnumerable<string> emails, Invoice invoice)
|
if (user.Premium)
|
||||||
{
|
|
||||||
var validEmails = emails.Where(e => !string.IsNullOrEmpty(e));
|
|
||||||
|
|
||||||
var items = invoice.Lines.Select(i => i.Description).ToList();
|
|
||||||
|
|
||||||
if (invoice.NextPaymentAttempt.HasValue && invoice.AmountDue > 0)
|
|
||||||
{
|
{
|
||||||
await mailService.SendInvoiceUpcoming(
|
await SendUpcomingInvoiceEmailsAsync(new List<string> { user.Email }, invoice);
|
||||||
validEmails,
|
|
||||||
invoice.AmountDue / 100M,
|
|
||||||
invoice.NextPaymentAttempt.Value,
|
|
||||||
items,
|
|
||||||
true);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SendProviderUpcomingInvoiceEmailsAsync(IEnumerable<string> emails, Invoice invoice, Subscription subscription, Guid providerId)
|
private async Task AlignPremiumUsersTaxConcernsAsync(
|
||||||
|
User user,
|
||||||
|
Event @event,
|
||||||
|
Customer customer,
|
||||||
|
Subscription subscription)
|
||||||
|
{
|
||||||
|
if (!subscription.AutomaticTax.Enabled && customer.HasRecognizedTaxLocation())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await stripeFacade.UpdateSubscription(subscription.Id,
|
||||||
|
new SubscriptionUpdateOptions
|
||||||
|
{
|
||||||
|
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
logger.LogError(
|
||||||
|
exception,
|
||||||
|
"Failed to set user's ({UserID}) subscription to automatic tax while processing event with ID {EventID}",
|
||||||
|
user.Id,
|
||||||
|
@event.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> AlignPremiumUsersSubscriptionConcernsAsync(
|
||||||
|
User user,
|
||||||
|
Event @event,
|
||||||
|
Subscription subscription)
|
||||||
|
{
|
||||||
|
var premiumItem = subscription.Items.FirstOrDefault(i => i.Price.Id == Prices.PremiumAnnually);
|
||||||
|
|
||||||
|
if (premiumItem == null)
|
||||||
|
{
|
||||||
|
logger.LogWarning("Could not find User's ({UserID}) premium subscription item while processing '{EventType}' event ({EventID})",
|
||||||
|
user.Id, @event.Type, @event.Id);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var plan = await pricingClient.GetAvailablePremiumPlan();
|
||||||
|
await stripeFacade.UpdateSubscription(subscription.Id,
|
||||||
|
new SubscriptionUpdateOptions
|
||||||
|
{
|
||||||
|
Items =
|
||||||
|
[
|
||||||
|
new SubscriptionItemOptions { Id = premiumItem.Id, Price = plan.Seat.StripePriceId }
|
||||||
|
],
|
||||||
|
Discounts =
|
||||||
|
[
|
||||||
|
new SubscriptionDiscountOptions { Coupon = CouponIDs.Milestone2SubscriptionDiscount }
|
||||||
|
],
|
||||||
|
ProrationBehavior = ProrationBehavior.None
|
||||||
|
});
|
||||||
|
await SendPremiumRenewalEmailAsync(user, plan);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
logger.LogError(
|
||||||
|
exception,
|
||||||
|
"Failed to update user's ({UserID}) subscription price id while processing event with ID {EventID}",
|
||||||
|
user.Id,
|
||||||
|
@event.Id);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Providers
|
||||||
|
|
||||||
|
private async Task HandleProviderUpcomingInvoiceAsync(
|
||||||
|
Guid providerId,
|
||||||
|
Event @event,
|
||||||
|
Invoice invoice,
|
||||||
|
Customer customer,
|
||||||
|
Subscription subscription)
|
||||||
|
{
|
||||||
|
var provider = await providerRepository.GetByIdAsync(providerId);
|
||||||
|
|
||||||
|
if (provider == null)
|
||||||
|
{
|
||||||
|
logger.LogWarning("Could not find Provider ({ProviderID}) for '{EventType}' event ({EventID})",
|
||||||
|
providerId, @event.Type, @event.Id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await AlignProviderTaxConcernsAsync(provider, subscription, customer, @event.Id);
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(provider.BillingEmail))
|
||||||
|
{
|
||||||
|
await SendProviderUpcomingInvoiceEmailsAsync(new List<string> { provider.BillingEmail }, invoice, subscription, providerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AlignProviderTaxConcernsAsync(
|
||||||
|
Provider provider,
|
||||||
|
Subscription subscription,
|
||||||
|
Customer customer,
|
||||||
|
string eventId)
|
||||||
|
{
|
||||||
|
if (customer.Address.Country != Core.Constants.CountryAbbreviations.UnitedStates &&
|
||||||
|
customer.TaxExempt != TaxExempt.Reverse)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await stripeFacade.UpdateCustomer(subscription.CustomerId,
|
||||||
|
new CustomerUpdateOptions { TaxExempt = TaxExempt.Reverse });
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
logger.LogError(
|
||||||
|
exception,
|
||||||
|
"Failed to set provider's ({ProviderID}) to reverse tax exemption while processing event with ID {EventID}",
|
||||||
|
provider.Id,
|
||||||
|
eventId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!subscription.AutomaticTax.Enabled)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await stripeFacade.UpdateSubscription(subscription.Id,
|
||||||
|
new SubscriptionUpdateOptions
|
||||||
|
{
|
||||||
|
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
logger.LogError(
|
||||||
|
exception,
|
||||||
|
"Failed to set provider's ({ProviderID}) subscription to automatic tax while processing event with ID {EventID}",
|
||||||
|
provider.Id,
|
||||||
|
eventId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SendProviderUpcomingInvoiceEmailsAsync(IEnumerable<string> emails, Invoice invoice,
|
||||||
|
Subscription subscription, Guid providerId)
|
||||||
{
|
{
|
||||||
var validEmails = emails.Where(e => !string.IsNullOrEmpty(e));
|
var validEmails = emails.Where(e => !string.IsNullOrEmpty(e));
|
||||||
|
|
||||||
@@ -195,96 +525,114 @@ public class UpcomingInvoiceHandler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task AlignOrganizationTaxConcernsAsync(
|
#endregion
|
||||||
|
|
||||||
|
#region Shared
|
||||||
|
|
||||||
|
private async Task SendUpcomingInvoiceEmailsAsync(IEnumerable<string> emails, Invoice invoice)
|
||||||
|
{
|
||||||
|
var validEmails = emails.Where(e => !string.IsNullOrEmpty(e));
|
||||||
|
|
||||||
|
var items = invoice.Lines.Select(i => i.Description).ToList();
|
||||||
|
|
||||||
|
if (invoice is { NextPaymentAttempt: not null, AmountDue: > 0 })
|
||||||
|
{
|
||||||
|
await mailService.SendInvoiceUpcoming(
|
||||||
|
validEmails,
|
||||||
|
invoice.AmountDue / 100M,
|
||||||
|
invoice.NextPaymentAttempt.Value,
|
||||||
|
items,
|
||||||
|
true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SendFamiliesRenewalEmailAsync(
|
||||||
Organization organization,
|
Organization organization,
|
||||||
Subscription subscription,
|
Plan familiesPlan,
|
||||||
Customer customer,
|
Plan planBeforeAlignment)
|
||||||
string eventId)
|
|
||||||
{
|
{
|
||||||
var nonUSBusinessUse =
|
await (planBeforeAlignment switch
|
||||||
organization.PlanType.GetProductTier() != ProductTierType.Families &&
|
|
||||||
customer.Address.Country != Core.Constants.CountryAbbreviations.UnitedStates;
|
|
||||||
|
|
||||||
if (nonUSBusinessUse && customer.TaxExempt != StripeConstants.TaxExempt.Reverse)
|
|
||||||
{
|
{
|
||||||
try
|
{ Type: PlanType.FamiliesAnnually2025 } => SendFamilies2020RenewalEmailAsync(organization, familiesPlan),
|
||||||
{
|
{ Type: PlanType.FamiliesAnnually2019 } => SendFamilies2019RenewalEmailAsync(organization, familiesPlan),
|
||||||
await stripeFacade.UpdateCustomer(subscription.CustomerId,
|
_ => throw new InvalidOperationException("Unsupported families plan in SendFamiliesRenewalEmailAsync().")
|
||||||
new CustomerUpdateOptions { TaxExempt = StripeConstants.TaxExempt.Reverse });
|
});
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
logger.LogError(
|
|
||||||
exception,
|
|
||||||
"Failed to set organization's ({OrganizationID}) to reverse tax exemption while processing event with ID {EventID}",
|
|
||||||
organization.Id,
|
|
||||||
eventId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!subscription.AutomaticTax.Enabled)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await stripeFacade.UpdateSubscription(subscription.Id,
|
|
||||||
new SubscriptionUpdateOptions
|
|
||||||
{
|
|
||||||
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
logger.LogError(
|
|
||||||
exception,
|
|
||||||
"Failed to set organization's ({OrganizationID}) subscription to automatic tax while processing event with ID {EventID}",
|
|
||||||
organization.Id,
|
|
||||||
eventId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task AlignProviderTaxConcernsAsync(
|
private async Task SendFamilies2020RenewalEmailAsync(Organization organization, Plan familiesPlan)
|
||||||
Provider provider,
|
|
||||||
Subscription subscription,
|
|
||||||
Customer customer,
|
|
||||||
string eventId)
|
|
||||||
{
|
{
|
||||||
if (customer.Address.Country != Core.Constants.CountryAbbreviations.UnitedStates &&
|
var email = new Families2020RenewalMail
|
||||||
customer.TaxExempt != StripeConstants.TaxExempt.Reverse)
|
|
||||||
{
|
{
|
||||||
try
|
ToEmails = [organization.BillingEmail],
|
||||||
|
View = new Families2020RenewalMailView
|
||||||
{
|
{
|
||||||
await stripeFacade.UpdateCustomer(subscription.CustomerId,
|
MonthlyRenewalPrice = (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US"))
|
||||||
new CustomerUpdateOptions { TaxExempt = StripeConstants.TaxExempt.Reverse });
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
logger.LogError(
|
|
||||||
exception,
|
|
||||||
"Failed to set provider's ({ProviderID}) to reverse tax exemption while processing event with ID {EventID}",
|
|
||||||
provider.Id,
|
|
||||||
eventId);
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await mailer.SendEmail(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SendFamilies2019RenewalEmailAsync(Organization organization, Plan familiesPlan)
|
||||||
|
{
|
||||||
|
var coupon = await stripeFacade.GetCoupon(CouponIDs.Milestone3SubscriptionDiscount);
|
||||||
|
if (coupon == null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Coupon for sending families 2019 email id:{CouponIDs.Milestone3SubscriptionDiscount} not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!subscription.AutomaticTax.Enabled)
|
if (coupon.PercentOff == null)
|
||||||
{
|
{
|
||||||
try
|
throw new InvalidOperationException($"coupon.PercentOff for sending families 2019 email id:{CouponIDs.Milestone3SubscriptionDiscount} is null");
|
||||||
{
|
|
||||||
await stripeFacade.UpdateSubscription(subscription.Id,
|
|
||||||
new SubscriptionUpdateOptions
|
|
||||||
{
|
|
||||||
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
logger.LogError(
|
|
||||||
exception,
|
|
||||||
"Failed to set provider's ({ProviderID}) subscription to automatic tax while processing event with ID {EventID}",
|
|
||||||
provider.Id,
|
|
||||||
eventId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var discountedAnnualRenewalPrice = familiesPlan.PasswordManager.BasePrice * (100 - coupon.PercentOff.Value) / 100;
|
||||||
|
|
||||||
|
var email = new Families2019RenewalMail
|
||||||
|
{
|
||||||
|
ToEmails = [organization.BillingEmail],
|
||||||
|
View = new Families2019RenewalMailView
|
||||||
|
{
|
||||||
|
BaseMonthlyRenewalPrice = (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US")),
|
||||||
|
BaseAnnualRenewalPrice = familiesPlan.PasswordManager.BasePrice.ToString("C", new CultureInfo("en-US")),
|
||||||
|
DiscountAmount = $"{coupon.PercentOff}%",
|
||||||
|
DiscountedAnnualRenewalPrice = discountedAnnualRenewalPrice.ToString("C", new CultureInfo("en-US"))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await mailer.SendEmail(email);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task SendPremiumRenewalEmailAsync(
|
||||||
|
User user,
|
||||||
|
PremiumPlan premiumPlan)
|
||||||
|
{
|
||||||
|
var coupon = await stripeFacade.GetCoupon(CouponIDs.Milestone2SubscriptionDiscount);
|
||||||
|
if (coupon == null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Coupon for sending premium renewal email id:{CouponIDs.Milestone2SubscriptionDiscount} not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (coupon.PercentOff == null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"coupon.PercentOff for sending premium renewal email id:{CouponIDs.Milestone2SubscriptionDiscount} is null");
|
||||||
|
}
|
||||||
|
|
||||||
|
var discountedAnnualRenewalPrice = premiumPlan.Seat.Price * (100 - coupon.PercentOff.Value) / 100;
|
||||||
|
|
||||||
|
var email = new PremiumRenewalMail
|
||||||
|
{
|
||||||
|
ToEmails = [user.Email],
|
||||||
|
View = new PremiumRenewalMailView
|
||||||
|
{
|
||||||
|
BaseMonthlyRenewalPrice = (premiumPlan.Seat.Price / 12).ToString("C", new CultureInfo("en-US")),
|
||||||
|
DiscountAmount = $"{coupon.PercentOff}%",
|
||||||
|
DiscountedMonthlyRenewalPrice = (discountedAnnualRenewalPrice / 12).ToString("C", new CultureInfo("en-US"))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await mailer.SendEmail(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ using Bit.Core.Billing.Extensions;
|
|||||||
using Bit.Core.Context;
|
using Bit.Core.Context;
|
||||||
using Bit.Core.SecretsManager.Repositories;
|
using Bit.Core.SecretsManager.Repositories;
|
||||||
using Bit.Core.SecretsManager.Repositories.Noop;
|
using Bit.Core.SecretsManager.Repositories.Noop;
|
||||||
using Bit.Core.Settings;
|
|
||||||
using Bit.Core.Utilities;
|
using Bit.Core.Utilities;
|
||||||
using Bit.SharedWeb.Utilities;
|
using Bit.SharedWeb.Utilities;
|
||||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
@@ -129,12 +128,8 @@ public class Startup
|
|||||||
|
|
||||||
public void Configure(
|
public void Configure(
|
||||||
IApplicationBuilder app,
|
IApplicationBuilder app,
|
||||||
IWebHostEnvironment env,
|
IWebHostEnvironment env)
|
||||||
IHostApplicationLifetime appLifetime,
|
|
||||||
GlobalSettings globalSettings)
|
|
||||||
{
|
{
|
||||||
app.UseSerilog(env, appLifetime, globalSettings);
|
|
||||||
|
|
||||||
// Add general security headers
|
// Add general security headers
|
||||||
app.UseMiddleware<SecurityHeadersMiddleware>();
|
app.UseMiddleware<SecurityHeadersMiddleware>();
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
"billingSettings": {
|
"billingSettings": {
|
||||||
"onyx": {
|
"onyx": {
|
||||||
"personaId": 68
|
"personaId": 68
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"pricingUri": "https://billingpricing.qa.bitwarden.pw"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,9 +30,6 @@
|
|||||||
"connectionString": "SECRET",
|
"connectionString": "SECRET",
|
||||||
"applicationCacheTopicName": "SECRET"
|
"applicationCacheTopicName": "SECRET"
|
||||||
},
|
},
|
||||||
"sentry": {
|
|
||||||
"dsn": "SECRET"
|
|
||||||
},
|
|
||||||
"notificationHub": {
|
"notificationHub": {
|
||||||
"connectionString": "SECRET",
|
"connectionString": "SECRET",
|
||||||
"hubName": "SECRET"
|
"hubName": "SECRET"
|
||||||
|
|||||||
@@ -138,6 +138,9 @@ public class Organization : ITableObject<Guid>, IStorableSubscriber, IRevisable
|
|||||||
/// If set to true, disables Secrets Manager ads for users in the organization
|
/// If set to true, disables Secrets Manager ads for users in the organization
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool UseDisableSmAdsForUsers { get; set; }
|
public bool UseDisableSmAdsForUsers { get; set; }
|
||||||
|
/// If set to true, the organization has phishing protection enabled.
|
||||||
|
/// </summary>
|
||||||
|
public bool UsePhishingBlocker { get; set; }
|
||||||
|
|
||||||
public void SetNewId()
|
public void SetNewId()
|
||||||
{
|
{
|
||||||
@@ -340,5 +343,6 @@ public class Organization : ITableObject<Guid>, IStorableSubscriber, IRevisable
|
|||||||
UseAdminSponsoredFamilies = license.UseAdminSponsoredFamilies;
|
UseAdminSponsoredFamilies = license.UseAdminSponsoredFamilies;
|
||||||
UseDisableSmAdsForUsers = license.UseDisableSmAdsForUsers;
|
UseDisableSmAdsForUsers = license.UseDisableSmAdsForUsers;
|
||||||
UseAutomaticUserConfirmation = license.UseAutomaticUserConfirmation;
|
UseAutomaticUserConfirmation = license.UseAutomaticUserConfirmation;
|
||||||
|
UsePhishingBlocker = license.UsePhishingBlocker;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ public enum EventType : int
|
|||||||
OrganizationUser_RejectedAuthRequest = 1514,
|
OrganizationUser_RejectedAuthRequest = 1514,
|
||||||
OrganizationUser_Deleted = 1515, // Both user and organization user data were deleted
|
OrganizationUser_Deleted = 1515, // Both user and organization user data were deleted
|
||||||
OrganizationUser_Left = 1516, // User voluntarily left the organization
|
OrganizationUser_Left = 1516, // User voluntarily left the organization
|
||||||
|
OrganizationUser_AutomaticallyConfirmed = 1517,
|
||||||
|
|
||||||
Organization_Updated = 1600,
|
Organization_Updated = 1600,
|
||||||
Organization_PurgedVault = 1601,
|
Organization_PurgedVault = 1601,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ public enum PolicyType : byte
|
|||||||
UriMatchDefaults = 16,
|
UriMatchDefaults = 16,
|
||||||
AutotypeDefaultSetting = 17,
|
AutotypeDefaultSetting = 17,
|
||||||
AutomaticUserConfirmation = 18,
|
AutomaticUserConfirmation = 18,
|
||||||
|
BlockClaimedDomainAccountCreation = 19,
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class PolicyTypeExtensions
|
public static class PolicyTypeExtensions
|
||||||
@@ -52,6 +53,7 @@ public static class PolicyTypeExtensions
|
|||||||
PolicyType.UriMatchDefaults => "URI match defaults",
|
PolicyType.UriMatchDefaults => "URI match defaults",
|
||||||
PolicyType.AutotypeDefaultSetting => "Autotype default setting",
|
PolicyType.AutotypeDefaultSetting => "Autotype default setting",
|
||||||
PolicyType.AutomaticUserConfirmation => "Automatically confirm invited users",
|
PolicyType.AutomaticUserConfirmation => "Automatically confirm invited users",
|
||||||
|
PolicyType.BlockClaimedDomainAccountCreation => "Block account creation for claimed domains",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Bit.Core.AdminConsole.Entities;
|
using Bit.Core.AdminConsole.Entities;
|
||||||
using Bit.Core.Entities;
|
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.Models.Data;
|
using Bit.Core.Models.Data;
|
||||||
|
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||||
|
|
||||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||||
|
|
||||||
@@ -36,13 +36,18 @@ public class IntegrationTemplateContext(EventMessage eventMessage)
|
|||||||
public string DateIso8601 => Date.ToString("o");
|
public string DateIso8601 => Date.ToString("o");
|
||||||
public string EventMessage => JsonSerializer.Serialize(Event);
|
public string EventMessage => JsonSerializer.Serialize(Event);
|
||||||
|
|
||||||
public User? User { get; set; }
|
public OrganizationUserUserDetails? User { get; set; }
|
||||||
public string? UserName => User?.Name;
|
public string? UserName => User?.Name;
|
||||||
public string? UserEmail => User?.Email;
|
public string? UserEmail => User?.Email;
|
||||||
|
public OrganizationUserType? UserType => User?.Type;
|
||||||
|
|
||||||
public User? ActingUser { get; set; }
|
public OrganizationUserUserDetails? ActingUser { get; set; }
|
||||||
public string? ActingUserName => ActingUser?.Name;
|
public string? ActingUserName => ActingUser?.Name;
|
||||||
public string? ActingUserEmail => ActingUser?.Email;
|
public string? ActingUserEmail => ActingUser?.Email;
|
||||||
|
public OrganizationUserType? ActingUserType => ActingUser?.Type;
|
||||||
|
|
||||||
|
public Group? Group { get; set; }
|
||||||
|
public string? GroupName => Group?.Name;
|
||||||
|
|
||||||
public Organization? Organization { get; set; }
|
public Organization? Organization { get; set; }
|
||||||
public string? OrganizationName => Organization?.DisplayName();
|
public string? OrganizationName => Organization?.DisplayName();
|
||||||
|
|||||||
@@ -53,4 +53,5 @@ public interface IProfileOrganizationDetails
|
|||||||
bool UseAdminSponsoredFamilies { get; set; }
|
bool UseAdminSponsoredFamilies { get; set; }
|
||||||
bool UseOrganizationDomains { get; set; }
|
bool UseOrganizationDomains { get; set; }
|
||||||
bool UseAutomaticUserConfirmation { get; set; }
|
bool UseAutomaticUserConfirmation { get; set; }
|
||||||
|
bool UsePhishingBlocker { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace Bit.Core.AdminConsole.Models.Data.OrganizationUsers;
|
||||||
|
|
||||||
|
public record AcceptedOrganizationUserToConfirm
|
||||||
|
{
|
||||||
|
public required Guid OrganizationUserId { get; init; }
|
||||||
|
public required Guid UserId { get; init; }
|
||||||
|
public required string Key { get; init; }
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ public class OrganizationAbility
|
|||||||
UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies;
|
UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies;
|
||||||
UseAutomaticUserConfirmation = organization.UseAutomaticUserConfirmation;
|
UseAutomaticUserConfirmation = organization.UseAutomaticUserConfirmation;
|
||||||
UseDisableSmAdsForUsers = organization.UseDisableSmAdsForUsers;
|
UseDisableSmAdsForUsers = organization.UseDisableSmAdsForUsers;
|
||||||
|
UsePhishingBlocker = organization.UsePhishingBlocker;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
@@ -53,4 +54,5 @@ public class OrganizationAbility
|
|||||||
public bool UseAdminSponsoredFamilies { get; set; }
|
public bool UseAdminSponsoredFamilies { get; set; }
|
||||||
public bool UseAutomaticUserConfirmation { get; set; }
|
public bool UseAutomaticUserConfirmation { get; set; }
|
||||||
public bool UseDisableSmAdsForUsers { get; set; }
|
public bool UseDisableSmAdsForUsers { get; set; }
|
||||||
|
public bool UsePhishingBlocker { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,4 +65,5 @@ public class OrganizationUserOrganizationDetails : IProfileOrganizationDetails
|
|||||||
public bool UseAdminSponsoredFamilies { get; set; }
|
public bool UseAdminSponsoredFamilies { get; set; }
|
||||||
public bool? IsAdminInitiated { get; set; }
|
public bool? IsAdminInitiated { get; set; }
|
||||||
public bool UseAutomaticUserConfirmation { get; set; }
|
public bool UseAutomaticUserConfirmation { get; set; }
|
||||||
|
public bool UsePhishingBlocker { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ public class SelfHostedOrganizationDetails : Organization
|
|||||||
UseRiskInsights = UseRiskInsights,
|
UseRiskInsights = UseRiskInsights,
|
||||||
UseAdminSponsoredFamilies = UseAdminSponsoredFamilies,
|
UseAdminSponsoredFamilies = UseAdminSponsoredFamilies,
|
||||||
UseDisableSmAdsForUsers = UseDisableSmAdsForUsers,
|
UseDisableSmAdsForUsers = UseDisableSmAdsForUsers,
|
||||||
|
UsePhishingBlocker = UsePhishingBlocker,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,4 +56,5 @@ public class ProviderUserOrganizationDetails : IProfileOrganizationDetails
|
|||||||
public string? SsoExternalId { get; set; }
|
public string? SsoExternalId { get; set; }
|
||||||
public string? Permissions { get; set; }
|
public string? Permissions { get; set; }
|
||||||
public string? ResetPasswordKey { get; set; }
|
public string? ResetPasswordKey { get; set; }
|
||||||
|
public bool UsePhishingBlocker { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ public class SlackOAuthResponse : SlackApiResponse
|
|||||||
public SlackTeam Team { get; set; } = new();
|
public SlackTeam Team { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class SlackSendMessageResponse : SlackApiResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("channel")]
|
||||||
|
public string Channel { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
public class SlackTeam
|
public class SlackTeam
|
||||||
{
|
{
|
||||||
public string Id { get; set; } = string.Empty;
|
public string Id { get; set; } = string.Empty;
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
using Bit.Core.AdminConsole.Models.Data.OrganizationUsers;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
|
||||||
|
using Bit.Core.Entities;
|
||||||
|
using Bit.Core.Enums;
|
||||||
|
using Bit.Core.Models.Data;
|
||||||
|
using Bit.Core.Platform.Push;
|
||||||
|
using Bit.Core.Repositories;
|
||||||
|
using Bit.Core.Services;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using OneOf.Types;
|
||||||
|
using CommandResult = Bit.Core.AdminConsole.Utilities.v2.Results.CommandResult;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser;
|
||||||
|
|
||||||
|
public class AutomaticallyConfirmOrganizationUserCommand(IOrganizationUserRepository organizationUserRepository,
|
||||||
|
IOrganizationRepository organizationRepository,
|
||||||
|
IAutomaticallyConfirmOrganizationUsersValidator validator,
|
||||||
|
IEventService eventService,
|
||||||
|
IMailService mailService,
|
||||||
|
IUserRepository userRepository,
|
||||||
|
IPushRegistrationService pushRegistrationService,
|
||||||
|
IDeviceRepository deviceRepository,
|
||||||
|
IPushNotificationService pushNotificationService,
|
||||||
|
IPolicyRequirementQuery policyRequirementQuery,
|
||||||
|
ICollectionRepository collectionRepository,
|
||||||
|
TimeProvider timeProvider,
|
||||||
|
ILogger<AutomaticallyConfirmOrganizationUserCommand> logger) : IAutomaticallyConfirmOrganizationUserCommand
|
||||||
|
{
|
||||||
|
public async Task<CommandResult> AutomaticallyConfirmOrganizationUserAsync(AutomaticallyConfirmOrganizationUserRequest request)
|
||||||
|
{
|
||||||
|
var validatorRequest = await RetrieveDataAsync(request);
|
||||||
|
|
||||||
|
var validatedData = await validator.ValidateAsync(validatorRequest);
|
||||||
|
|
||||||
|
return await validatedData.Match<Task<CommandResult>>(
|
||||||
|
error => Task.FromResult(new CommandResult(error)),
|
||||||
|
async _ =>
|
||||||
|
{
|
||||||
|
var userToConfirm = new AcceptedOrganizationUserToConfirm
|
||||||
|
{
|
||||||
|
OrganizationUserId = validatedData.Request.OrganizationUser!.Id,
|
||||||
|
UserId = validatedData.Request.OrganizationUser.UserId!.Value,
|
||||||
|
Key = validatedData.Request.Key
|
||||||
|
};
|
||||||
|
|
||||||
|
// This operation is idempotent. If false, the user is already confirmed and no additional side effects are required.
|
||||||
|
if (!await organizationUserRepository.ConfirmOrganizationUserAsync(userToConfirm))
|
||||||
|
{
|
||||||
|
return new None();
|
||||||
|
}
|
||||||
|
|
||||||
|
await CreateDefaultCollectionsAsync(validatedData.Request);
|
||||||
|
|
||||||
|
await Task.WhenAll(
|
||||||
|
LogOrganizationUserConfirmedEventAsync(validatedData.Request),
|
||||||
|
SendConfirmedOrganizationUserEmailAsync(validatedData.Request),
|
||||||
|
SyncOrganizationKeysAsync(validatedData.Request)
|
||||||
|
);
|
||||||
|
|
||||||
|
return new None();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SyncOrganizationKeysAsync(AutomaticallyConfirmOrganizationUserValidationRequest request)
|
||||||
|
{
|
||||||
|
await DeleteDeviceRegistrationAsync(request);
|
||||||
|
await PushSyncOrganizationKeysAsync(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CreateDefaultCollectionsAsync(AutomaticallyConfirmOrganizationUserValidationRequest request)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!await ShouldCreateDefaultCollectionAsync(request))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await collectionRepository.CreateAsync(
|
||||||
|
new Collection
|
||||||
|
{
|
||||||
|
OrganizationId = request.Organization!.Id,
|
||||||
|
Name = request.DefaultUserCollectionName,
|
||||||
|
Type = CollectionType.DefaultUserCollection
|
||||||
|
},
|
||||||
|
groups: null,
|
||||||
|
[new CollectionAccessSelection
|
||||||
|
{
|
||||||
|
Id = request.OrganizationUser!.Id,
|
||||||
|
Manage = true
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to create default collection for user.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines whether a default collection should be created for an organization user during the confirmation process.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">
|
||||||
|
/// The validation request containing information about the user, organization, and collection settings.
|
||||||
|
/// </param>
|
||||||
|
/// <returns>The result is a boolean value indicating whether a default collection should be created.</returns>
|
||||||
|
private async Task<bool> ShouldCreateDefaultCollectionAsync(AutomaticallyConfirmOrganizationUserValidationRequest request) =>
|
||||||
|
!string.IsNullOrWhiteSpace(request.DefaultUserCollectionName)
|
||||||
|
&& (await policyRequirementQuery.GetAsync<OrganizationDataOwnershipPolicyRequirement>(request.OrganizationUser!.UserId!.Value))
|
||||||
|
.RequiresDefaultCollectionOnConfirm(request.Organization!.Id);
|
||||||
|
|
||||||
|
private async Task PushSyncOrganizationKeysAsync(AutomaticallyConfirmOrganizationUserValidationRequest request)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await pushNotificationService.PushSyncOrgKeysAsync(request.OrganizationUser!.UserId!.Value);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to push organization keys.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LogOrganizationUserConfirmedEventAsync(AutomaticallyConfirmOrganizationUserValidationRequest request)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await eventService.LogOrganizationUserEventAsync(request.OrganizationUser,
|
||||||
|
EventType.OrganizationUser_AutomaticallyConfirmed,
|
||||||
|
timeProvider.GetUtcNow().UtcDateTime);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to log OrganizationUser_AutomaticallyConfirmed event.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SendConfirmedOrganizationUserEmailAsync(AutomaticallyConfirmOrganizationUserValidationRequest request)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var user = await userRepository.GetByIdAsync(request.OrganizationUser!.UserId!.Value);
|
||||||
|
|
||||||
|
await mailService.SendOrganizationConfirmedEmailAsync(request.Organization!.Name,
|
||||||
|
user!.Email,
|
||||||
|
request.OrganizationUser.AccessSecretsManager);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to send OrganizationUserConfirmed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteDeviceRegistrationAsync(AutomaticallyConfirmOrganizationUserValidationRequest request)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var devices = (await deviceRepository.GetManyByUserIdAsync(request.OrganizationUser!.UserId!.Value))
|
||||||
|
.Where(d => !string.IsNullOrWhiteSpace(d.PushToken))
|
||||||
|
.Select(d => d.Id.ToString());
|
||||||
|
|
||||||
|
await pushRegistrationService.DeleteUserRegistrationOrganizationAsync(devices, request.Organization!.Id.ToString());
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to delete device registration.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<AutomaticallyConfirmOrganizationUserValidationRequest> RetrieveDataAsync(
|
||||||
|
AutomaticallyConfirmOrganizationUserRequest request)
|
||||||
|
{
|
||||||
|
return new AutomaticallyConfirmOrganizationUserValidationRequest
|
||||||
|
{
|
||||||
|
OrganizationUserId = request.OrganizationUserId,
|
||||||
|
OrganizationId = request.OrganizationId,
|
||||||
|
Key = request.Key,
|
||||||
|
DefaultUserCollectionName = request.DefaultUserCollectionName,
|
||||||
|
PerformedBy = request.PerformedBy,
|
||||||
|
OrganizationUser = await organizationUserRepository.GetByIdAsync(request.OrganizationUserId),
|
||||||
|
Organization = await organizationRepository.GetByIdAsync(request.OrganizationId)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Bit.Core.AdminConsole.Models.Data;
|
||||||
|
using Bit.Core.Entities;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Automatically Confirm User Command Request
|
||||||
|
/// </summary>
|
||||||
|
public record AutomaticallyConfirmOrganizationUserRequest
|
||||||
|
{
|
||||||
|
public required Guid OrganizationUserId { get; init; }
|
||||||
|
public required Guid OrganizationId { get; init; }
|
||||||
|
public required string Key { get; init; }
|
||||||
|
public required string DefaultUserCollectionName { get; init; }
|
||||||
|
public required IActingUser PerformedBy { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Automatically Confirm User Validation Request
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This is used to hold retrieved data and pass it to the validator
|
||||||
|
/// </remarks>
|
||||||
|
public record AutomaticallyConfirmOrganizationUserValidationRequest : AutomaticallyConfirmOrganizationUserRequest
|
||||||
|
{
|
||||||
|
public OrganizationUser? OrganizationUser { get; set; }
|
||||||
|
public Organization? Organization { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
using Bit.Core.AdminConsole.Enums;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
|
||||||
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
|
using Bit.Core.AdminConsole.Utilities.v2;
|
||||||
|
using Bit.Core.AdminConsole.Utilities.v2.Validation;
|
||||||
|
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
||||||
|
using Bit.Core.Enums;
|
||||||
|
using Bit.Core.Repositories;
|
||||||
|
using static Bit.Core.AdminConsole.Utilities.v2.Validation.ValidationResultHelpers;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser;
|
||||||
|
|
||||||
|
public class AutomaticallyConfirmOrganizationUsersValidator(
|
||||||
|
IOrganizationUserRepository organizationUserRepository,
|
||||||
|
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
|
||||||
|
IPolicyRequirementQuery policyRequirementQuery,
|
||||||
|
IPolicyRepository policyRepository) : IAutomaticallyConfirmOrganizationUsersValidator
|
||||||
|
{
|
||||||
|
public async Task<ValidationResult<AutomaticallyConfirmOrganizationUserValidationRequest>> ValidateAsync(
|
||||||
|
AutomaticallyConfirmOrganizationUserValidationRequest request)
|
||||||
|
{
|
||||||
|
// User must exist
|
||||||
|
if (request is { OrganizationUser: null } || request.OrganizationUser is { UserId: null })
|
||||||
|
{
|
||||||
|
return Invalid(request, new UserNotFoundError());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Organization must exist
|
||||||
|
if (request is { Organization: null })
|
||||||
|
{
|
||||||
|
return Invalid(request, new OrganizationNotFound());
|
||||||
|
}
|
||||||
|
|
||||||
|
// User must belong to the organization
|
||||||
|
if (request.OrganizationUser.OrganizationId != request.Organization.Id)
|
||||||
|
{
|
||||||
|
return Invalid(request, new OrganizationUserIdIsInvalid());
|
||||||
|
}
|
||||||
|
|
||||||
|
// User must be accepted
|
||||||
|
if (request is { OrganizationUser.Status: not OrganizationUserStatusType.Accepted })
|
||||||
|
{
|
||||||
|
return Invalid(request, new UserIsNotAccepted());
|
||||||
|
}
|
||||||
|
|
||||||
|
// User must be of type User
|
||||||
|
if (request is { OrganizationUser.Type: not OrganizationUserType.User })
|
||||||
|
{
|
||||||
|
return Invalid(request, new UserIsNotUserType());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await OrganizationHasAutomaticallyConfirmUsersPolicyEnabledAsync(request))
|
||||||
|
{
|
||||||
|
return Invalid(request, new AutomaticallyConfirmUsersPolicyIsNotEnabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await OrganizationUserConformsToTwoFactorRequiredPolicyAsync(request))
|
||||||
|
{
|
||||||
|
return Invalid(request, new UserDoesNotHaveTwoFactorEnabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await OrganizationUserConformsToSingleOrgPolicyAsync(request) is { } error)
|
||||||
|
{
|
||||||
|
return Invalid(request, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Valid(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> OrganizationHasAutomaticallyConfirmUsersPolicyEnabledAsync(
|
||||||
|
AutomaticallyConfirmOrganizationUserValidationRequest request) =>
|
||||||
|
await policyRepository.GetByOrganizationIdTypeAsync(request.OrganizationId,
|
||||||
|
PolicyType.AutomaticUserConfirmation) is { Enabled: true }
|
||||||
|
&& request.Organization is { UseAutomaticUserConfirmation: true };
|
||||||
|
|
||||||
|
private async Task<bool> OrganizationUserConformsToTwoFactorRequiredPolicyAsync(AutomaticallyConfirmOrganizationUserValidationRequest request)
|
||||||
|
{
|
||||||
|
if ((await twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync([request.OrganizationUser!.UserId!.Value]))
|
||||||
|
.Any(x => x.userId == request.OrganizationUser.UserId && x.twoFactorIsEnabled))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !(await policyRequirementQuery.GetAsync<RequireTwoFactorPolicyRequirement>(request.OrganizationUser.UserId!.Value))
|
||||||
|
.IsTwoFactorRequiredForOrganization(request.Organization!.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Error?> OrganizationUserConformsToSingleOrgPolicyAsync(
|
||||||
|
AutomaticallyConfirmOrganizationUserValidationRequest request)
|
||||||
|
{
|
||||||
|
var allOrganizationUsersForUser = await organizationUserRepository
|
||||||
|
.GetManyByUserAsync(request.OrganizationUser!.UserId!.Value);
|
||||||
|
|
||||||
|
if (allOrganizationUsersForUser.Count == 1)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var policyRequirement = await policyRequirementQuery
|
||||||
|
.GetAsync<SingleOrganizationPolicyRequirement>(request.OrganizationUser!.UserId!.Value);
|
||||||
|
|
||||||
|
if (policyRequirement.IsSingleOrgEnabledForThisOrganization(request.Organization!.Id))
|
||||||
|
{
|
||||||
|
return new OrganizationEnforcesSingleOrgPolicy();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (policyRequirement.IsSingleOrgEnabledForOrganizationsOtherThan(request.Organization.Id))
|
||||||
|
{
|
||||||
|
return new OtherOrganizationEnforcesSingleOrgPolicy();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
using Bit.Core.AdminConsole.Utilities.v2;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser;
|
||||||
|
|
||||||
|
public record OrganizationNotFound() : NotFoundError("Invalid organization");
|
||||||
|
public record FailedToWriteToEventLog() : InternalError("Failed to write to event log");
|
||||||
|
public record UserIsNotUserType() : BadRequestError("Only organization users with the User role can be automatically confirmed");
|
||||||
|
public record UserIsNotAccepted() : BadRequestError("Cannot confirm user that has not accepted the invitation.");
|
||||||
|
public record OrganizationUserIdIsInvalid() : BadRequestError("Invalid organization user id.");
|
||||||
|
public record UserDoesNotHaveTwoFactorEnabled() : BadRequestError("User does not have two-step login enabled.");
|
||||||
|
public record OrganizationEnforcesSingleOrgPolicy() : BadRequestError("Cannot confirm this member to the organization until they leave or remove all other organizations");
|
||||||
|
public record OtherOrganizationEnforcesSingleOrgPolicy() : BadRequestError("Cannot confirm this member to the organization because they are in another organization which forbids it.");
|
||||||
|
public record AutomaticallyConfirmUsersPolicyIsNotEnabled() : BadRequestError("Cannot confirm this member because the Automatically Confirm Users policy is not enabled.");
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using Bit.Core.AdminConsole.Utilities.v2.Validation;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser;
|
||||||
|
|
||||||
|
public interface IAutomaticallyConfirmOrganizationUsersValidator
|
||||||
|
{
|
||||||
|
Task<ValidationResult<AutomaticallyConfirmOrganizationUserValidationRequest>> ValidateAsync(
|
||||||
|
AutomaticallyConfirmOrganizationUserValidationRequest request);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Automatic User Confirmation
|
||||||
|
|
||||||
|
Owned by: admin-console
|
||||||
|
|
||||||
|
Automatic confirmation requests are server driven events that are sent to the admin's client where via a background service the confirmation will occur. The basic model
|
||||||
|
for the workflow is as follows:
|
||||||
|
|
||||||
|
- The Api server sends an invite email to a user.
|
||||||
|
- The user accepts the invite request, which is sent back to the Api server
|
||||||
|
- The Api server sends a push-notification with the OrganizationId and UserId to a client admin session.
|
||||||
|
- The Client performs the key exchange in the background and POSTs the ConfirmRequest back to the Api server
|
||||||
|
- The Api server runs the OrgUser_Confirm sproc to confirm the user in the DB
|
||||||
|
|
||||||
|
This Feature has the following security measures in place in order to achieve our security goals:
|
||||||
|
|
||||||
|
- The single organization exemption for admins/owners is removed for this policy.
|
||||||
|
- This is enforced by preventing enabling the policy and organization plan feature if there are non-compliant users
|
||||||
|
- Emergency access is removed for all organization users
|
||||||
|
- Automatic confirmation will only apply to the User role (You cannot auto confirm admins/owners to an organization)
|
||||||
|
- The organization has no members with the Provider user type.
|
||||||
|
- This will also prevent the policy and organization plan feature from being enabled
|
||||||
|
- This will prevent sending organization invites to provider users
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||||
|
using Bit.Core.AdminConsole.Utilities.v2.Results;
|
||||||
|
using Bit.Core.AdminConsole.Utilities.v2.Validation;
|
||||||
using Bit.Core.Entities;
|
using Bit.Core.Entities;
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.Exceptions;
|
using Bit.Core.Exceptions;
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
|
using Bit.Core.AdminConsole.Utilities.v2.Validation;
|
||||||
using Bit.Core.Context;
|
using Bit.Core.Context;
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
using static Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount.ValidationResultHelpers;
|
using static Bit.Core.AdminConsole.Utilities.v2.Validation.ValidationResultHelpers;
|
||||||
|
|
||||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount;
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount;
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,6 @@
|
|||||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount;
|
using Bit.Core.AdminConsole.Utilities.v2;
|
||||||
|
|
||||||
/// <summary>
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount;
|
||||||
/// A strongly typed error containing a reason that an action failed.
|
|
||||||
/// This is used for business logic validation and other expected errors, not exceptions.
|
|
||||||
/// </summary>
|
|
||||||
public abstract record Error(string Message);
|
|
||||||
/// <summary>
|
|
||||||
/// An <see cref="Error"/> type that maps to a NotFoundResult at the api layer.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="Message"></param>
|
|
||||||
public abstract record NotFoundError(string Message) : Error(Message);
|
|
||||||
|
|
||||||
public record UserNotFoundError() : NotFoundError("Invalid user.");
|
public record UserNotFoundError() : NotFoundError("Invalid user.");
|
||||||
public record UserNotClaimedError() : Error("Member is not claimed by the organization.");
|
public record UserNotClaimedError() : Error("Member is not claimed by the organization.");
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount;
|
using Bit.Core.AdminConsole.Utilities.v2.Results;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount;
|
||||||
|
|
||||||
public interface IDeleteClaimedOrganizationUserAccountCommand
|
public interface IDeleteClaimedOrganizationUserAccountCommand
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount;
|
using Bit.Core.AdminConsole.Utilities.v2.Validation;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount;
|
||||||
|
|
||||||
public interface IDeleteClaimedOrganizationUserAccountValidator
|
public interface IDeleteClaimedOrganizationUserAccountValidator
|
||||||
{
|
{
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user