mirror of
https://github.com/bitwarden/server
synced 2026-01-27 14:53:21 +00:00
Merge branch 'main' into fix-rc
This commit is contained in:
@@ -1,25 +0,0 @@
|
||||
Please review this pull request with a focus on:
|
||||
|
||||
- Code quality and best practices
|
||||
- Potential bugs or issues
|
||||
- Security implications
|
||||
- Performance considerations
|
||||
|
||||
Note: The PR branch is already checked out in the current working directory.
|
||||
|
||||
Provide a comprehensive review including:
|
||||
|
||||
- Summary of changes since last review
|
||||
- Critical issues found (be thorough)
|
||||
- Suggested improvements (be thorough)
|
||||
- Good practices observed (be concise - list only the most notable items without elaboration)
|
||||
- Action items for the author
|
||||
- Leverage collapsible <details> sections where appropriate for lengthy explanations or code snippets to enhance human readability
|
||||
|
||||
When reviewing subsequent commits:
|
||||
|
||||
- Track status of previously identified issues (fixed/unfixed/reopened)
|
||||
- Identify NEW problems introduced since last review
|
||||
- Note if fixes introduced new issues
|
||||
|
||||
IMPORTANT: Be comprehensive about issues and improvements. For good practices, be brief - just note what was done well without explaining why or praising excessively.
|
||||
7
.github/CODEOWNERS
vendored
7
.github/CODEOWNERS
vendored
@@ -53,6 +53,11 @@ src/Core/IdentityServer @bitwarden/team-auth-dev
|
||||
|
||||
# Dirt (Data Insights & Reporting) team
|
||||
**/Dirt @bitwarden/team-data-insights-and-reporting-dev
|
||||
src/Events @bitwarden/team-data-insights-and-reporting-dev
|
||||
src/EventsProcessor @bitwarden/team-data-insights-and-reporting-dev
|
||||
test/Events.IntegrationTest @bitwarden/team-data-insights-and-reporting-dev
|
||||
test/Events.Test @bitwarden/team-data-insights-and-reporting-dev
|
||||
test/EventsProcessor.Test @bitwarden/team-data-insights-and-reporting-dev
|
||||
|
||||
# Vault team
|
||||
**/Vault @bitwarden/team-vault-dev
|
||||
@@ -63,8 +68,6 @@ src/Core/IdentityServer @bitwarden/team-auth-dev
|
||||
bitwarden_license/src/Scim @bitwarden/team-admin-console-dev
|
||||
bitwarden_license/src/test/Scim.IntegrationTest @bitwarden/team-admin-console-dev
|
||||
bitwarden_license/src/test/Scim.ScimTest @bitwarden/team-admin-console-dev
|
||||
src/Events @bitwarden/team-admin-console-dev
|
||||
src/EventsProcessor @bitwarden/team-admin-console-dev
|
||||
|
||||
# Billing team
|
||||
**/*billing* @bitwarden/team-billing-dev
|
||||
|
||||
117
.github/renovate.json5
vendored
117
.github/renovate.json5
vendored
@@ -10,42 +10,7 @@
|
||||
"nuget",
|
||||
],
|
||||
packageRules: [
|
||||
{
|
||||
groupName: "cargo minor",
|
||||
matchManagers: ["cargo"],
|
||||
matchUpdateTypes: ["minor"],
|
||||
},
|
||||
{
|
||||
groupName: "dockerfile minor",
|
||||
matchManagers: ["dockerfile"],
|
||||
matchUpdateTypes: ["minor"],
|
||||
},
|
||||
{
|
||||
groupName: "docker-compose minor",
|
||||
matchManagers: ["docker-compose"],
|
||||
matchUpdateTypes: ["minor"],
|
||||
},
|
||||
{
|
||||
groupName: "github-action minor",
|
||||
matchManagers: ["github-actions"],
|
||||
matchUpdateTypes: ["minor"],
|
||||
addLabels: ["hold"],
|
||||
},
|
||||
{
|
||||
// For any Microsoft.Extensions.* and Microsoft.AspNetCore.* packages, we want to create PRs for patch updates.
|
||||
// This overrides the default that ignores patch updates for nuget dependencies.
|
||||
matchPackageNames: [
|
||||
"/^Microsoft\\.Extensions\\./",
|
||||
"/^Microsoft\\.AspNetCore\\./",
|
||||
],
|
||||
matchUpdateTypes: ["patch"],
|
||||
dependencyDashboardApproval: false,
|
||||
},
|
||||
{
|
||||
matchPackageNames: ["https://github.com/bitwarden/sdk-internal.git"],
|
||||
groupName: "sdk-internal",
|
||||
dependencyDashboardApproval: true
|
||||
},
|
||||
// ==================== Team Ownership Rules ====================
|
||||
{
|
||||
matchManagers: ["dockerfile", "docker-compose"],
|
||||
commitMessagePrefix: "[deps] BRE:",
|
||||
@@ -68,6 +33,7 @@
|
||||
"Fido2.AspNet",
|
||||
"Duende.IdentityServer",
|
||||
"Microsoft.AspNetCore.Authentication.JwtBearer",
|
||||
"Microsoft.Extensions.Caching.Cosmos",
|
||||
"Microsoft.Extensions.Identity.Stores",
|
||||
"Otp.NET",
|
||||
"Sustainsys.Saml2.AspNetCore2",
|
||||
@@ -101,11 +67,6 @@
|
||||
commitMessagePrefix: "[deps] Billing:",
|
||||
reviewers: ["team:team-billing-dev"],
|
||||
},
|
||||
{
|
||||
matchPackageNames: ["/^Microsoft\\.EntityFrameworkCore\\./", "/^dotnet-ef/"],
|
||||
groupName: "EntityFrameworkCore",
|
||||
description: "Group EntityFrameworkCore to exclude them from the dotnet monorepo preset",
|
||||
},
|
||||
{
|
||||
matchPackageNames: [
|
||||
"Dapper",
|
||||
@@ -153,7 +114,6 @@
|
||||
"Microsoft.Extensions.DependencyInjection",
|
||||
"Microsoft.Extensions.Logging",
|
||||
"Microsoft.Extensions.Logging.Console",
|
||||
"Microsoft.Extensions.Caching.Cosmos",
|
||||
"Microsoft.Extensions.Caching.SqlServer",
|
||||
"Microsoft.Extensions.Caching.StackExchangeRedis",
|
||||
"Quartz",
|
||||
@@ -162,6 +122,12 @@
|
||||
commitMessagePrefix: "[deps] Platform:",
|
||||
reviewers: ["team:team-platform-dev"],
|
||||
},
|
||||
{
|
||||
matchUpdateTypes: ["lockFileMaintenance"],
|
||||
description: "Platform owns lock file maintenance",
|
||||
commitMessagePrefix: "[deps] Platform:",
|
||||
reviewers: ["team:team-platform-dev"],
|
||||
},
|
||||
{
|
||||
matchPackageNames: [
|
||||
"AutoMapper.Extensions.Microsoft.DependencyInjection",
|
||||
@@ -191,6 +157,73 @@
|
||||
commitMessagePrefix: "[deps] Vault:",
|
||||
reviewers: ["team:team-vault-dev"],
|
||||
},
|
||||
|
||||
// ==================== Grouping Rules ====================
|
||||
// These come after any specific team assignment rules to ensure
|
||||
// that grouping is not overridden by subsequent rule definitions.
|
||||
{
|
||||
groupName: "cargo minor",
|
||||
matchManagers: ["cargo"],
|
||||
matchUpdateTypes: ["minor"],
|
||||
},
|
||||
{
|
||||
groupName: "dockerfile minor",
|
||||
matchManagers: ["dockerfile"],
|
||||
matchUpdateTypes: ["minor"],
|
||||
},
|
||||
{
|
||||
groupName: "docker-compose minor",
|
||||
matchManagers: ["docker-compose"],
|
||||
matchUpdateTypes: ["minor"],
|
||||
},
|
||||
{
|
||||
groupName: "github-action minor",
|
||||
matchManagers: ["github-actions"],
|
||||
matchUpdateTypes: ["minor"],
|
||||
addLabels: ["hold"],
|
||||
},
|
||||
{
|
||||
matchPackageNames: ["/^Microsoft\\.EntityFrameworkCore\\./", "/^dotnet-ef/"],
|
||||
groupName: "EntityFrameworkCore",
|
||||
description: "Group EntityFrameworkCore to exclude them from the dotnet monorepo preset",
|
||||
},
|
||||
{
|
||||
matchPackageNames: ["https://github.com/bitwarden/sdk-internal.git"],
|
||||
groupName: "sdk-internal",
|
||||
dependencyDashboardApproval: true
|
||||
},
|
||||
|
||||
// ==================== Dashboard Rules ====================
|
||||
{
|
||||
// For any Microsoft.Extensions.* and Microsoft.AspNetCore.* packages, we want to create PRs for patch updates.
|
||||
// This overrides the default that ignores patch updates for nuget dependencies.
|
||||
matchPackageNames: [
|
||||
"/^Microsoft\\.Extensions\\./",
|
||||
"/^Microsoft\\.AspNetCore\\./",
|
||||
],
|
||||
matchUpdateTypes: ["patch"],
|
||||
dependencyDashboardApproval: false,
|
||||
},
|
||||
{
|
||||
// For the Platform-owned dependencies below, we have decided we will only be creating PRs
|
||||
// for major updates, and sending minor (as well as patch, inherited from base config) to the dashboard.
|
||||
// This rule comes AFTER grouping rules so that groups are respected while still
|
||||
// sending minor/patch updates to the dependency dashboard for approval.
|
||||
matchPackageNames: [
|
||||
"AspNetCoreRateLimit",
|
||||
"AspNetCoreRateLimit.Redis",
|
||||
"Azure.Data.Tables",
|
||||
"Azure.Extensions.AspNetCore.DataProtection.Blobs",
|
||||
"Azure.Messaging.EventGrid",
|
||||
"Azure.Messaging.ServiceBus",
|
||||
"Azure.Storage.Blobs",
|
||||
"Azure.Storage.Queues",
|
||||
"LaunchDarkly.ServerSdk",
|
||||
"Quartz",
|
||||
],
|
||||
matchUpdateTypes: ["minor"],
|
||||
dependencyDashboardApproval: true,
|
||||
},
|
||||
],
|
||||
ignoreDeps: ["dotnet-sdk"],
|
||||
}
|
||||
|
||||
4
.github/workflows/_move_edd_db_scripts.yml
vendored
4
.github/workflows/_move_edd_db_scripts.yml
vendored
@@ -38,7 +38,7 @@ jobs:
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- name: Check out branch
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
token: ${{ steps.retrieve-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
|
||||
persist-credentials: false
|
||||
@@ -68,7 +68,7 @@ jobs:
|
||||
if: ${{ needs.setup.outputs.copy_edd_scripts == 'true' }}
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: true
|
||||
|
||||
38
.github/workflows/build.yml
vendored
38
.github/workflows/build.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
persist-credentials: false
|
||||
@@ -102,7 +102,7 @@ jobs:
|
||||
echo "has_secrets=$has_secrets" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
persist-credentials: false
|
||||
@@ -123,7 +123,7 @@ jobs:
|
||||
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
cache: "npm"
|
||||
cache-dependency-path: "**/package-lock.json"
|
||||
@@ -160,7 +160,7 @@ jobs:
|
||||
ls -atlh ../../../
|
||||
|
||||
- name: Upload project artifact
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
if: ${{ matrix.dotnet }}
|
||||
with:
|
||||
name: ${{ matrix.project_name }}.zip
|
||||
@@ -169,10 +169,10 @@ jobs:
|
||||
|
||||
########## Set up Docker ##########
|
||||
- name: Set up QEMU emulators
|
||||
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
########## ACRs ##########
|
||||
- name: Log in to Azure
|
||||
@@ -246,7 +246,7 @@ jobs:
|
||||
|
||||
- name: Install Cosign
|
||||
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
||||
uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3.8.2
|
||||
uses: sigstore/cosign-installer@7e8b541eb2e61bf99390e1afd4be13a184e9ebc5 # v3.10.1
|
||||
|
||||
- name: Sign image with Cosign
|
||||
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
||||
@@ -264,7 +264,7 @@ jobs:
|
||||
|
||||
- name: Scan Docker image
|
||||
id: container-scan
|
||||
uses: anchore/scan-action@f6601287cdb1efc985d6b765bbf99cb4c0ac29d8 # v7.0.0
|
||||
uses: anchore/scan-action@3c9a191a0fbab285ca6b8530b5de5a642cba332f # v7.2.2
|
||||
with:
|
||||
image: ${{ steps.image-tags.outputs.primary_tag }}
|
||||
fail-build: false
|
||||
@@ -289,7 +289,7 @@ jobs:
|
||||
actions: read
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
persist-credentials: false
|
||||
@@ -356,7 +356,7 @@ jobs:
|
||||
if: |
|
||||
github.event_name != 'pull_request'
|
||||
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: docker-stub-US.zip
|
||||
path: docker-stub-US.zip
|
||||
@@ -366,7 +366,7 @@ jobs:
|
||||
if: |
|
||||
github.event_name != 'pull_request'
|
||||
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: docker-stub-EU.zip
|
||||
path: docker-stub-EU.zip
|
||||
@@ -378,21 +378,21 @@ jobs:
|
||||
pwsh ./generate_openapi_files.ps1
|
||||
|
||||
- name: Upload Public API Swagger artifact
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: swagger.json
|
||||
path: api.public.json
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload Internal API Swagger artifact
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: internal.json
|
||||
path: api.json
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload Identity Swagger artifact
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: identity.json
|
||||
path: identity.json
|
||||
@@ -416,7 +416,7 @@ jobs:
|
||||
- win-x64
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
persist-credentials: false
|
||||
@@ -438,7 +438,7 @@ jobs:
|
||||
|
||||
- name: Upload project artifact for Windows
|
||||
if: ${{ contains(matrix.target, 'win') == true }}
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: MsSqlMigratorUtility-${{ matrix.target }}
|
||||
path: util/MsSqlMigratorUtility/obj/build-output/publish/MsSqlMigratorUtility.exe
|
||||
@@ -446,7 +446,7 @@ jobs:
|
||||
|
||||
- name: Upload project artifact
|
||||
if: ${{ contains(matrix.target, 'win') == false }}
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: MsSqlMigratorUtility-${{ matrix.target }}
|
||||
path: util/MsSqlMigratorUtility/obj/build-output/publish/MsSqlMigratorUtility
|
||||
@@ -481,7 +481,7 @@ jobs:
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- name: Generate GH App token
|
||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
|
||||
@@ -531,7 +531,7 @@ jobs:
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- name: Generate GH App token
|
||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
|
||||
|
||||
2
.github/workflows/cleanup-rc-branch.yml
vendored
2
.github/workflows/cleanup-rc-branch.yml
vendored
@@ -31,7 +31,7 @@ jobs:
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
ref: main
|
||||
token: ${{ steps.retrieve-bot-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
|
||||
|
||||
4
.github/workflows/code-references.yml
vendored
4
.github/workflows/code-references.yml
vendored
@@ -36,7 +36,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
|
||||
- name: Collect
|
||||
id: collect
|
||||
uses: launchdarkly/find-code-references@e3e9da201b87ada54eb4c550c14fb783385c5c8a # v2.13.0
|
||||
uses: launchdarkly/find-code-references@89a7d362d1d4b3725fe0fe0ccd0dc69e3bdcba58 # v2.14.0
|
||||
with:
|
||||
accessToken: ${{ steps.get-kv-secrets.outputs.LD-ACCESS-TOKEN }}
|
||||
projKey: default
|
||||
|
||||
4
.github/workflows/load-test.yml
vendored
4
.github/workflows/load-test.yml
vendored
@@ -87,7 +87,7 @@ jobs:
|
||||
datadog/agent:7-full@sha256:7ea933dec3b8baa8c19683b1c3f6f801dbf3291f748d9ed59234accdaac4e479
|
||||
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -95,7 +95,7 @@ jobs:
|
||||
uses: grafana/setup-k6-action@ffe7d7290dfa715e48c2ccc924d068444c94bde2 # v1.1.0
|
||||
|
||||
- name: Run k6 tests
|
||||
uses: grafana/run-k6-action@c6b79182b9b666aa4f630f4a6be9158ead62536e # v1.2.0
|
||||
uses: grafana/run-k6-action@a15e2072ede004e8d46141e33d7f7dad8ad08d9d # v1.3.1
|
||||
continue-on-error: false
|
||||
env:
|
||||
K6_OTEL_METRIC_PREFIX: k6_
|
||||
|
||||
2
.github/workflows/protect-files.yml
vendored
2
.github/workflows/protect-files.yml
vendored
@@ -31,7 +31,7 @@ jobs:
|
||||
label: "DB-migrations-changed"
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
fetch-depth: 2
|
||||
persist-credentials: false
|
||||
|
||||
3
.github/workflows/publish.yml
vendored
3
.github/workflows/publish.yml
vendored
@@ -91,7 +91,6 @@ jobs:
|
||||
- project_name: Nginx
|
||||
- project_name: Notifications
|
||||
- project_name: Scim
|
||||
- project_name: Server
|
||||
- project_name: Setup
|
||||
- project_name: Sso
|
||||
steps:
|
||||
@@ -106,7 +105,7 @@ jobs:
|
||||
echo "Github Release Option: $RELEASE_OPTION"
|
||||
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@@ -39,7 +39,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -89,7 +89,7 @@ jobs:
|
||||
|
||||
- name: Create release
|
||||
if: ${{ inputs.release_type != 'Dry Run' }}
|
||||
uses: ncipollo/release-action@440c8c1cb0ed28b9f43e4d1d670870f059653174 # v1.16.0
|
||||
uses: ncipollo/release-action@b7eabc95ff50cbeeedec83973935c8f306dfcd0b # v1.20.0
|
||||
with:
|
||||
artifacts: "docker-stub-US.zip,
|
||||
docker-stub-EU.zip,
|
||||
|
||||
8
.github/workflows/repository-management.yml
vendored
8
.github/workflows/repository-management.yml
vendored
@@ -83,7 +83,7 @@ jobs:
|
||||
version: ${{ inputs.version_number_override }}
|
||||
|
||||
- name: Generate GH App token
|
||||
uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
|
||||
@@ -91,7 +91,7 @@ jobs:
|
||||
permission-contents: write
|
||||
|
||||
- name: Check out branch
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
ref: main
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
@@ -207,7 +207,7 @@ jobs:
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- name: Generate GH App token
|
||||
uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
|
||||
@@ -215,7 +215,7 @@ jobs:
|
||||
permission-contents: write
|
||||
|
||||
- name: Check out target ref
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
ref: ${{ inputs.target_ref }}
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
2
.github/workflows/review-code.yml
vendored
2
.github/workflows/review-code.yml
vendored
@@ -2,7 +2,7 @@ name: Code Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions: {}
|
||||
|
||||
|
||||
2
.github/workflows/stale-bot.yml
vendored
2
.github/workflows/stale-bot.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Check
|
||||
uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0
|
||||
uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
|
||||
with:
|
||||
stale-issue-label: "needs-reply"
|
||||
stale-pr-label: "needs-changes"
|
||||
|
||||
14
.github/workflows/test-database.yml
vendored
14
.github/workflows/test-database.yml
vendored
@@ -44,7 +44,7 @@ jobs:
|
||||
checks: write
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -156,7 +156,7 @@ jobs:
|
||||
run: 'docker logs "$(docker ps --quiet --filter "name=mssql")"'
|
||||
|
||||
- name: Report test results
|
||||
uses: dorny/test-reporter@890a17cecf52a379fc869ab770a71657660be727 # v2.1.0
|
||||
uses: dorny/test-reporter@fe45e9537387dac839af0d33ba56eed8e24189e8 # v2.3.0
|
||||
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !cancelled() }}
|
||||
with:
|
||||
name: Test Results
|
||||
@@ -165,7 +165,7 @@ jobs:
|
||||
fail-on-error: true
|
||||
|
||||
- name: Upload to codecov.io
|
||||
uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3
|
||||
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
|
||||
|
||||
- name: Docker Compose down
|
||||
if: always()
|
||||
@@ -178,7 +178,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -197,7 +197,7 @@ jobs:
|
||||
shell: pwsh
|
||||
|
||||
- name: Upload DACPAC
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: sql.dacpac
|
||||
path: Sql.dacpac
|
||||
@@ -223,7 +223,7 @@ jobs:
|
||||
shell: pwsh
|
||||
|
||||
- name: Report validation results
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: report.xml
|
||||
path: |
|
||||
@@ -269,7 +269,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
8
.github/workflows/test.yml
vendored
8
.github/workflows/test.yml
vendored
@@ -27,7 +27,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
toolchain: stable
|
||||
|
||||
- name: Cache cargo registry
|
||||
uses: Swatinem/rust-cache@f0deed1e0edfc6a9be95417288c0e1099b1eeec3 # v2.7.7
|
||||
uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2
|
||||
|
||||
- name: Print environment
|
||||
run: |
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
run: dotnet test ./bitwarden_license/test --configuration Debug --logger "trx;LogFileName=bw-test-results.trx" /p:CoverletOutputFormatter="cobertura" --collect:"XPlat Code Coverage"
|
||||
|
||||
- name: Report test results
|
||||
uses: dorny/test-reporter@890a17cecf52a379fc869ab770a71657660be727 # v2.1.0
|
||||
uses: dorny/test-reporter@fe45e9537387dac839af0d33ba56eed8e24189e8 # v2.3.0
|
||||
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !cancelled() }}
|
||||
with:
|
||||
name: Test Results
|
||||
@@ -68,4 +68,4 @@ jobs:
|
||||
fail-on-error: true
|
||||
|
||||
- name: Upload to codecov.io
|
||||
uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3
|
||||
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
|
||||
<Version>2025.12.0</Version>
|
||||
<Version>2025.12.2</Version>
|
||||
|
||||
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
|
||||
36
README.md
36
README.md
@@ -58,6 +58,42 @@ Invoke-RestMethod -OutFile bitwarden.ps1 `
|
||||
.\bitwarden.ps1 -start
|
||||
```
|
||||
|
||||
## Production Container Images
|
||||
|
||||
<details>
|
||||
<summary><b>View Current Production Image Hashes</b> (click to expand)</summary>
|
||||
<br>
|
||||
|
||||
### US Production Cluster
|
||||
|
||||
| Service | Image Hash |
|
||||
|---------|------------|
|
||||
| **Admin** |  |
|
||||
| **API** |  |
|
||||
| **Billing** |  |
|
||||
| **Events** |  |
|
||||
| **EventsProcessor** |  |
|
||||
| **Identity** |  |
|
||||
| **Notifications** |  |
|
||||
| **SCIM** |  |
|
||||
| **SSO** |  |
|
||||
|
||||
### EU Production Cluster
|
||||
|
||||
| Service | Image Hash |
|
||||
|---------|------------|
|
||||
| **Admin** |  |
|
||||
| **API** |  |
|
||||
| **Billing** |  |
|
||||
| **Events** |  |
|
||||
| **EventsProcessor** |  |
|
||||
| **Identity** |  |
|
||||
| **Notifications** |  |
|
||||
| **SCIM** |  |
|
||||
| **SSO** |  |
|
||||
|
||||
</details>
|
||||
|
||||
## We're Hiring!
|
||||
|
||||
Interested in contributing in a big way? Consider joining our team! We're hiring for many positions. Please take a look at our [Careers page](https://bitwarden.com/careers/) to see what opportunities are currently open as well as what it's like to work at Bitwarden.
|
||||
|
||||
@@ -9,6 +9,9 @@ using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.Models.Business.Provider;
|
||||
using Bit.Core.AdminConsole.Models.Business.Tokenables;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Billing.Enums;
|
||||
@@ -60,6 +63,7 @@ public class ProviderService : IProviderService
|
||||
private readonly IProviderBillingService _providerBillingService;
|
||||
private readonly IPricingClient _pricingClient;
|
||||
private readonly IProviderClientOrganizationSignUpCommand _providerClientOrganizationSignUpCommand;
|
||||
private readonly IPolicyRequirementQuery _policyRequirementQuery;
|
||||
|
||||
public ProviderService(IProviderRepository providerRepository, IProviderUserRepository providerUserRepository,
|
||||
IProviderOrganizationRepository providerOrganizationRepository, IUserRepository userRepository,
|
||||
@@ -69,7 +73,8 @@ public class ProviderService : IProviderService
|
||||
ICurrentContext currentContext, IStripeAdapter stripeAdapter, IFeatureService featureService,
|
||||
IDataProtectorTokenFactory<ProviderDeleteTokenable> providerDeleteTokenDataFactory,
|
||||
IApplicationCacheService applicationCacheService, IProviderBillingService providerBillingService, IPricingClient pricingClient,
|
||||
IProviderClientOrganizationSignUpCommand providerClientOrganizationSignUpCommand)
|
||||
IProviderClientOrganizationSignUpCommand providerClientOrganizationSignUpCommand,
|
||||
IPolicyRequirementQuery policyRequirementQuery)
|
||||
{
|
||||
_providerRepository = providerRepository;
|
||||
_providerUserRepository = providerUserRepository;
|
||||
@@ -90,6 +95,7 @@ public class ProviderService : IProviderService
|
||||
_providerBillingService = providerBillingService;
|
||||
_pricingClient = pricingClient;
|
||||
_providerClientOrganizationSignUpCommand = providerClientOrganizationSignUpCommand;
|
||||
_policyRequirementQuery = policyRequirementQuery;
|
||||
}
|
||||
|
||||
public async Task<Provider> CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TokenizedPaymentMethod paymentMethod, BillingAddress billingAddress)
|
||||
@@ -117,6 +123,18 @@ public class ProviderService : IProviderService
|
||||
throw new BadRequestException("Invalid owner.");
|
||||
}
|
||||
|
||||
if (_featureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers))
|
||||
{
|
||||
var organizationAutoConfirmPolicyRequirement = await _policyRequirementQuery
|
||||
.GetAsync<AutomaticUserConfirmationPolicyRequirement>(ownerUserId);
|
||||
|
||||
if (organizationAutoConfirmPolicyRequirement
|
||||
.CannotCreateProvider())
|
||||
{
|
||||
throw new BadRequestException(new UserCannotJoinProvider().Message);
|
||||
}
|
||||
}
|
||||
|
||||
var customer = await _providerBillingService.SetupCustomer(provider, paymentMethod, billingAddress);
|
||||
provider.GatewayCustomerId = customer.Id;
|
||||
var subscription = await _providerBillingService.SetupSubscription(provider);
|
||||
@@ -249,6 +267,18 @@ public class ProviderService : IProviderService
|
||||
throw new BadRequestException("User email does not match invite.");
|
||||
}
|
||||
|
||||
if (_featureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers))
|
||||
{
|
||||
var organizationAutoConfirmPolicyRequirement = await _policyRequirementQuery
|
||||
.GetAsync<AutomaticUserConfirmationPolicyRequirement>(user.Id);
|
||||
|
||||
if (organizationAutoConfirmPolicyRequirement
|
||||
.CannotJoinProvider())
|
||||
{
|
||||
throw new BadRequestException(new UserCannotJoinProvider().Message);
|
||||
}
|
||||
}
|
||||
|
||||
providerUser.Status = ProviderUserStatusType.Accepted;
|
||||
providerUser.UserId = user.Id;
|
||||
providerUser.Email = null;
|
||||
@@ -294,6 +324,19 @@ public class ProviderService : IProviderService
|
||||
throw new BadRequestException("Invalid user.");
|
||||
}
|
||||
|
||||
if (_featureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers))
|
||||
{
|
||||
var organizationAutoConfirmPolicyRequirement = await _policyRequirementQuery
|
||||
.GetAsync<AutomaticUserConfirmationPolicyRequirement>(user.Id);
|
||||
|
||||
if (organizationAutoConfirmPolicyRequirement
|
||||
.CannotJoinProvider())
|
||||
{
|
||||
result.Add(Tuple.Create(providerUser, new UserCannotJoinProvider().Message));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
providerUser.Status = ProviderUserStatusType.Confirmed;
|
||||
providerUser.Key = keys[providerUser.Id];
|
||||
providerUser.Email = null;
|
||||
|
||||
@@ -796,6 +796,44 @@ public class ProviderBillingService(
|
||||
}
|
||||
}
|
||||
|
||||
public async Task UpdateProviderNameAndEmail(Provider provider)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(provider.GatewayCustomerId))
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Provider ({ProviderId}) has no Stripe customer to update",
|
||||
provider.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
var newDisplayName = provider.DisplayName();
|
||||
|
||||
// Provider.DisplayName() can return null - handle gracefully
|
||||
if (string.IsNullOrWhiteSpace(newDisplayName))
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Provider ({ProviderId}) has no name to update in Stripe",
|
||||
provider.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
await stripeAdapter.UpdateCustomerAsync(provider.GatewayCustomerId,
|
||||
new CustomerUpdateOptions
|
||||
{
|
||||
Email = provider.BillingEmail,
|
||||
Description = newDisplayName,
|
||||
InvoiceSettings = new CustomerInvoiceSettingsOptions
|
||||
{
|
||||
CustomFields = [
|
||||
new CustomerInvoiceSettingsCustomFieldOptions
|
||||
{
|
||||
Name = provider.SubscriberType(),
|
||||
Value = newDisplayName
|
||||
}]
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private Func<int, Task> CurrySeatScalingUpdate(
|
||||
Provider provider,
|
||||
ProviderPlan providerPlan,
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
using Bit.Commercial.Core.AdminConsole.Services;
|
||||
using Bit.Commercial.Core.Test.AdminConsole.AutoFixture;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.Models.Business.Provider;
|
||||
using Bit.Core.AdminConsole.Models.Business.Tokenables;
|
||||
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
|
||||
using Bit.Core.AdminConsole.Models.Data.Provider;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Payment.Models;
|
||||
@@ -101,6 +106,57 @@ public class ProviderServiceTests
|
||||
.ReplaceAsync(Arg.Is<ProviderUser>(pu => pu.UserId == user.Id && pu.ProviderId == provider.Id && pu.Key == key));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task CompleteSetupAsync_WithAutoConfirmEnabled_ThrowsUserCannotJoinProviderError(User user, Provider provider,
|
||||
string key,
|
||||
TokenizedPaymentMethod tokenizedPaymentMethod, BillingAddress billingAddress,
|
||||
[ProviderUser] ProviderUser providerUser,
|
||||
SutProvider<ProviderService> sutProvider)
|
||||
{
|
||||
providerUser.ProviderId = provider.Id;
|
||||
providerUser.UserId = user.Id;
|
||||
var userService = sutProvider.GetDependency<IUserService>();
|
||||
userService.GetUserByIdAsync(user.Id).Returns(user);
|
||||
|
||||
var providerUserRepository = sutProvider.GetDependency<IProviderUserRepository>();
|
||||
providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id).Returns(providerUser);
|
||||
|
||||
var dataProtectionProvider = DataProtectionProvider.Create("ApplicationName");
|
||||
var protector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector");
|
||||
sutProvider.GetDependency<IDataProtectionProvider>().CreateProtector("ProviderServiceDataProtector")
|
||||
.Returns(protector);
|
||||
|
||||
var providerBillingService = sutProvider.GetDependency<IProviderBillingService>();
|
||||
|
||||
var customer = new Customer { Id = "customer_id" };
|
||||
providerBillingService.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress).Returns(customer);
|
||||
|
||||
var subscription = new Subscription { Id = "subscription_id" };
|
||||
providerBillingService.SetupSubscription(provider).Returns(subscription);
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)
|
||||
.Returns(true);
|
||||
|
||||
var policyDetails = new List<PolicyDetails> { new() { OrganizationId = Guid.NewGuid(), IsProvider = false } };
|
||||
var policyRequirement = new AutomaticUserConfirmationPolicyRequirement(policyDetails);
|
||||
sutProvider.GetDependency<IPolicyRequirementQuery>()
|
||||
.GetAsync<AutomaticUserConfirmationPolicyRequirement>(user.Id)
|
||||
.Returns(policyRequirement);
|
||||
|
||||
sutProvider.Create();
|
||||
|
||||
var token = protector.Protect(
|
||||
$"ProviderSetupInvite {provider.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}");
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
|
||||
sutProvider.Sut.CompleteSetupAsync(provider, user.Id, token, key, tokenizedPaymentMethod,
|
||||
billingAddress));
|
||||
|
||||
Assert.Equal(new UserCannotJoinProvider().Message, exception.Message);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateAsync_ProviderIdIsInvalid_Throws(Provider provider, SutProvider<ProviderService> sutProvider)
|
||||
{
|
||||
@@ -580,6 +636,132 @@ public class ProviderServiceTests
|
||||
Assert.Equal(user.Id, pu.UserId);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task AcceptUserAsync_WithAutoConfirmEnabledAndPolicyExists_Throws(
|
||||
[ProviderUser(ProviderUserStatusType.Invited)] ProviderUser providerUser,
|
||||
User user,
|
||||
SutProvider<ProviderService> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
sutProvider.GetDependency<IProviderUserRepository>()
|
||||
.GetByIdAsync(providerUser.Id)
|
||||
.Returns(providerUser);
|
||||
|
||||
var protector = DataProtectionProvider
|
||||
.Create("ApplicationName")
|
||||
.CreateProtector("ProviderServiceDataProtector");
|
||||
|
||||
sutProvider.GetDependency<IDataProtectionProvider>()
|
||||
.CreateProtector("ProviderServiceDataProtector")
|
||||
.Returns(protector);
|
||||
|
||||
sutProvider.Create();
|
||||
|
||||
providerUser.Email = user.Email;
|
||||
var token = protector.Protect($"ProviderUserInvite {providerUser.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}");
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)
|
||||
.Returns(true);
|
||||
|
||||
var policyDetails = new List<PolicyDetails>
|
||||
{
|
||||
new() { OrganizationId = Guid.NewGuid(), IsProvider = false }
|
||||
};
|
||||
var policyRequirement = new AutomaticUserConfirmationPolicyRequirement(policyDetails);
|
||||
sutProvider.GetDependency<IPolicyRequirementQuery>()
|
||||
.GetAsync<AutomaticUserConfirmationPolicyRequirement>(user.Id)
|
||||
.Returns(policyRequirement);
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
() => sutProvider.Sut.AcceptUserAsync(providerUser.Id, user, token));
|
||||
|
||||
Assert.Equal(new UserCannotJoinProvider().Message, exception.Message);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task AcceptUserAsync_WithAutoConfirmEnabledButNoPolicyExists_Success(
|
||||
[ProviderUser(ProviderUserStatusType.Invited)] ProviderUser providerUser,
|
||||
User user,
|
||||
SutProvider<ProviderService> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
sutProvider.GetDependency<IProviderUserRepository>()
|
||||
.GetByIdAsync(providerUser.Id)
|
||||
.Returns(providerUser);
|
||||
|
||||
var protector = DataProtectionProvider
|
||||
.Create("ApplicationName")
|
||||
.CreateProtector("ProviderServiceDataProtector");
|
||||
|
||||
sutProvider.GetDependency<IDataProtectionProvider>()
|
||||
.CreateProtector("ProviderServiceDataProtector")
|
||||
.Returns(protector);
|
||||
sutProvider.Create();
|
||||
|
||||
providerUser.Email = user.Email;
|
||||
var token = protector.Protect($"ProviderUserInvite {providerUser.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}");
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)
|
||||
.Returns(true);
|
||||
|
||||
var policyRequirement = new AutomaticUserConfirmationPolicyRequirement([]);
|
||||
sutProvider.GetDependency<IPolicyRequirementQuery>()
|
||||
.GetAsync<AutomaticUserConfirmationPolicyRequirement>(user.Id)
|
||||
.Returns(policyRequirement);
|
||||
|
||||
// Act
|
||||
var pu = await sutProvider.Sut.AcceptUserAsync(providerUser.Id, user, token);
|
||||
|
||||
// Assert
|
||||
Assert.Null(pu.Email);
|
||||
Assert.Equal(ProviderUserStatusType.Accepted, pu.Status);
|
||||
Assert.Equal(user.Id, pu.UserId);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task AcceptUserAsync_WithAutoConfirmDisabled_Success(
|
||||
[ProviderUser(ProviderUserStatusType.Invited)] ProviderUser providerUser,
|
||||
User user,
|
||||
SutProvider<ProviderService> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
sutProvider.GetDependency<IProviderUserRepository>()
|
||||
.GetByIdAsync(providerUser.Id)
|
||||
.Returns(providerUser);
|
||||
|
||||
var protector = DataProtectionProvider
|
||||
.Create("ApplicationName")
|
||||
.CreateProtector("ProviderServiceDataProtector");
|
||||
|
||||
sutProvider.GetDependency<IDataProtectionProvider>()
|
||||
.CreateProtector("ProviderServiceDataProtector")
|
||||
.Returns(protector);
|
||||
sutProvider.Create();
|
||||
|
||||
providerUser.Email = user.Email;
|
||||
var token = protector.Protect($"ProviderUserInvite {providerUser.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}");
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)
|
||||
.Returns(false);
|
||||
|
||||
// Act
|
||||
var pu = await sutProvider.Sut.AcceptUserAsync(providerUser.Id, user, token);
|
||||
|
||||
// Assert
|
||||
Assert.Null(pu.Email);
|
||||
Assert.Equal(ProviderUserStatusType.Accepted, pu.Status);
|
||||
Assert.Equal(user.Id, pu.UserId);
|
||||
|
||||
// Verify that policy check was never called when feature flag is disabled
|
||||
await sutProvider.GetDependency<IPolicyRequirementQuery>()
|
||||
.DidNotReceive()
|
||||
.GetAsync<AutomaticUserConfirmationPolicyRequirement>(user.Id);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ConfirmUsersAsync_NoValid(
|
||||
[ProviderUser(ProviderUserStatusType.Invited)] ProviderUser pu1,
|
||||
@@ -626,13 +808,131 @@ public class ProviderServiceTests
|
||||
Assert.Equal("Invalid user.", result[2].Item2);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ConfirmUsersAsync_WithAutoConfirmEnabledAndPolicyExists_ReturnsError(
|
||||
[ProviderUser(ProviderUserStatusType.Accepted)] ProviderUser pu1, User u1,
|
||||
Provider provider, User confirmingUser, SutProvider<ProviderService> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
pu1.ProviderId = provider.Id;
|
||||
pu1.UserId = u1.Id;
|
||||
var providerUsers = new[] { pu1 };
|
||||
|
||||
var providerUserRepository = sutProvider.GetDependency<IProviderUserRepository>();
|
||||
providerUserRepository.GetManyAsync([]).ReturnsForAnyArgs(providerUsers);
|
||||
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
|
||||
sutProvider.GetDependency<IUserRepository>().GetManyAsync([]).ReturnsForAnyArgs([u1]);
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)
|
||||
.Returns(true);
|
||||
|
||||
var policyDetails = new List<PolicyDetails>
|
||||
{
|
||||
new() { OrganizationId = Guid.NewGuid(), IsProvider = false }
|
||||
};
|
||||
var policyRequirement = new AutomaticUserConfirmationPolicyRequirement(policyDetails);
|
||||
sutProvider.GetDependency<IPolicyRequirementQuery>()
|
||||
.GetAsync<AutomaticUserConfirmationPolicyRequirement>(u1.Id)
|
||||
.Returns(policyRequirement);
|
||||
|
||||
var dict = providerUsers.ToDictionary(pu => pu.Id, _ => "key");
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.ConfirmUsersAsync(pu1.ProviderId, dict, confirmingUser.Id);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
Assert.Equal(new UserCannotJoinProvider().Message, result[0].Item2);
|
||||
|
||||
// Verify user was not confirmed
|
||||
await providerUserRepository.DidNotReceive().ReplaceAsync(Arg.Any<ProviderUser>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ConfirmUsersAsync_WithAutoConfirmEnabledButNoPolicyExists_Success(
|
||||
[ProviderUser(ProviderUserStatusType.Accepted)] ProviderUser pu1, User u1,
|
||||
Provider provider, User confirmingUser, SutProvider<ProviderService> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
pu1.ProviderId = provider.Id;
|
||||
pu1.UserId = u1.Id;
|
||||
var providerUsers = new[] { pu1 };
|
||||
|
||||
var providerUserRepository = sutProvider.GetDependency<IProviderUserRepository>();
|
||||
providerUserRepository.GetManyAsync([]).ReturnsForAnyArgs(providerUsers);
|
||||
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
|
||||
sutProvider.GetDependency<IUserRepository>().GetManyAsync([]).ReturnsForAnyArgs([u1]);
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)
|
||||
.Returns(true);
|
||||
|
||||
var policyRequirement = new AutomaticUserConfirmationPolicyRequirement(new List<PolicyDetails>());
|
||||
sutProvider.GetDependency<IPolicyRequirementQuery>()
|
||||
.GetAsync<AutomaticUserConfirmationPolicyRequirement>(u1.Id)
|
||||
.Returns(policyRequirement);
|
||||
|
||||
var dict = providerUsers.ToDictionary(pu => pu.Id, _ => "key");
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.ConfirmUsersAsync(pu1.ProviderId, dict, confirmingUser.Id);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
Assert.Equal("", result[0].Item2);
|
||||
|
||||
// Verify user was confirmed
|
||||
await providerUserRepository.Received(1).ReplaceAsync(Arg.Is<ProviderUser>(pu =>
|
||||
pu.Status == ProviderUserStatusType.Confirmed));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ConfirmUsersAsync_WithAutoConfirmDisabled_Success(
|
||||
[ProviderUser(ProviderUserStatusType.Accepted)] ProviderUser pu1, User u1,
|
||||
Provider provider, User confirmingUser, SutProvider<ProviderService> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
pu1.ProviderId = provider.Id;
|
||||
pu1.UserId = u1.Id;
|
||||
var providerUsers = new[] { pu1 };
|
||||
|
||||
var providerUserRepository = sutProvider.GetDependency<IProviderUserRepository>();
|
||||
providerUserRepository.GetManyAsync([]).ReturnsForAnyArgs(providerUsers);
|
||||
|
||||
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
|
||||
sutProvider.GetDependency<IUserRepository>().GetManyAsync([]).ReturnsForAnyArgs([u1]);
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)
|
||||
.Returns(false);
|
||||
|
||||
var dict = providerUsers.ToDictionary(pu => pu.Id, _ => "key");
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.ConfirmUsersAsync(pu1.ProviderId, dict, confirmingUser.Id);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
Assert.Equal("", result[0].Item2);
|
||||
|
||||
// Verify user was confirmed
|
||||
await providerUserRepository.Received(1).ReplaceAsync(Arg.Is<ProviderUser>(pu =>
|
||||
pu.Status == ProviderUserStatusType.Confirmed));
|
||||
|
||||
// Verify that policy check was never called when feature flag is disabled
|
||||
await sutProvider.GetDependency<IPolicyRequirementQuery>()
|
||||
.DidNotReceive()
|
||||
.GetAsync<AutomaticUserConfirmationPolicyRequirement>(Arg.Any<Guid>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task SaveUserAsync_UserIdIsInvalid_Throws(ProviderUser providerUser,
|
||||
SutProvider<ProviderService> sutProvider)
|
||||
{
|
||||
providerUser.Id = default;
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
() => sutProvider.Sut.SaveUserAsync(providerUser, default));
|
||||
providerUser.Id = Guid.Empty;
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
|
||||
sutProvider.Sut.SaveUserAsync(providerUser, Guid.Empty));
|
||||
Assert.Equal("Invite the user first.", exception.Message);
|
||||
}
|
||||
|
||||
|
||||
@@ -2150,4 +2150,151 @@ public class ProviderBillingServiceTests
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region UpdateProviderNameAndEmail
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateProviderNameAndEmail_NullGatewayCustomerId_LogsWarningAndReturns(
|
||||
Provider provider,
|
||||
SutProvider<ProviderBillingService> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
provider.GatewayCustomerId = null;
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.UpdateProviderNameAndEmail(provider);
|
||||
|
||||
// Assert
|
||||
await stripeAdapter.DidNotReceive().UpdateCustomerAsync(
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<CustomerUpdateOptions>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateProviderNameAndEmail_EmptyGatewayCustomerId_LogsWarningAndReturns(
|
||||
Provider provider,
|
||||
SutProvider<ProviderBillingService> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
provider.GatewayCustomerId = "";
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.UpdateProviderNameAndEmail(provider);
|
||||
|
||||
// Assert
|
||||
await stripeAdapter.DidNotReceive().UpdateCustomerAsync(
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<CustomerUpdateOptions>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateProviderNameAndEmail_NullProviderName_LogsWarningAndReturns(
|
||||
Provider provider,
|
||||
SutProvider<ProviderBillingService> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
provider.Name = null;
|
||||
provider.GatewayCustomerId = "cus_test123";
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.UpdateProviderNameAndEmail(provider);
|
||||
|
||||
// Assert
|
||||
await stripeAdapter.DidNotReceive().UpdateCustomerAsync(
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<CustomerUpdateOptions>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateProviderNameAndEmail_EmptyProviderName_LogsWarningAndReturns(
|
||||
Provider provider,
|
||||
SutProvider<ProviderBillingService> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
provider.Name = "";
|
||||
provider.GatewayCustomerId = "cus_test123";
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.UpdateProviderNameAndEmail(provider);
|
||||
|
||||
// Assert
|
||||
await stripeAdapter.DidNotReceive().UpdateCustomerAsync(
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<CustomerUpdateOptions>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateProviderNameAndEmail_ValidProvider_CallsStripeWithCorrectParameters(
|
||||
Provider provider,
|
||||
SutProvider<ProviderBillingService> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
provider.Name = "Test Provider";
|
||||
provider.BillingEmail = "billing@test.com";
|
||||
provider.GatewayCustomerId = "cus_test123";
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.UpdateProviderNameAndEmail(provider);
|
||||
|
||||
// Assert
|
||||
await stripeAdapter.Received(1).UpdateCustomerAsync(
|
||||
provider.GatewayCustomerId,
|
||||
Arg.Is<CustomerUpdateOptions>(options =>
|
||||
options.Email == provider.BillingEmail &&
|
||||
options.Description == provider.Name &&
|
||||
options.InvoiceSettings.CustomFields.Count == 1 &&
|
||||
options.InvoiceSettings.CustomFields[0].Name == "Provider" &&
|
||||
options.InvoiceSettings.CustomFields[0].Value == provider.Name));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateProviderNameAndEmail_LongProviderName_UsesFullName(
|
||||
Provider provider,
|
||||
SutProvider<ProviderBillingService> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
var longName = new string('A', 50); // 50 characters
|
||||
provider.Name = longName;
|
||||
provider.BillingEmail = "billing@test.com";
|
||||
provider.GatewayCustomerId = "cus_test123";
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.UpdateProviderNameAndEmail(provider);
|
||||
|
||||
// Assert
|
||||
await stripeAdapter.Received(1).UpdateCustomerAsync(
|
||||
provider.GatewayCustomerId,
|
||||
Arg.Is<CustomerUpdateOptions>(options =>
|
||||
options.InvoiceSettings.CustomFields[0].Value == longName));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateProviderNameAndEmail_NullBillingEmail_UpdatesWithNull(
|
||||
Provider provider,
|
||||
SutProvider<ProviderBillingService> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
provider.Name = "Test Provider";
|
||||
provider.BillingEmail = null;
|
||||
provider.GatewayCustomerId = "cus_test123";
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.UpdateProviderNameAndEmail(provider);
|
||||
|
||||
// Assert
|
||||
await stripeAdapter.Received(1).UpdateCustomerAsync(
|
||||
provider.GatewayCustomerId,
|
||||
Arg.Is<CustomerUpdateOptions>(options =>
|
||||
options.Email == null &&
|
||||
options.Description == provider.Name));
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ services:
|
||||
- idp
|
||||
|
||||
rabbitmq:
|
||||
image: rabbitmq:4.1.3-management
|
||||
image: rabbitmq:4.2.0-management
|
||||
ports:
|
||||
- "5672:5672"
|
||||
- "15672:15672"
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
},
|
||||
"msbuild-sdks": {
|
||||
"Microsoft.Build.Traversal": "4.1.0",
|
||||
"Microsoft.Build.Sql": "1.0.0"
|
||||
"Microsoft.Build.Sql": "1.0.0",
|
||||
"Bitwarden.Server.Sdk": "1.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ using Bit.Core.AdminConsole.Providers.Interfaces;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Organizations.Services;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Providers.Services;
|
||||
using Bit.Core.Billing.Services;
|
||||
@@ -57,6 +58,7 @@ public class OrganizationsController : Controller
|
||||
private readonly IOrganizationInitiateDeleteCommand _organizationInitiateDeleteCommand;
|
||||
private readonly IPricingClient _pricingClient;
|
||||
private readonly IResendOrganizationInviteCommand _resendOrganizationInviteCommand;
|
||||
private readonly IOrganizationBillingService _organizationBillingService;
|
||||
|
||||
public OrganizationsController(
|
||||
IOrganizationRepository organizationRepository,
|
||||
@@ -81,7 +83,8 @@ public class OrganizationsController : Controller
|
||||
IProviderBillingService providerBillingService,
|
||||
IOrganizationInitiateDeleteCommand organizationInitiateDeleteCommand,
|
||||
IPricingClient pricingClient,
|
||||
IResendOrganizationInviteCommand resendOrganizationInviteCommand)
|
||||
IResendOrganizationInviteCommand resendOrganizationInviteCommand,
|
||||
IOrganizationBillingService organizationBillingService)
|
||||
{
|
||||
_organizationRepository = organizationRepository;
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
@@ -106,6 +109,7 @@ public class OrganizationsController : Controller
|
||||
_organizationInitiateDeleteCommand = organizationInitiateDeleteCommand;
|
||||
_pricingClient = pricingClient;
|
||||
_resendOrganizationInviteCommand = resendOrganizationInviteCommand;
|
||||
_organizationBillingService = organizationBillingService;
|
||||
}
|
||||
|
||||
[RequirePermission(Permission.Org_List_View)]
|
||||
@@ -242,6 +246,8 @@ public class OrganizationsController : Controller
|
||||
var existingOrganizationData = new Organization
|
||||
{
|
||||
Id = organization.Id,
|
||||
Name = organization.Name,
|
||||
BillingEmail = organization.BillingEmail,
|
||||
Status = organization.Status,
|
||||
PlanType = organization.PlanType,
|
||||
Seats = organization.Seats
|
||||
@@ -287,6 +293,22 @@ public class OrganizationsController : Controller
|
||||
|
||||
await _applicationCacheService.UpsertOrganizationAbilityAsync(organization);
|
||||
|
||||
// Sync name/email changes to Stripe
|
||||
if (existingOrganizationData.Name != organization.Name || existingOrganizationData.BillingEmail != organization.BillingEmail)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _organizationBillingService.UpdateOrganizationNameAndEmail(organization);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"Failed to update Stripe customer for organization {OrganizationId}. Database was updated successfully.",
|
||||
organization.Id);
|
||||
TempData["Warning"] = "Organization updated successfully, but Stripe customer name/email synchronization failed.";
|
||||
}
|
||||
}
|
||||
|
||||
return RedirectToAction("Edit", new { id });
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ public class ProvidersController : Controller
|
||||
private readonly IStripeAdapter _stripeAdapter;
|
||||
private readonly IAccessControlService _accessControlService;
|
||||
private readonly ISubscriberService _subscriberService;
|
||||
private readonly ILogger<ProvidersController> _logger;
|
||||
|
||||
public ProvidersController(IOrganizationRepository organizationRepository,
|
||||
IResellerClientOrganizationSignUpCommand resellerClientOrganizationSignUpCommand,
|
||||
@@ -72,7 +73,8 @@ public class ProvidersController : Controller
|
||||
IPricingClient pricingClient,
|
||||
IStripeAdapter stripeAdapter,
|
||||
IAccessControlService accessControlService,
|
||||
ISubscriberService subscriberService)
|
||||
ISubscriberService subscriberService,
|
||||
ILogger<ProvidersController> logger)
|
||||
{
|
||||
_organizationRepository = organizationRepository;
|
||||
_resellerClientOrganizationSignUpCommand = resellerClientOrganizationSignUpCommand;
|
||||
@@ -92,6 +94,7 @@ public class ProvidersController : Controller
|
||||
_braintreeMerchantUrl = webHostEnvironment.GetBraintreeMerchantUrl();
|
||||
_braintreeMerchantId = globalSettings.Braintree.MerchantId;
|
||||
_subscriberService = subscriberService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[RequirePermission(Permission.Provider_List_View)]
|
||||
@@ -296,6 +299,9 @@ public class ProvidersController : Controller
|
||||
|
||||
var originalProviderStatus = provider.Enabled;
|
||||
|
||||
// Capture original billing email before modifications for Stripe sync
|
||||
var originalBillingEmail = provider.BillingEmail;
|
||||
|
||||
model.ToProvider(provider);
|
||||
|
||||
// validate the stripe ids to prevent saving a bad one
|
||||
@@ -321,6 +327,22 @@ public class ProvidersController : Controller
|
||||
await _providerService.UpdateAsync(provider);
|
||||
await _applicationCacheService.UpsertProviderAbilityAsync(provider);
|
||||
|
||||
// Sync billing email changes to Stripe
|
||||
if (!string.IsNullOrEmpty(provider.GatewayCustomerId) && originalBillingEmail != provider.BillingEmail)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _providerBillingService.UpdateProviderNameAndEmail(provider);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"Failed to update Stripe customer for provider {ProviderId}. Database was updated successfully.",
|
||||
provider.Id);
|
||||
TempData["Warning"] = "Provider updated successfully, but Stripe customer email synchronization failed.";
|
||||
}
|
||||
}
|
||||
|
||||
if (!provider.IsBillable())
|
||||
{
|
||||
return RedirectToAction("Edit", new { id });
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
###############################################
|
||||
# Node.js build stage #
|
||||
###############################################
|
||||
FROM node:20-alpine3.21 AS node-build
|
||||
FROM --platform=$BUILDPLATFORM node:20-alpine3.21 AS node-build
|
||||
|
||||
WORKDIR /app
|
||||
COPY src/Admin/package*.json ./
|
||||
|
||||
@@ -57,8 +57,7 @@ public class ProviderClientsController(
|
||||
Owner = user,
|
||||
BillingEmail = provider.BillingEmail,
|
||||
OwnerKey = requestBody.Key,
|
||||
PublicKey = requestBody.KeyPair.PublicKey,
|
||||
PrivateKey = requestBody.KeyPair.EncryptedPrivateKey,
|
||||
Keys = requestBody.KeyPair.ToPublicKeyEncryptionKeyPairData(),
|
||||
CollectionName = requestBody.CollectionName,
|
||||
IsFromProvider = true
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ using Bit.Api.AdminConsole.Models.Request.Providers;
|
||||
using Bit.Api.AdminConsole.Models.Response.Providers;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Billing.Providers.Services;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Services;
|
||||
@@ -23,15 +24,20 @@ public class ProvidersController : Controller
|
||||
private readonly IProviderService _providerService;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
private readonly IProviderBillingService _providerBillingService;
|
||||
private readonly ILogger<ProvidersController> _logger;
|
||||
|
||||
public ProvidersController(IUserService userService, IProviderRepository providerRepository,
|
||||
IProviderService providerService, ICurrentContext currentContext, GlobalSettings globalSettings)
|
||||
IProviderService providerService, ICurrentContext currentContext, GlobalSettings globalSettings,
|
||||
IProviderBillingService providerBillingService, ILogger<ProvidersController> logger)
|
||||
{
|
||||
_userService = userService;
|
||||
_providerRepository = providerRepository;
|
||||
_providerService = providerService;
|
||||
_currentContext = currentContext;
|
||||
_globalSettings = globalSettings;
|
||||
_providerBillingService = providerBillingService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
@@ -65,7 +71,27 @@ public class ProvidersController : Controller
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
// Capture original values before modifications for Stripe sync
|
||||
var originalName = provider.Name;
|
||||
var originalBillingEmail = provider.BillingEmail;
|
||||
|
||||
await _providerService.UpdateAsync(model.ToProvider(provider, _globalSettings));
|
||||
|
||||
// Sync name/email changes to Stripe
|
||||
if (originalName != provider.Name || originalBillingEmail != provider.BillingEmail)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _providerBillingService.UpdateProviderNameAndEmail(provider);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"Failed to update Stripe customer for provider {ProviderId}. Database was updated successfully.",
|
||||
provider.Id);
|
||||
}
|
||||
}
|
||||
|
||||
return new ProviderResponseModel(provider);
|
||||
}
|
||||
|
||||
|
||||
@@ -113,11 +113,10 @@ public class OrganizationCreateRequestModel : IValidatableObject
|
||||
BillingAddressCountry = BillingAddressCountry,
|
||||
},
|
||||
InitiationPath = InitiationPath,
|
||||
SkipTrial = SkipTrial
|
||||
SkipTrial = SkipTrial,
|
||||
Keys = Keys?.ToPublicKeyEncryptionKeyPairData()
|
||||
};
|
||||
|
||||
Keys?.ToOrganizationSignup(orgSignup);
|
||||
|
||||
return orgSignup;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
#nullable disable
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Models.Business;
|
||||
using Bit.Core.KeyManagement.Models.Data;
|
||||
|
||||
namespace Bit.Api.AdminConsole.Models.Request.Organizations;
|
||||
|
||||
@@ -14,48 +13,10 @@ public class OrganizationKeysRequestModel
|
||||
[Required]
|
||||
public string EncryptedPrivateKey { get; set; }
|
||||
|
||||
public OrganizationSignup ToOrganizationSignup(OrganizationSignup existingSignup)
|
||||
public PublicKeyEncryptionKeyPairData ToPublicKeyEncryptionKeyPairData()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(existingSignup.PublicKey))
|
||||
{
|
||||
existingSignup.PublicKey = PublicKey;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(existingSignup.PrivateKey))
|
||||
{
|
||||
existingSignup.PrivateKey = EncryptedPrivateKey;
|
||||
}
|
||||
|
||||
return existingSignup;
|
||||
}
|
||||
|
||||
public OrganizationUpgrade ToOrganizationUpgrade(OrganizationUpgrade existingUpgrade)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(existingUpgrade.PublicKey))
|
||||
{
|
||||
existingUpgrade.PublicKey = PublicKey;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(existingUpgrade.PrivateKey))
|
||||
{
|
||||
existingUpgrade.PrivateKey = EncryptedPrivateKey;
|
||||
}
|
||||
|
||||
return existingUpgrade;
|
||||
}
|
||||
|
||||
public Organization ToOrganization(Organization existingOrg)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(existingOrg.PublicKey))
|
||||
{
|
||||
existingOrg.PublicKey = PublicKey;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(existingOrg.PrivateKey))
|
||||
{
|
||||
existingOrg.PrivateKey = EncryptedPrivateKey;
|
||||
}
|
||||
|
||||
return existingOrg;
|
||||
return new PublicKeyEncryptionKeyPairData(
|
||||
wrappedPrivateKey: EncryptedPrivateKey,
|
||||
publicKey: PublicKey);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,10 +110,9 @@ public class OrganizationNoPaymentCreateRequest
|
||||
BillingAddressCountry = BillingAddressCountry,
|
||||
},
|
||||
InitiationPath = InitiationPath,
|
||||
Keys = Keys?.ToPublicKeyEncryptionKeyPairData()
|
||||
};
|
||||
|
||||
Keys?.ToOrganizationSignup(orgSignup);
|
||||
|
||||
return orgSignup;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ public class OrganizationUpdateRequestModel
|
||||
OrganizationId = organizationId,
|
||||
Name = Name,
|
||||
BillingEmail = BillingEmail,
|
||||
PublicKey = Keys?.PublicKey,
|
||||
EncryptedPrivateKey = Keys?.EncryptedPrivateKey
|
||||
Keys = Keys?.ToPublicKeyEncryptionKeyPairData()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -43,11 +43,10 @@ public class OrganizationUpgradeRequestModel
|
||||
{
|
||||
BillingAddressCountry = BillingAddressCountry,
|
||||
BillingAddressPostalCode = BillingAddressPostalCode
|
||||
}
|
||||
},
|
||||
Keys = Keys?.ToPublicKeyEncryptionKeyPairData()
|
||||
};
|
||||
|
||||
Keys?.ToOrganizationUpgrade(orgUpgrade);
|
||||
|
||||
return orgUpgrade;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#nullable disable
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
using Bit.Api.Models.Public.Response;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Models.Data;
|
||||
@@ -13,6 +14,12 @@ namespace Bit.Api.AdminConsole.Public.Models.Response;
|
||||
/// </summary>
|
||||
public class GroupResponseModel : GroupBaseModel, IResponseModel
|
||||
{
|
||||
[JsonConstructor]
|
||||
public GroupResponseModel()
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
public GroupResponseModel(Group group, IEnumerable<CollectionAccessSelection> collections)
|
||||
{
|
||||
if (group == null)
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AspNetCore.HealthChecks.SqlServer" Version="8.0.2" />
|
||||
<PackageReference Include="AspNetCore.HealthChecks.Uris" Version="8.0.1" />
|
||||
<PackageReference Include="Azure.Messaging.EventGrid" Version="4.31.0" />
|
||||
<PackageReference Include="Azure.Messaging.EventGrid" Version="5.0.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.4" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.KeyManagement.Kdf;
|
||||
using Bit.Core.KeyManagement.Models.Data;
|
||||
using Bit.Core.KeyManagement.Queries.Interfaces;
|
||||
using Bit.Core.Models.Api.Response;
|
||||
using Bit.Core.Repositories;
|
||||
@@ -44,6 +45,7 @@ public class AccountsController : Controller
|
||||
private readonly IUserAccountKeysQuery _userAccountKeysQuery;
|
||||
private readonly ITwoFactorEmailService _twoFactorEmailService;
|
||||
private readonly IChangeKdfCommand _changeKdfCommand;
|
||||
private readonly IUserRepository _userRepository;
|
||||
|
||||
public AccountsController(
|
||||
IOrganizationService organizationService,
|
||||
@@ -57,7 +59,8 @@ public class AccountsController : Controller
|
||||
IFeatureService featureService,
|
||||
IUserAccountKeysQuery userAccountKeysQuery,
|
||||
ITwoFactorEmailService twoFactorEmailService,
|
||||
IChangeKdfCommand changeKdfCommand
|
||||
IChangeKdfCommand changeKdfCommand,
|
||||
IUserRepository userRepository
|
||||
)
|
||||
{
|
||||
_organizationService = organizationService;
|
||||
@@ -72,6 +75,7 @@ public class AccountsController : Controller
|
||||
_userAccountKeysQuery = userAccountKeysQuery;
|
||||
_twoFactorEmailService = twoFactorEmailService;
|
||||
_changeKdfCommand = changeKdfCommand;
|
||||
_userRepository = userRepository;
|
||||
}
|
||||
|
||||
|
||||
@@ -432,16 +436,36 @@ public class AccountsController : Controller
|
||||
throw new UnauthorizedAccessException();
|
||||
}
|
||||
|
||||
if (_featureService.IsEnabled(FeatureFlagKeys.ReturnErrorOnExistingKeypair))
|
||||
if (!string.IsNullOrWhiteSpace(user.PrivateKey) || !string.IsNullOrWhiteSpace(user.PublicKey))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(user.PrivateKey) || !string.IsNullOrWhiteSpace(user.PublicKey))
|
||||
{
|
||||
throw new BadRequestException("User has existing keypair");
|
||||
}
|
||||
throw new BadRequestException("User has existing keypair");
|
||||
}
|
||||
|
||||
if (model.AccountKeys != null)
|
||||
{
|
||||
var accountKeysData = model.AccountKeys.ToAccountKeysData();
|
||||
if (!accountKeysData.IsV2Encryption())
|
||||
{
|
||||
throw new BadRequestException("AccountKeys are only supported for V2 encryption.");
|
||||
}
|
||||
await _userRepository.SetV2AccountCryptographicStateAsync(user.Id, accountKeysData);
|
||||
return new KeysResponseModel(accountKeysData, user.Key);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Todo: Drop this after a transition period. This will drop no-account-keys requests.
|
||||
// The V1 check in the other branch should persist
|
||||
// https://bitwarden.atlassian.net/browse/PM-27329
|
||||
await _userService.SaveUserAsync(model.ToUser(user));
|
||||
return new KeysResponseModel(new UserAccountKeysData
|
||||
{
|
||||
PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData(
|
||||
user.PrivateKey,
|
||||
user.PublicKey
|
||||
)
|
||||
}, user.Key);
|
||||
}
|
||||
|
||||
await _userService.SaveUserAsync(model.ToUser(user));
|
||||
return new KeysResponseModel(user);
|
||||
}
|
||||
|
||||
[HttpGet("keys")]
|
||||
@@ -453,7 +477,8 @@ public class AccountsController : Controller
|
||||
throw new UnauthorizedAccessException();
|
||||
}
|
||||
|
||||
return new KeysResponseModel(user);
|
||||
var accountKeys = await _userAccountKeysQuery.Run(user);
|
||||
return new KeysResponseModel(accountKeys, user.Key);
|
||||
}
|
||||
|
||||
[HttpDelete]
|
||||
|
||||
@@ -21,7 +21,6 @@ using Microsoft.AspNetCore.Mvc;
|
||||
namespace Bit.Api.Auth.Controllers;
|
||||
|
||||
[Route("webauthn")]
|
||||
[Authorize(Policies.Web)]
|
||||
public class WebAuthnController : Controller
|
||||
{
|
||||
private readonly IUserService _userService;
|
||||
@@ -62,6 +61,7 @@ public class WebAuthnController : Controller
|
||||
_featureService = featureService;
|
||||
}
|
||||
|
||||
[Authorize(Policies.Web)]
|
||||
[HttpGet("")]
|
||||
public async Task<ListResponseModel<WebAuthnCredentialResponseModel>> Get()
|
||||
{
|
||||
@@ -71,6 +71,7 @@ public class WebAuthnController : Controller
|
||||
return new ListResponseModel<WebAuthnCredentialResponseModel>(credentials.Select(c => new WebAuthnCredentialResponseModel(c)));
|
||||
}
|
||||
|
||||
[Authorize(Policies.Application)]
|
||||
[HttpPost("attestation-options")]
|
||||
public async Task<WebAuthnCredentialCreateOptionsResponseModel> AttestationOptions([FromBody] SecretVerificationRequestModel model)
|
||||
{
|
||||
@@ -88,6 +89,7 @@ public class WebAuthnController : Controller
|
||||
};
|
||||
}
|
||||
|
||||
[Authorize(Policies.Web)]
|
||||
[HttpPost("assertion-options")]
|
||||
public async Task<WebAuthnLoginAssertionOptionsResponseModel> AssertionOptions([FromBody] SecretVerificationRequestModel model)
|
||||
{
|
||||
@@ -104,6 +106,7 @@ public class WebAuthnController : Controller
|
||||
};
|
||||
}
|
||||
|
||||
[Authorize(Policies.Application)]
|
||||
[HttpPost("")]
|
||||
public async Task Post([FromBody] WebAuthnLoginCredentialCreateRequestModel model)
|
||||
{
|
||||
@@ -149,6 +152,7 @@ public class WebAuthnController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
[Authorize(Policies.Application)]
|
||||
[HttpPut()]
|
||||
public async Task UpdateCredential([FromBody] WebAuthnLoginCredentialUpdateRequestModel model)
|
||||
{
|
||||
@@ -172,6 +176,7 @@ public class WebAuthnController : Controller
|
||||
await _credentialRepository.UpdateAsync(credential);
|
||||
}
|
||||
|
||||
[Authorize(Policies.Web)]
|
||||
[HttpPost("{id}/delete")]
|
||||
public async Task Delete(Guid id, [FromBody] SecretVerificationRequestModel model)
|
||||
{
|
||||
|
||||
@@ -273,7 +273,7 @@ public class TwoFactorWebAuthnDeleteRequestModel : SecretVerificationRequestMode
|
||||
yield return validationResult;
|
||||
}
|
||||
|
||||
if (!Id.HasValue || Id < 0 || Id > 5)
|
||||
if (!Id.HasValue)
|
||||
{
|
||||
yield return new ValidationResult("Invalid Key Id", new string[] { nameof(Id) });
|
||||
}
|
||||
|
||||
91
src/Api/Billing/Controllers/LicensesController.cs
Normal file
91
src/Api/Billing/Controllers/LicensesController.cs
Normal file
@@ -0,0 +1,91 @@
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationConnections.Interfaces;
|
||||
using Bit.Core.Billing.Models.Business;
|
||||
using Bit.Core.Billing.Organizations.Models;
|
||||
using Bit.Core.Billing.Organizations.Queries;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Api.OrganizationLicenses;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Bit.Api.Billing.Controllers;
|
||||
|
||||
[Route("licenses")]
|
||||
[Authorize("Licensing")]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public class LicensesController : Controller
|
||||
{
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly IUserService _userService;
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly IGetCloudOrganizationLicenseQuery _getCloudOrganizationLicenseQuery;
|
||||
private readonly IValidateBillingSyncKeyCommand _validateBillingSyncKeyCommand;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
|
||||
public LicensesController(
|
||||
IUserRepository userRepository,
|
||||
IUserService userService,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IGetCloudOrganizationLicenseQuery getCloudOrganizationLicenseQuery,
|
||||
IValidateBillingSyncKeyCommand validateBillingSyncKeyCommand,
|
||||
ICurrentContext currentContext)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
_userService = userService;
|
||||
_organizationRepository = organizationRepository;
|
||||
_getCloudOrganizationLicenseQuery = getCloudOrganizationLicenseQuery;
|
||||
_validateBillingSyncKeyCommand = validateBillingSyncKeyCommand;
|
||||
_currentContext = currentContext;
|
||||
}
|
||||
|
||||
[HttpGet("user/{id}")]
|
||||
public async Task<UserLicense> GetUser(string id, [FromQuery] string key)
|
||||
{
|
||||
var user = await _userRepository.GetByIdAsync(new Guid(id));
|
||||
if (user == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
else if (!user.LicenseKey.Equals(key))
|
||||
{
|
||||
await Task.Delay(2000);
|
||||
throw new BadRequestException("Invalid license key.");
|
||||
}
|
||||
|
||||
var license = await _userService.GenerateLicenseAsync(user, null);
|
||||
return license;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Used by self-hosted installations to get an updated license file
|
||||
/// </summary>
|
||||
[HttpGet("organization/{id}")]
|
||||
public async Task<OrganizationLicense> OrganizationSync(string id, [FromBody] SelfHostedOrganizationLicenseRequestModel model)
|
||||
{
|
||||
var organization = await _organizationRepository.GetByIdAsync(new Guid(id));
|
||||
if (organization == null)
|
||||
{
|
||||
throw new NotFoundException("Organization not found.");
|
||||
}
|
||||
|
||||
if (!organization.LicenseKey.Equals(model.LicenseKey))
|
||||
{
|
||||
await Task.Delay(2000);
|
||||
throw new BadRequestException("Invalid license key.");
|
||||
}
|
||||
|
||||
if (!await _validateBillingSyncKeyCommand.ValidateBillingSyncKeyAsync(organization, model.BillingSyncKey))
|
||||
{
|
||||
throw new BadRequestException("Invalid Billing Sync Key");
|
||||
}
|
||||
|
||||
var license = await _getCloudOrganizationLicenseQuery.GetLicenseAsync(organization, _currentContext.InstallationId.Value);
|
||||
return license;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
#nullable disable
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Core.KeyManagement.Models.Data;
|
||||
|
||||
namespace Bit.Api.Billing.Models.Requests;
|
||||
|
||||
@@ -12,4 +13,11 @@ public class KeyPairRequestBody
|
||||
public string PublicKey { get; set; }
|
||||
[Required(ErrorMessage = "'encryptedPrivateKey' must be provided")]
|
||||
public string EncryptedPrivateKey { get; set; }
|
||||
|
||||
public PublicKeyEncryptionKeyPairData ToPublicKeyEncryptionKeyPairData()
|
||||
{
|
||||
return new PublicKeyEncryptionKeyPairData(
|
||||
wrappedPrivateKey: EncryptedPrivateKey,
|
||||
publicKey: PublicKey);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
using Bit.Core;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Bit.Api.Controllers;
|
||||
|
||||
[Route("phishing-domains")]
|
||||
public class PhishingDomainsController(IPhishingDomainRepository phishingDomainRepository, IFeatureService featureService) : Controller
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<ICollection<string>>> GetPhishingDomainsAsync()
|
||||
{
|
||||
if (!featureService.IsEnabled(FeatureFlagKeys.PhishingDetection))
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var domains = await phishingDomainRepository.GetActivePhishingDomainsAsync();
|
||||
return Ok(domains);
|
||||
}
|
||||
|
||||
[HttpGet("checksum")]
|
||||
public async Task<ActionResult<string>> GetChecksumAsync()
|
||||
{
|
||||
if (!featureService.IsEnabled(FeatureFlagKeys.PhishingDetection))
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var checksum = await phishingDomainRepository.GetCurrentChecksumAsync();
|
||||
return Ok(checksum);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using Bit.Api.Dirt.Models.Response;
|
||||
using Bit.Api.Models.Response;
|
||||
using Bit.Api.Utilities;
|
||||
using Bit.Api.Utilities.DiagnosticTools;
|
||||
@@ -17,7 +18,7 @@ using Bit.Core.Vault.Repositories;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Bit.Api.Controllers;
|
||||
namespace Bit.Api.Dirt.Controllers;
|
||||
|
||||
[Route("events")]
|
||||
[Authorize("Application")]
|
||||
@@ -1,12 +1,12 @@
|
||||
using Bit.Api.AdminConsole.Models.Request.Organizations;
|
||||
using Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces;
|
||||
using Bit.Api.Dirt.Models.Request;
|
||||
using Bit.Api.Dirt.Models.Response;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Dirt.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces;
|
||||
using Bit.Core.Exceptions;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Bit.Api.AdminConsole.Controllers;
|
||||
namespace Bit.Api.Dirt.Controllers;
|
||||
|
||||
[Route("organizations/{organizationId:guid}/integrations/{integrationId:guid}/configurations")]
|
||||
[Authorize("Application")]
|
||||
@@ -1,12 +1,12 @@
|
||||
using Bit.Api.AdminConsole.Models.Request.Organizations;
|
||||
using Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces;
|
||||
using Bit.Api.Dirt.Models.Request;
|
||||
using Bit.Api.Dirt.Models.Response;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Dirt.EventIntegrations.OrganizationIntegrations.Interfaces;
|
||||
using Bit.Core.Exceptions;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Bit.Api.AdminConsole.Controllers;
|
||||
namespace Bit.Api.Dirt.Controllers;
|
||||
|
||||
[Route("organizations/{organizationId:guid}/integrations")]
|
||||
[Authorize("Application")]
|
||||
@@ -1,16 +1,16 @@
|
||||
using System.Text.Json;
|
||||
using Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
using Bit.Api.Dirt.Models.Response;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Dirt.Entities;
|
||||
using Bit.Core.Dirt.Enums;
|
||||
using Bit.Core.Dirt.Models.Data.EventIntegrations;
|
||||
using Bit.Core.Dirt.Repositories;
|
||||
using Bit.Core.Dirt.Services;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Bit.Api.AdminConsole.Controllers;
|
||||
namespace Bit.Api.Dirt.Controllers;
|
||||
|
||||
[Route("organizations")]
|
||||
[Authorize("Application")]
|
||||
@@ -1,18 +1,18 @@
|
||||
using System.Text.Json;
|
||||
using Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
using Bit.Api.Dirt.Models.Response;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Dirt.Entities;
|
||||
using Bit.Core.Dirt.Enums;
|
||||
using Bit.Core.Dirt.Models.Data.EventIntegrations;
|
||||
using Bit.Core.Dirt.Repositories;
|
||||
using Bit.Core.Dirt.Services;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Bot.Builder;
|
||||
using Microsoft.Bot.Builder.Integration.AspNet.Core;
|
||||
|
||||
namespace Bit.Api.AdminConsole.Controllers;
|
||||
namespace Bit.Api.Dirt.Controllers;
|
||||
|
||||
[Route("organizations")]
|
||||
[Authorize("Application")]
|
||||
@@ -1,8 +1,7 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Dirt.Entities;
|
||||
using Bit.Core.Enums;
|
||||
|
||||
|
||||
namespace Bit.Api.AdminConsole.Models.Request.Organizations;
|
||||
namespace Bit.Api.Dirt.Models.Request;
|
||||
|
||||
public class OrganizationIntegrationConfigurationRequestModel
|
||||
{
|
||||
@@ -1,10 +1,10 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Dirt.Entities;
|
||||
using Bit.Core.Dirt.Enums;
|
||||
using Bit.Core.Dirt.Models.Data.EventIntegrations;
|
||||
|
||||
namespace Bit.Api.AdminConsole.Models.Request.Organizations;
|
||||
namespace Bit.Api.Dirt.Models.Request;
|
||||
|
||||
public class OrganizationIntegrationRequestModel : IValidatableObject
|
||||
{
|
||||
@@ -2,7 +2,7 @@
|
||||
using Bit.Core.Models.Api;
|
||||
using Bit.Core.Models.Data;
|
||||
|
||||
namespace Bit.Api.Models.Response;
|
||||
namespace Bit.Api.Dirt.Models.Response;
|
||||
|
||||
public class EventResponseModel : ResponseModel
|
||||
{
|
||||
@@ -1,8 +1,8 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Dirt.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Api;
|
||||
|
||||
namespace Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||
namespace Bit.Api.Dirt.Models.Response;
|
||||
|
||||
public class OrganizationIntegrationConfigurationResponseModel : ResponseModel
|
||||
{
|
||||
@@ -1,10 +1,10 @@
|
||||
using System.Text.Json;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Dirt.Entities;
|
||||
using Bit.Core.Dirt.Enums;
|
||||
using Bit.Core.Dirt.Models.Data.EventIntegrations;
|
||||
using Bit.Core.Models.Api;
|
||||
|
||||
namespace Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||
namespace Bit.Api.Dirt.Models.Response;
|
||||
|
||||
public class OrganizationIntegrationResponseModel : ResponseModel
|
||||
{
|
||||
@@ -1,6 +1,5 @@
|
||||
|
||||
using System.Net;
|
||||
using Bit.Api.Models.Public.Request;
|
||||
using System.Net;
|
||||
using Bit.Api.Dirt.Public.Models;
|
||||
using Bit.Api.Models.Public.Response;
|
||||
using Bit.Api.Utilities.DiagnosticTools;
|
||||
using Bit.Core.Context;
|
||||
@@ -12,7 +11,7 @@ using Bit.Core.Vault.Repositories;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Bit.Api.Public.Controllers;
|
||||
namespace Bit.Api.Dirt.Public.Controllers;
|
||||
|
||||
[Route("public/events")]
|
||||
[Authorize("Organization")]
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
using Bit.Core.Exceptions;
|
||||
|
||||
namespace Bit.Api.Models.Public.Request;
|
||||
namespace Bit.Api.Dirt.Public.Models;
|
||||
|
||||
public class EventFilterRequestModel
|
||||
{
|
||||
@@ -1,8 +1,9 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Api.Models.Public.Response;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Data;
|
||||
|
||||
namespace Bit.Api.Models.Public.Response;
|
||||
namespace Bit.Api.Dirt.Public.Models;
|
||||
|
||||
/// <summary>
|
||||
/// An event log.
|
||||
@@ -59,13 +59,6 @@ public class JobsHostedService : BaseJobsHostedService
|
||||
.StartNow()
|
||||
.WithCronSchedule("0 0 * * * ?")
|
||||
.Build();
|
||||
var updatePhishingDomainsTrigger = TriggerBuilder.Create()
|
||||
.WithIdentity("UpdatePhishingDomainsTrigger")
|
||||
.StartNow()
|
||||
.WithSimpleSchedule(x => x
|
||||
.WithIntervalInHours(24)
|
||||
.RepeatForever())
|
||||
.Build();
|
||||
var updateOrgSubscriptionsTrigger = TriggerBuilder.Create()
|
||||
.WithIdentity("UpdateOrgSubscriptionsTrigger")
|
||||
.StartNow()
|
||||
@@ -81,7 +74,6 @@ public class JobsHostedService : BaseJobsHostedService
|
||||
new Tuple<Type, ITrigger>(typeof(ValidateUsersJob), everyTopOfTheSixthHourTrigger),
|
||||
new Tuple<Type, ITrigger>(typeof(ValidateOrganizationsJob), everyTwelfthHourAndThirtyMinutesTrigger),
|
||||
new Tuple<Type, ITrigger>(typeof(ValidateOrganizationDomainJob), validateOrganizationDomainTrigger),
|
||||
new Tuple<Type, ITrigger>(typeof(UpdatePhishingDomainsJob), updatePhishingDomainsTrigger),
|
||||
new (typeof(OrganizationSubscriptionUpdateJob), updateOrgSubscriptionsTrigger),
|
||||
};
|
||||
|
||||
@@ -111,7 +103,6 @@ public class JobsHostedService : BaseJobsHostedService
|
||||
services.AddTransient<ValidateUsersJob>();
|
||||
services.AddTransient<ValidateOrganizationsJob>();
|
||||
services.AddTransient<ValidateOrganizationDomainJob>();
|
||||
services.AddTransient<UpdatePhishingDomainsJob>();
|
||||
services.AddTransient<OrganizationSubscriptionUpdateJob>();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
using Bit.Core;
|
||||
using Bit.Core.Jobs;
|
||||
using Bit.Core.PhishingDomainFeatures.Interfaces;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
using Quartz;
|
||||
|
||||
namespace Bit.Api.Jobs;
|
||||
|
||||
public class UpdatePhishingDomainsJob : BaseJob
|
||||
{
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
private readonly IPhishingDomainRepository _phishingDomainRepository;
|
||||
private readonly ICloudPhishingDomainQuery _cloudPhishingDomainQuery;
|
||||
private readonly IFeatureService _featureService;
|
||||
public UpdatePhishingDomainsJob(
|
||||
GlobalSettings globalSettings,
|
||||
IPhishingDomainRepository phishingDomainRepository,
|
||||
ICloudPhishingDomainQuery cloudPhishingDomainQuery,
|
||||
IFeatureService featureService,
|
||||
ILogger<UpdatePhishingDomainsJob> logger)
|
||||
: base(logger)
|
||||
{
|
||||
_globalSettings = globalSettings;
|
||||
_phishingDomainRepository = phishingDomainRepository;
|
||||
_cloudPhishingDomainQuery = cloudPhishingDomainQuery;
|
||||
_featureService = featureService;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteJobAsync(IJobExecutionContext context)
|
||||
{
|
||||
if (!_featureService.IsEnabled(FeatureFlagKeys.PhishingDetection))
|
||||
{
|
||||
_logger.LogInformation(Constants.BypassFiltersEventId, "Skipping phishing domain update. Feature flag is disabled.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_globalSettings.PhishingDomain?.UpdateUrl))
|
||||
{
|
||||
_logger.LogInformation(Constants.BypassFiltersEventId, "Skipping phishing domain update. No URL configured.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (_globalSettings.SelfHosted && !_globalSettings.EnableCloudCommunication)
|
||||
{
|
||||
_logger.LogInformation(Constants.BypassFiltersEventId, "Skipping phishing domain update. Cloud communication is disabled in global settings.");
|
||||
return;
|
||||
}
|
||||
|
||||
var remoteChecksum = await _cloudPhishingDomainQuery.GetRemoteChecksumAsync();
|
||||
if (string.IsNullOrWhiteSpace(remoteChecksum))
|
||||
{
|
||||
_logger.LogWarning(Constants.BypassFiltersEventId, "Could not retrieve remote checksum. Skipping update.");
|
||||
return;
|
||||
}
|
||||
|
||||
var currentChecksum = await _phishingDomainRepository.GetCurrentChecksumAsync();
|
||||
|
||||
if (string.Equals(currentChecksum, remoteChecksum, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogInformation(Constants.BypassFiltersEventId,
|
||||
"Phishing domains list is up to date (checksum: {Checksum}). Skipping update.",
|
||||
currentChecksum);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation(Constants.BypassFiltersEventId,
|
||||
"Checksums differ (current: {CurrentChecksum}, remote: {RemoteChecksum}). Fetching updated domains from {Source}.",
|
||||
currentChecksum, remoteChecksum, _globalSettings.SelfHosted ? "Bitwarden cloud API" : "external source");
|
||||
|
||||
try
|
||||
{
|
||||
var domains = await _cloudPhishingDomainQuery.GetPhishingDomainsAsync();
|
||||
if (!domains.Contains("phishing.testcategory.com", StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
domains.Add("phishing.testcategory.com");
|
||||
}
|
||||
|
||||
if (domains.Count > 0)
|
||||
{
|
||||
_logger.LogInformation(Constants.BypassFiltersEventId, "Updating {Count} phishing domains with checksum {Checksum}.",
|
||||
domains.Count, remoteChecksum);
|
||||
await _phishingDomainRepository.UpdatePhishingDomainsAsync(domains, remoteChecksum);
|
||||
_logger.LogInformation(Constants.BypassFiltersEventId, "Successfully updated phishing domains.");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(Constants.BypassFiltersEventId, "No valid domains found in the response. Skipping update.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(Constants.BypassFiltersEventId, ex, "Error updating phishing domains.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,7 @@ public class AccountsKeyManagementController : Controller
|
||||
_webauthnKeyValidator;
|
||||
private readonly IRotationValidator<IEnumerable<OtherDeviceKeysUpdateRequestModel>, IEnumerable<Device>> _deviceValidator;
|
||||
private readonly IKeyConnectorConfirmationDetailsQuery _keyConnectorConfirmationDetailsQuery;
|
||||
private readonly ISetKeyConnectorKeyCommand _setKeyConnectorKeyCommand;
|
||||
|
||||
public AccountsKeyManagementController(IUserService userService,
|
||||
IFeatureService featureService,
|
||||
@@ -62,8 +63,10 @@ public class AccountsKeyManagementController : Controller
|
||||
emergencyAccessValidator,
|
||||
IRotationValidator<IEnumerable<ResetPasswordWithOrgIdRequestModel>, IReadOnlyList<OrganizationUser>>
|
||||
organizationUserValidator,
|
||||
IRotationValidator<IEnumerable<WebAuthnLoginRotateKeyRequestModel>, IEnumerable<WebAuthnLoginRotateKeyData>> webAuthnKeyValidator,
|
||||
IRotationValidator<IEnumerable<OtherDeviceKeysUpdateRequestModel>, IEnumerable<Device>> deviceValidator)
|
||||
IRotationValidator<IEnumerable<WebAuthnLoginRotateKeyRequestModel>, IEnumerable<WebAuthnLoginRotateKeyData>>
|
||||
webAuthnKeyValidator,
|
||||
IRotationValidator<IEnumerable<OtherDeviceKeysUpdateRequestModel>, IEnumerable<Device>> deviceValidator,
|
||||
ISetKeyConnectorKeyCommand setKeyConnectorKeyCommand)
|
||||
{
|
||||
_userService = userService;
|
||||
_featureService = featureService;
|
||||
@@ -79,6 +82,7 @@ public class AccountsKeyManagementController : Controller
|
||||
_webauthnKeyValidator = webAuthnKeyValidator;
|
||||
_deviceValidator = deviceValidator;
|
||||
_keyConnectorConfirmationDetailsQuery = keyConnectorConfirmationDetailsQuery;
|
||||
_setKeyConnectorKeyCommand = setKeyConnectorKeyCommand;
|
||||
}
|
||||
|
||||
[HttpPost("key-management/regenerate-keys")]
|
||||
@@ -146,18 +150,28 @@ public class AccountsKeyManagementController : Controller
|
||||
throw new UnauthorizedAccessException();
|
||||
}
|
||||
|
||||
var result = await _userService.SetKeyConnectorKeyAsync(model.ToUser(user), model.Key, model.OrgIdentifier);
|
||||
if (result.Succeeded)
|
||||
if (model.IsV2Request())
|
||||
{
|
||||
return;
|
||||
// V2 account registration
|
||||
await _setKeyConnectorKeyCommand.SetKeyConnectorKeyForUserAsync(user, model.ToKeyConnectorKeysData());
|
||||
}
|
||||
|
||||
foreach (var error in result.Errors)
|
||||
else
|
||||
{
|
||||
ModelState.AddModelError(string.Empty, error.Description);
|
||||
}
|
||||
// V1 account registration
|
||||
// TODO removed with https://bitwarden.atlassian.net/browse/PM-27328
|
||||
var result = await _userService.SetKeyConnectorKeyAsync(model.ToUser(user), model.Key, model.OrgIdentifier);
|
||||
if (result.Succeeded)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
throw new BadRequestException(ModelState);
|
||||
foreach (var error in result.Errors)
|
||||
{
|
||||
ModelState.AddModelError(string.Empty, error.Description);
|
||||
}
|
||||
|
||||
throw new BadRequestException(ModelState);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("convert-to-key-connector")]
|
||||
|
||||
@@ -1,36 +1,112 @@
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Core.Auth.Models.Api.Request.Accounts;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.KeyManagement.Models.Api.Request;
|
||||
using Bit.Core.KeyManagement.Models.Data;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Api.KeyManagement.Models.Requests;
|
||||
|
||||
public class SetKeyConnectorKeyRequestModel
|
||||
public class SetKeyConnectorKeyRequestModel : IValidatableObject
|
||||
{
|
||||
[Required]
|
||||
public string Key { get; set; }
|
||||
[Required]
|
||||
public KeysRequestModel Keys { get; set; }
|
||||
[Required]
|
||||
public KdfType Kdf { get; set; }
|
||||
[Required]
|
||||
public int KdfIterations { get; set; }
|
||||
public int? KdfMemory { get; set; }
|
||||
public int? KdfParallelism { get; set; }
|
||||
[Required]
|
||||
public string OrgIdentifier { get; set; }
|
||||
// TODO will be removed with https://bitwarden.atlassian.net/browse/PM-27328
|
||||
[Obsolete("Use KeyConnectorKeyWrappedUserKey instead")]
|
||||
public string? Key { get; set; }
|
||||
|
||||
[Obsolete("Use AccountKeys instead")]
|
||||
public KeysRequestModel? Keys { get; set; }
|
||||
[Obsolete("Not used anymore")]
|
||||
public KdfType? Kdf { get; set; }
|
||||
[Obsolete("Not used anymore")]
|
||||
public int? KdfIterations { get; set; }
|
||||
[Obsolete("Not used anymore")]
|
||||
public int? KdfMemory { get; set; }
|
||||
[Obsolete("Not used anymore")]
|
||||
public int? KdfParallelism { get; set; }
|
||||
|
||||
[EncryptedString]
|
||||
public string? KeyConnectorKeyWrappedUserKey { get; set; }
|
||||
public AccountKeysRequestModel? AccountKeys { get; set; }
|
||||
|
||||
[Required]
|
||||
public required string OrgIdentifier { get; init; }
|
||||
|
||||
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
||||
{
|
||||
if (IsV2Request())
|
||||
{
|
||||
// V2 registration
|
||||
yield break;
|
||||
}
|
||||
|
||||
// V1 registration
|
||||
// TODO removed with https://bitwarden.atlassian.net/browse/PM-27328
|
||||
if (string.IsNullOrEmpty(Key))
|
||||
{
|
||||
yield return new ValidationResult("Key must be supplied.");
|
||||
}
|
||||
|
||||
if (Keys == null)
|
||||
{
|
||||
yield return new ValidationResult("Keys must be supplied.");
|
||||
}
|
||||
|
||||
if (Kdf == null)
|
||||
{
|
||||
yield return new ValidationResult("Kdf must be supplied.");
|
||||
}
|
||||
|
||||
if (KdfIterations == null)
|
||||
{
|
||||
yield return new ValidationResult("KdfIterations must be supplied.");
|
||||
}
|
||||
|
||||
if (Kdf == KdfType.Argon2id)
|
||||
{
|
||||
if (KdfMemory == null)
|
||||
{
|
||||
yield return new ValidationResult("KdfMemory must be supplied when Kdf is Argon2id.");
|
||||
}
|
||||
|
||||
if (KdfParallelism == null)
|
||||
{
|
||||
yield return new ValidationResult("KdfParallelism must be supplied when Kdf is Argon2id.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsV2Request()
|
||||
{
|
||||
return !string.IsNullOrEmpty(KeyConnectorKeyWrappedUserKey) && AccountKeys != null;
|
||||
}
|
||||
|
||||
// TODO removed with https://bitwarden.atlassian.net/browse/PM-27328
|
||||
public User ToUser(User existingUser)
|
||||
{
|
||||
existingUser.Kdf = Kdf;
|
||||
existingUser.KdfIterations = KdfIterations;
|
||||
existingUser.Kdf = Kdf!.Value;
|
||||
existingUser.KdfIterations = KdfIterations!.Value;
|
||||
existingUser.KdfMemory = KdfMemory;
|
||||
existingUser.KdfParallelism = KdfParallelism;
|
||||
existingUser.Key = Key;
|
||||
Keys.ToUser(existingUser);
|
||||
Keys!.ToUser(existingUser);
|
||||
return existingUser;
|
||||
}
|
||||
|
||||
public KeyConnectorKeysData ToKeyConnectorKeysData()
|
||||
{
|
||||
// TODO remove validation with https://bitwarden.atlassian.net/browse/PM-27328
|
||||
if (string.IsNullOrEmpty(KeyConnectorKeyWrappedUserKey) || AccountKeys == null)
|
||||
{
|
||||
throw new BadRequestException("KeyConnectorKeyWrappedUserKey and AccountKeys must be supplied.");
|
||||
}
|
||||
|
||||
return new KeyConnectorKeysData
|
||||
{
|
||||
KeyConnectorKeyWrappedUserKey = KeyConnectorKeyWrappedUserKey,
|
||||
AccountKeys = AccountKeys,
|
||||
OrgIdentifier = OrgIdentifier
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#nullable disable
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
using Bit.Api.AdminConsole.Public.Models.Response;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Models.Data;
|
||||
@@ -13,6 +14,12 @@ namespace Bit.Api.Models.Public.Response;
|
||||
/// </summary>
|
||||
public class CollectionResponseModel : CollectionBaseModel, IResponseModel
|
||||
{
|
||||
[JsonConstructor]
|
||||
public CollectionResponseModel()
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
public CollectionResponseModel(Collection collection, IEnumerable<CollectionAccessSelection> groups)
|
||||
{
|
||||
if (collection == null)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using Bit.Core;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Api;
|
||||
using Bit.Core.Services;
|
||||
@@ -45,7 +46,8 @@ public class ConfigResponseModel : ResponseModel
|
||||
Sso = globalSettings.BaseServiceUri.Sso
|
||||
};
|
||||
FeatureStates = featureService.GetAll();
|
||||
Push = PushSettings.Build(globalSettings);
|
||||
var webPushEnabled = FeatureStates.TryGetValue(FeatureFlagKeys.WebPush, out var webPushEnabledValue) ? (bool)webPushEnabledValue : false;
|
||||
Push = PushSettings.Build(webPushEnabled, globalSettings);
|
||||
Settings = new ServerSettingsResponseModel
|
||||
{
|
||||
DisableUserRegistration = globalSettings.DisableUserRegistration
|
||||
@@ -74,9 +76,9 @@ public class PushSettings
|
||||
public PushTechnologyType PushTechnology { get; private init; }
|
||||
public string VapidPublicKey { get; private init; }
|
||||
|
||||
public static PushSettings Build(IGlobalSettings globalSettings)
|
||||
public static PushSettings Build(bool webPushEnabled, IGlobalSettings globalSettings)
|
||||
{
|
||||
var vapidPublicKey = globalSettings.WebPush.VapidPublicKey;
|
||||
var vapidPublicKey = webPushEnabled ? globalSettings.WebPush.VapidPublicKey : null;
|
||||
var pushTechnology = vapidPublicKey != null ? PushTechnologyType.WebPush : PushTechnologyType.SignalR;
|
||||
return new()
|
||||
{
|
||||
|
||||
@@ -1,27 +1,32 @@
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.KeyManagement.Models.Api.Response;
|
||||
using Bit.Core.KeyManagement.Models.Data;
|
||||
using Bit.Core.Models.Api;
|
||||
|
||||
namespace Bit.Api.Models.Response;
|
||||
|
||||
public class KeysResponseModel : ResponseModel
|
||||
{
|
||||
public KeysResponseModel(User user)
|
||||
public KeysResponseModel(UserAccountKeysData accountKeys, string? masterKeyWrappedUserKey)
|
||||
: base("keys")
|
||||
{
|
||||
if (user == null)
|
||||
if (masterKeyWrappedUserKey != null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(user));
|
||||
Key = masterKeyWrappedUserKey;
|
||||
}
|
||||
|
||||
Key = user.Key;
|
||||
PublicKey = user.PublicKey;
|
||||
PrivateKey = user.PrivateKey;
|
||||
PublicKey = accountKeys.PublicKeyEncryptionKeyPairData.PublicKey;
|
||||
PrivateKey = accountKeys.PublicKeyEncryptionKeyPairData.WrappedPrivateKey;
|
||||
AccountKeys = new PrivateKeysResponseModel(accountKeys);
|
||||
}
|
||||
|
||||
public string Key { get; set; }
|
||||
/// <summary>
|
||||
/// The master key wrapped user key. The master key can either be a master-password master key or a
|
||||
/// key-connector master key.
|
||||
/// </summary>
|
||||
public string? Key { get; set; }
|
||||
[Obsolete("Use AccountKeys.PublicKeyEncryptionKeyPair.PublicKey instead")]
|
||||
public string PublicKey { get; set; }
|
||||
[Obsolete("Use AccountKeys.PublicKeyEncryptionKeyPair.WrappedPrivateKey instead")]
|
||||
public string PrivateKey { get; set; }
|
||||
public PrivateKeysResponseModel AccountKeys { get; set; }
|
||||
}
|
||||
|
||||
@@ -65,10 +65,11 @@ public class CollectionsController : Controller
|
||||
[ProducesResponseType(typeof(ListResponseModel<CollectionResponseModel>), (int)HttpStatusCode.OK)]
|
||||
public async Task<IActionResult> List()
|
||||
{
|
||||
var collections = await _collectionRepository.GetManySharedCollectionsByOrganizationIdAsync(
|
||||
_currentContext.OrganizationId.Value);
|
||||
// TODO: Get all CollectionGroup associations for the organization and marry them up here for the response.
|
||||
var collectionResponses = collections.Select(c => new CollectionResponseModel(c, null));
|
||||
var collections = await _collectionRepository.GetManyByOrganizationIdWithAccessAsync(_currentContext.OrganizationId.Value);
|
||||
|
||||
var collectionResponses = collections.Select(c =>
|
||||
new CollectionResponseModel(c.Item1, c.Item2.Groups));
|
||||
|
||||
var response = new ListResponseModel<CollectionResponseModel>(collectionResponses);
|
||||
return new JsonResult(response);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using Bit.Api.Dirt.Models.Response;
|
||||
using Bit.Api.Models.Response;
|
||||
using Bit.Api.Utilities;
|
||||
using Bit.Core.Exceptions;
|
||||
|
||||
@@ -187,7 +187,6 @@ public class Startup
|
||||
services.AddBillingOperations();
|
||||
services.AddReportingServices();
|
||||
services.AddImportServices();
|
||||
services.AddPhishingDomainServices(globalSettings);
|
||||
|
||||
services.AddSendServices();
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Bit.Api.Models.Public.Request;
|
||||
using Bit.Api.Dirt.Public.Models;
|
||||
using Bit.Api.Models.Public.Response;
|
||||
using Bit.Core;
|
||||
using Bit.Core.Services;
|
||||
@@ -49,7 +49,7 @@ public static class EventDiagnosticLogger
|
||||
this ILogger logger,
|
||||
IFeatureService featureService,
|
||||
Guid organizationId,
|
||||
IEnumerable<Bit.Api.Models.Response.EventResponseModel> data,
|
||||
IEnumerable<Dirt.Models.Response.EventResponseModel> data,
|
||||
string? continuationToken,
|
||||
DateTime? queryStart = null,
|
||||
DateTime? queryEnd = null)
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
using Bit.Api.AdminConsole.Authorization;
|
||||
using Bit.Api.Tools.Authorization;
|
||||
using Bit.Core.PhishingDomainFeatures;
|
||||
using Bit.Core.PhishingDomainFeatures.Interfaces;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Repositories.Implementations;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Core.Vault.Authorization.SecurityTasks;
|
||||
@@ -103,25 +99,4 @@ public static class ServiceCollectionExtensions
|
||||
// Admin Console authorization handlers
|
||||
services.AddAdminConsoleAuthorizationHandlers();
|
||||
}
|
||||
|
||||
public static void AddPhishingDomainServices(this IServiceCollection services, GlobalSettings globalSettings)
|
||||
{
|
||||
services.AddHttpClient("PhishingDomains", client =>
|
||||
{
|
||||
client.DefaultRequestHeaders.Add("User-Agent", globalSettings.SelfHosted ? "Bitwarden Self-Hosted" : "Bitwarden");
|
||||
client.Timeout = TimeSpan.FromSeconds(1000); // the source list is very slow
|
||||
});
|
||||
|
||||
services.AddSingleton<AzurePhishingDomainStorageService>();
|
||||
services.AddSingleton<IPhishingDomainRepository, AzurePhishingDomainRepository>();
|
||||
|
||||
if (globalSettings.SelfHosted)
|
||||
{
|
||||
services.AddScoped<ICloudPhishingDomainQuery, CloudPhishingDomainRelayQuery>();
|
||||
}
|
||||
else
|
||||
{
|
||||
services.AddScoped<ICloudPhishingDomainQuery, CloudPhishingDomainDirectQuery>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,10 +38,6 @@
|
||||
"storage": {
|
||||
"connectionString": "UseDevelopmentStorage=true"
|
||||
},
|
||||
"phishingDomain": {
|
||||
"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"
|
||||
},
|
||||
"pricingUri": "https://billingpricing.qa.bitwarden.pw"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,9 +69,6 @@
|
||||
"accessKeySecret": "SECRET",
|
||||
"region": "SECRET"
|
||||
},
|
||||
"phishingDomain": {
|
||||
"updateUrl": "SECRET"
|
||||
},
|
||||
"distributedIpRateLimiting": {
|
||||
"enabled": true,
|
||||
"maxRedisTimeoutsThreshold": 10,
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<Sdk Name="Bitwarden.Server.Sdk" />
|
||||
|
||||
<PropertyGroup>
|
||||
<UserSecretsId>bitwarden-Billing</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Label="Server SDK settings">
|
||||
<!-- These features will be gradually turned on -->
|
||||
<BitIncludeFeatures>false</BitIncludeFeatures>
|
||||
<BitIncludeTelemetry>false</BitIncludeTelemetry>
|
||||
<BitIncludeAuthentication>false</BitIncludeAuthentication>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition=" '$(RunConfiguration)' == 'Billing' " />
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\bitwarden_license\src\Commercial.Core\Commercial.Core.csproj" />
|
||||
|
||||
@@ -9,10 +9,7 @@ public class BillingSettings
|
||||
public virtual string StripeWebhookKey { get; set; }
|
||||
public virtual string StripeWebhookSecret20250827Basil { get; set; }
|
||||
public virtual string AppleWebhookKey { get; set; }
|
||||
public virtual FreshDeskSettings FreshDesk { get; set; } = new FreshDeskSettings();
|
||||
public virtual string FreshsalesApiKey { get; set; }
|
||||
public virtual PayPalSettings PayPal { get; set; } = new PayPalSettings();
|
||||
public virtual OnyxSettings Onyx { get; set; } = new OnyxSettings();
|
||||
|
||||
public class PayPalSettings
|
||||
{
|
||||
@@ -21,35 +18,4 @@ public class BillingSettings
|
||||
public virtual string WebhookKey { get; set; }
|
||||
}
|
||||
|
||||
public class FreshDeskSettings
|
||||
{
|
||||
public virtual string ApiKey { get; set; }
|
||||
public virtual string WebhookKey { get; set; }
|
||||
/// <summary>
|
||||
/// Indicates the data center region. Valid values are "US" and "EU"
|
||||
/// </summary>
|
||||
public virtual string Region { get; set; }
|
||||
public virtual string UserFieldName { get; set; }
|
||||
public virtual string OrgFieldName { get; set; }
|
||||
|
||||
public virtual bool RemoveNewlinesInReplies { get; set; } = false;
|
||||
public virtual string AutoReplyGreeting { get; set; } = string.Empty;
|
||||
public virtual string AutoReplySalutation { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class OnyxSettings
|
||||
{
|
||||
public virtual string ApiKey { get; set; }
|
||||
public virtual string BaseUrl { get; set; }
|
||||
public virtual string Path { get; set; }
|
||||
public virtual int PersonaId { get; set; }
|
||||
public virtual bool UseAnswerWithCitationModels { get; set; } = true;
|
||||
|
||||
public virtual SearchSettings SearchSettings { get; set; } = new SearchSettings();
|
||||
}
|
||||
public class SearchSettings
|
||||
{
|
||||
public virtual string RunSearch { get; set; } = "auto"; // "always", "never", "auto"
|
||||
public virtual bool RealTime { get; set; } = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,395 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Web;
|
||||
using Bit.Billing.Models;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Utilities;
|
||||
using Markdig;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Bit.Billing.Controllers;
|
||||
|
||||
[Route("freshdesk")]
|
||||
public class FreshdeskController : Controller
|
||||
{
|
||||
private readonly BillingSettings _billingSettings;
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly ILogger<FreshdeskController> _logger;
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
|
||||
public FreshdeskController(
|
||||
IUserRepository userRepository,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOptions<BillingSettings> billingSettings,
|
||||
ILogger<FreshdeskController> logger,
|
||||
GlobalSettings globalSettings,
|
||||
IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_billingSettings = billingSettings?.Value ?? throw new ArgumentNullException(nameof(billingSettings));
|
||||
_userRepository = userRepository;
|
||||
_organizationRepository = organizationRepository;
|
||||
_logger = logger;
|
||||
_globalSettings = globalSettings;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
}
|
||||
|
||||
[HttpPost("webhook")]
|
||||
public async Task<IActionResult> PostWebhook([FromQuery, Required] string key,
|
||||
[FromBody, Required] FreshdeskWebhookModel model)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key) || !CoreHelpers.FixedTimeEquals(key, _billingSettings.FreshDesk.WebhookKey))
|
||||
{
|
||||
return new BadRequestResult();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var ticketId = model.TicketId;
|
||||
var ticketContactEmail = model.TicketContactEmail;
|
||||
var ticketTags = model.TicketTags;
|
||||
if (string.IsNullOrWhiteSpace(ticketId) || string.IsNullOrWhiteSpace(ticketContactEmail))
|
||||
{
|
||||
return new BadRequestResult();
|
||||
}
|
||||
|
||||
var updateBody = new Dictionary<string, object>();
|
||||
var note = string.Empty;
|
||||
note += $"<li>Region: {_billingSettings.FreshDesk.Region}</li>";
|
||||
var customFields = new Dictionary<string, object>();
|
||||
var user = await _userRepository.GetByEmailAsync(ticketContactEmail);
|
||||
if (user == null)
|
||||
{
|
||||
note += $"<li>No user found: {ticketContactEmail}</li>";
|
||||
await CreateNote(ticketId, note);
|
||||
}
|
||||
|
||||
if (user != null)
|
||||
{
|
||||
var userLink = $"{_globalSettings.BaseServiceUri.Admin}/users/edit/{user.Id}";
|
||||
note += $"<li>User, {user.Email}: {userLink}</li>";
|
||||
customFields.Add(_billingSettings.FreshDesk.UserFieldName, userLink);
|
||||
var tags = new HashSet<string>();
|
||||
if (user.Premium)
|
||||
{
|
||||
tags.Add("Premium");
|
||||
}
|
||||
var orgs = await _organizationRepository.GetManyByUserIdAsync(user.Id);
|
||||
|
||||
foreach (var org in orgs)
|
||||
{
|
||||
// Prevent org names from injecting any additional HTML
|
||||
var orgName = HttpUtility.HtmlEncode(org.Name);
|
||||
var orgNote = $"{orgName} ({org.Seats.GetValueOrDefault()}): " +
|
||||
$"{_globalSettings.BaseServiceUri.Admin}/organizations/edit/{org.Id}";
|
||||
note += $"<li>Org, {orgNote}</li>";
|
||||
if (!customFields.Any(kvp => kvp.Key == _billingSettings.FreshDesk.OrgFieldName))
|
||||
{
|
||||
customFields.Add(_billingSettings.FreshDesk.OrgFieldName, orgNote);
|
||||
}
|
||||
else
|
||||
{
|
||||
customFields[_billingSettings.FreshDesk.OrgFieldName] += $"\n{orgNote}";
|
||||
}
|
||||
|
||||
var displayAttribute = GetAttribute<DisplayAttribute>(org.PlanType);
|
||||
var planName = displayAttribute?.Name?.Split(" ").FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(planName))
|
||||
{
|
||||
tags.Add(string.Format("Org: {0}", planName));
|
||||
}
|
||||
}
|
||||
if (tags.Any())
|
||||
{
|
||||
var tagsToUpdate = tags.ToList();
|
||||
if (!string.IsNullOrWhiteSpace(ticketTags))
|
||||
{
|
||||
var splitTicketTags = ticketTags.Split(',');
|
||||
for (var i = 0; i < splitTicketTags.Length; i++)
|
||||
{
|
||||
tagsToUpdate.Insert(i, splitTicketTags[i]);
|
||||
}
|
||||
}
|
||||
updateBody.Add("tags", tagsToUpdate);
|
||||
}
|
||||
|
||||
if (customFields.Any())
|
||||
{
|
||||
updateBody.Add("custom_fields", customFields);
|
||||
}
|
||||
var updateRequest = new HttpRequestMessage(HttpMethod.Put,
|
||||
string.Format("https://bitwarden.freshdesk.com/api/v2/tickets/{0}", ticketId))
|
||||
{
|
||||
Content = JsonContent.Create(updateBody),
|
||||
};
|
||||
await CallFreshdeskApiAsync(updateRequest);
|
||||
await CreateNote(ticketId, note);
|
||||
}
|
||||
|
||||
return new OkResult();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, "Error processing freshdesk webhook.");
|
||||
return new BadRequestResult();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("webhook-onyx-ai")]
|
||||
public async Task<IActionResult> PostWebhookOnyxAi([FromQuery, Required] string key,
|
||||
[FromBody, Required] FreshdeskOnyxAiWebhookModel model)
|
||||
{
|
||||
// ensure that the key is from Freshdesk
|
||||
if (!IsValidRequestFromFreshdesk(key))
|
||||
{
|
||||
return new BadRequestResult();
|
||||
}
|
||||
|
||||
// if there is no description, then we don't send anything to onyx
|
||||
if (string.IsNullOrEmpty(model.TicketDescriptionText.Trim()))
|
||||
{
|
||||
return Ok();
|
||||
}
|
||||
|
||||
// Get response from Onyx AI
|
||||
var (onyxRequest, onyxResponse) = await GetAnswerFromOnyx(model);
|
||||
|
||||
// the CallOnyxApi will return a null if we have an error response
|
||||
if (onyxResponse?.Answer == null || !string.IsNullOrEmpty(onyxResponse?.ErrorMsg))
|
||||
{
|
||||
_logger.LogWarning("Error getting answer from Onyx AI. Freshdesk model: {model}\r\n Onyx query {query}\r\nresponse: {response}. ",
|
||||
JsonSerializer.Serialize(model),
|
||||
JsonSerializer.Serialize(onyxRequest),
|
||||
JsonSerializer.Serialize(onyxResponse));
|
||||
|
||||
return Ok(); // return ok so we don't retry
|
||||
}
|
||||
|
||||
// add the answer as a note to the ticket
|
||||
await AddAnswerNoteToTicketAsync(onyxResponse?.Answer ?? string.Empty, model.TicketId);
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
||||
[HttpPost("webhook-onyx-ai-reply")]
|
||||
public async Task<IActionResult> PostWebhookOnyxAiReply([FromQuery, Required] string key,
|
||||
[FromBody, Required] FreshdeskOnyxAiWebhookModel model)
|
||||
{
|
||||
// NOTE:
|
||||
// at this time, this endpoint is a duplicate of `webhook-onyx-ai`
|
||||
// eventually, we will merge both endpoints into one webhook for Freshdesk
|
||||
|
||||
// ensure that the key is from Freshdesk
|
||||
if (!IsValidRequestFromFreshdesk(key) || !ModelState.IsValid)
|
||||
{
|
||||
return new BadRequestResult();
|
||||
}
|
||||
|
||||
// if there is no description, then we don't send anything to onyx
|
||||
if (string.IsNullOrEmpty(model.TicketDescriptionText.Trim()))
|
||||
{
|
||||
return Ok();
|
||||
}
|
||||
|
||||
// create the onyx `answer-with-citation` request
|
||||
var (onyxRequest, onyxResponse) = await GetAnswerFromOnyx(model);
|
||||
|
||||
// the CallOnyxApi will return a null if we have an error response
|
||||
if (onyxResponse?.Answer == null || !string.IsNullOrEmpty(onyxResponse?.ErrorMsg))
|
||||
{
|
||||
_logger.LogWarning("Error getting answer from Onyx AI. Freshdesk model: {model}\r\n Onyx query {query}\r\nresponse: {response}. ",
|
||||
JsonSerializer.Serialize(model),
|
||||
JsonSerializer.Serialize(onyxRequest),
|
||||
JsonSerializer.Serialize(onyxResponse));
|
||||
|
||||
return Ok(); // return ok so we don't retry
|
||||
}
|
||||
|
||||
// add the reply to the ticket
|
||||
await AddReplyToTicketAsync(onyxResponse?.Answer ?? string.Empty, model.TicketId);
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
||||
private bool IsValidRequestFromFreshdesk(string key)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key)
|
||||
|| !CoreHelpers.FixedTimeEquals(key, _billingSettings.FreshDesk.WebhookKey))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task CreateNote(string ticketId, string note)
|
||||
{
|
||||
var noteBody = new Dictionary<string, object>
|
||||
{
|
||||
{ "body", $"<ul>{note}</ul>" },
|
||||
{ "private", true }
|
||||
};
|
||||
var noteRequest = new HttpRequestMessage(HttpMethod.Post,
|
||||
string.Format("https://bitwarden.freshdesk.com/api/v2/tickets/{0}/notes", ticketId))
|
||||
{
|
||||
Content = JsonContent.Create(noteBody),
|
||||
};
|
||||
await CallFreshdeskApiAsync(noteRequest);
|
||||
}
|
||||
|
||||
private async Task AddAnswerNoteToTicketAsync(string note, string ticketId)
|
||||
{
|
||||
// if there is no content, then we don't need to add a note
|
||||
if (string.IsNullOrWhiteSpace(note))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var noteBody = new Dictionary<string, object>
|
||||
{
|
||||
{ "body", $"<b>Onyx AI:</b><ul>{note}</ul>" },
|
||||
{ "private", true }
|
||||
};
|
||||
|
||||
var noteRequest = new HttpRequestMessage(HttpMethod.Post,
|
||||
string.Format("https://bitwarden.freshdesk.com/api/v2/tickets/{0}/notes", ticketId))
|
||||
{
|
||||
Content = JsonContent.Create(noteBody),
|
||||
};
|
||||
|
||||
var addNoteResponse = await CallFreshdeskApiAsync(noteRequest);
|
||||
if (addNoteResponse.StatusCode != System.Net.HttpStatusCode.Created)
|
||||
{
|
||||
_logger.LogError("Error adding note to Freshdesk ticket. Ticket Id: {0}. Status: {1}",
|
||||
ticketId, addNoteResponse.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddReplyToTicketAsync(string note, string ticketId)
|
||||
{
|
||||
// if there is no content, then we don't need to add a note
|
||||
if (string.IsNullOrWhiteSpace(note))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// convert note from markdown to html
|
||||
var htmlNote = note;
|
||||
try
|
||||
{
|
||||
var pipeline = new MarkdownPipelineBuilder().UseAdvancedExtensions().Build();
|
||||
htmlNote = Markdig.Markdown.ToHtml(note, pipeline);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error converting markdown to HTML for Freshdesk reply. Ticket Id: {0}. Note: {1}",
|
||||
ticketId, note);
|
||||
htmlNote = note; // fallback to the original note
|
||||
}
|
||||
|
||||
// clear out any new lines that Freshdesk doesn't like
|
||||
if (_billingSettings.FreshDesk.RemoveNewlinesInReplies)
|
||||
{
|
||||
htmlNote = htmlNote.Replace(Environment.NewLine, string.Empty);
|
||||
}
|
||||
|
||||
var replyBody = new FreshdeskReplyRequestModel
|
||||
{
|
||||
Body = $"{_billingSettings.FreshDesk.AutoReplyGreeting}{htmlNote}{_billingSettings.FreshDesk.AutoReplySalutation}",
|
||||
};
|
||||
|
||||
var replyRequest = new HttpRequestMessage(HttpMethod.Post,
|
||||
string.Format("https://bitwarden.freshdesk.com/api/v2/tickets/{0}/reply", ticketId))
|
||||
{
|
||||
Content = JsonContent.Create(replyBody),
|
||||
};
|
||||
|
||||
var addReplyResponse = await CallFreshdeskApiAsync(replyRequest);
|
||||
if (addReplyResponse.StatusCode != System.Net.HttpStatusCode.Created)
|
||||
{
|
||||
_logger.LogError("Error adding reply to Freshdesk ticket. Ticket Id: {0}. Status: {1}",
|
||||
ticketId, addReplyResponse.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<HttpResponseMessage> CallFreshdeskApiAsync(HttpRequestMessage request, int retriedCount = 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
var freshdeskAuthkey = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{_billingSettings.FreshDesk.ApiKey}:X"));
|
||||
var httpClient = _httpClientFactory.CreateClient("FreshdeskApi");
|
||||
request.Headers.Add("Authorization", $"Basic {freshdeskAuthkey}");
|
||||
var response = await httpClient.SendAsync(request);
|
||||
if (response.StatusCode != System.Net.HttpStatusCode.TooManyRequests || retriedCount > 3)
|
||||
{
|
||||
return response;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (retriedCount > 3)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
}
|
||||
await Task.Delay(30000 * (retriedCount + 1));
|
||||
return await CallFreshdeskApiAsync(request, retriedCount++);
|
||||
}
|
||||
|
||||
async Task<(OnyxRequestModel onyxRequest, OnyxResponseModel onyxResponse)> GetAnswerFromOnyx(FreshdeskOnyxAiWebhookModel model)
|
||||
{
|
||||
// TODO: remove the use of the deprecated answer-with-citation models after we are sure
|
||||
if (_billingSettings.Onyx.UseAnswerWithCitationModels)
|
||||
{
|
||||
var onyxRequest = new OnyxAnswerWithCitationRequestModel(model.TicketDescriptionText, _billingSettings.Onyx);
|
||||
var onyxAnswerWithCitationRequest = new HttpRequestMessage(HttpMethod.Post,
|
||||
string.Format("{0}/query/answer-with-citation", _billingSettings.Onyx.BaseUrl))
|
||||
{
|
||||
Content = JsonContent.Create(onyxRequest, mediaType: new MediaTypeHeaderValue("application/json")),
|
||||
};
|
||||
var onyxResponse = await CallOnyxApi<OnyxResponseModel>(onyxAnswerWithCitationRequest);
|
||||
return (onyxRequest, onyxResponse);
|
||||
}
|
||||
|
||||
var request = new OnyxSendMessageSimpleApiRequestModel(model.TicketDescriptionText, _billingSettings.Onyx);
|
||||
var onyxSimpleRequest = new HttpRequestMessage(HttpMethod.Post,
|
||||
string.Format("{0}{1}", _billingSettings.Onyx.BaseUrl, _billingSettings.Onyx.Path))
|
||||
{
|
||||
Content = JsonContent.Create(request, mediaType: new MediaTypeHeaderValue("application/json")),
|
||||
};
|
||||
var onyxSimpleResponse = await CallOnyxApi<OnyxResponseModel>(onyxSimpleRequest);
|
||||
return (request, onyxSimpleResponse);
|
||||
}
|
||||
|
||||
private async Task<T> CallOnyxApi<T>(HttpRequestMessage request) where T : class, new()
|
||||
{
|
||||
var httpClient = _httpClientFactory.CreateClient("OnyxApi");
|
||||
var response = await httpClient.SendAsync(request);
|
||||
|
||||
if (response.StatusCode != System.Net.HttpStatusCode.OK)
|
||||
{
|
||||
_logger.LogError("Error calling Onyx AI API. Status code: {0}. Response {1}",
|
||||
response.StatusCode, JsonSerializer.Serialize(response));
|
||||
return new T();
|
||||
}
|
||||
var responseStr = await response.Content.ReadAsStringAsync();
|
||||
var responseJson = JsonSerializer.Deserialize<T>(responseStr, options: new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
});
|
||||
|
||||
return responseJson ?? new T();
|
||||
}
|
||||
|
||||
private TAttribute? GetAttribute<TAttribute>(Enum enumValue) where TAttribute : Attribute
|
||||
{
|
||||
var memberInfo = enumValue.GetType().GetMember(enumValue.ToString()).FirstOrDefault();
|
||||
return memberInfo != null ? memberInfo.GetCustomAttribute<TAttribute>() : null;
|
||||
}
|
||||
}
|
||||
@@ -1,248 +0,0 @@
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json.Serialization;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Bit.Billing.Controllers;
|
||||
|
||||
[Route("freshsales")]
|
||||
public class FreshsalesController : Controller
|
||||
{
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly ILogger _logger;
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
|
||||
private readonly string _freshsalesApiKey;
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
public FreshsalesController(IUserRepository userRepository,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOptions<BillingSettings> billingSettings,
|
||||
ILogger<FreshsalesController> logger,
|
||||
GlobalSettings globalSettings)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
_organizationRepository = organizationRepository;
|
||||
_logger = logger;
|
||||
_globalSettings = globalSettings;
|
||||
|
||||
_httpClient = new HttpClient
|
||||
{
|
||||
BaseAddress = new Uri("https://bitwarden.freshsales.io/api/")
|
||||
};
|
||||
|
||||
_freshsalesApiKey = billingSettings.Value.FreshsalesApiKey;
|
||||
|
||||
_httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
|
||||
"Token",
|
||||
$"token={_freshsalesApiKey}");
|
||||
}
|
||||
|
||||
|
||||
[HttpPost("webhook")]
|
||||
public async Task<IActionResult> PostWebhook([FromHeader(Name = "Authorization")] string key,
|
||||
[FromBody] CustomWebhookRequestModel request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key) || !CoreHelpers.FixedTimeEquals(_freshsalesApiKey, key))
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var leadResponse = await _httpClient.GetFromJsonAsync<LeadWrapper<FreshsalesLeadModel>>(
|
||||
$"leads/{request.LeadId}",
|
||||
cancellationToken);
|
||||
|
||||
var lead = leadResponse.Lead;
|
||||
|
||||
var primaryEmail = lead.Emails
|
||||
.Where(e => e.IsPrimary)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (primaryEmail == null)
|
||||
{
|
||||
return BadRequest(new { Message = "Lead has not primary email." });
|
||||
}
|
||||
|
||||
var user = await _userRepository.GetByEmailAsync(primaryEmail.Value);
|
||||
|
||||
if (user == null)
|
||||
{
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
var newTags = new HashSet<string>();
|
||||
|
||||
if (user.Premium)
|
||||
{
|
||||
newTags.Add("Premium");
|
||||
}
|
||||
|
||||
var noteItems = new List<string>
|
||||
{
|
||||
$"User, {user.Email}: {_globalSettings.BaseServiceUri.Admin}/users/edit/{user.Id}"
|
||||
};
|
||||
|
||||
var orgs = await _organizationRepository.GetManyByUserIdAsync(user.Id);
|
||||
|
||||
foreach (var org in orgs)
|
||||
{
|
||||
noteItems.Add($"Org, {org.DisplayName()}: {_globalSettings.BaseServiceUri.Admin}/organizations/edit/{org.Id}");
|
||||
if (TryGetPlanName(org.PlanType, out var planName))
|
||||
{
|
||||
newTags.Add($"Org: {planName}");
|
||||
}
|
||||
}
|
||||
|
||||
if (newTags.Any())
|
||||
{
|
||||
var allTags = newTags.Concat(lead.Tags);
|
||||
var updateLeadResponse = await _httpClient.PutAsJsonAsync(
|
||||
$"leads/{request.LeadId}",
|
||||
CreateWrapper(new { tags = allTags }),
|
||||
cancellationToken);
|
||||
updateLeadResponse.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
var createNoteResponse = await _httpClient.PostAsJsonAsync(
|
||||
"notes",
|
||||
CreateNoteRequestModel(request.LeadId, string.Join('\n', noteItems)), cancellationToken);
|
||||
createNoteResponse.EnsureSuccessStatusCode();
|
||||
return NoContent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine(ex);
|
||||
_logger.LogError(ex, "Error processing freshsales webhook");
|
||||
return BadRequest(new { ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static LeadWrapper<T> CreateWrapper<T>(T lead)
|
||||
{
|
||||
return new LeadWrapper<T>
|
||||
{
|
||||
Lead = lead,
|
||||
};
|
||||
}
|
||||
|
||||
private static CreateNoteRequestModel CreateNoteRequestModel(long leadId, string content)
|
||||
{
|
||||
return new CreateNoteRequestModel
|
||||
{
|
||||
Note = new EditNoteModel
|
||||
{
|
||||
Description = content,
|
||||
TargetableType = "Lead",
|
||||
TargetableId = leadId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static bool TryGetPlanName(PlanType planType, out string planName)
|
||||
{
|
||||
switch (planType)
|
||||
{
|
||||
case PlanType.Free:
|
||||
planName = "Free";
|
||||
return true;
|
||||
case PlanType.FamiliesAnnually:
|
||||
case PlanType.FamiliesAnnually2025:
|
||||
case PlanType.FamiliesAnnually2019:
|
||||
planName = "Families";
|
||||
return true;
|
||||
case PlanType.TeamsAnnually:
|
||||
case PlanType.TeamsAnnually2023:
|
||||
case PlanType.TeamsAnnually2020:
|
||||
case PlanType.TeamsAnnually2019:
|
||||
case PlanType.TeamsMonthly:
|
||||
case PlanType.TeamsMonthly2023:
|
||||
case PlanType.TeamsMonthly2020:
|
||||
case PlanType.TeamsMonthly2019:
|
||||
case PlanType.TeamsStarter:
|
||||
case PlanType.TeamsStarter2023:
|
||||
planName = "Teams";
|
||||
return true;
|
||||
case PlanType.EnterpriseAnnually:
|
||||
case PlanType.EnterpriseAnnually2023:
|
||||
case PlanType.EnterpriseAnnually2020:
|
||||
case PlanType.EnterpriseAnnually2019:
|
||||
case PlanType.EnterpriseMonthly:
|
||||
case PlanType.EnterpriseMonthly2023:
|
||||
case PlanType.EnterpriseMonthly2020:
|
||||
case PlanType.EnterpriseMonthly2019:
|
||||
planName = "Enterprise";
|
||||
return true;
|
||||
case PlanType.Custom:
|
||||
planName = "Custom";
|
||||
return true;
|
||||
default:
|
||||
planName = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class CustomWebhookRequestModel
|
||||
{
|
||||
[JsonPropertyName("leadId")]
|
||||
public long LeadId { get; set; }
|
||||
}
|
||||
|
||||
public class LeadWrapper<T>
|
||||
{
|
||||
[JsonPropertyName("lead")]
|
||||
public T Lead { get; set; }
|
||||
|
||||
public static LeadWrapper<TItem> Create<TItem>(TItem lead)
|
||||
{
|
||||
return new LeadWrapper<TItem>
|
||||
{
|
||||
Lead = lead,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public class FreshsalesLeadModel
|
||||
{
|
||||
public string[] Tags { get; set; }
|
||||
public FreshsalesEmailModel[] Emails { get; set; }
|
||||
}
|
||||
|
||||
public class FreshsalesEmailModel
|
||||
{
|
||||
[JsonPropertyName("value")]
|
||||
public string Value { get; set; }
|
||||
|
||||
[JsonPropertyName("is_primary")]
|
||||
public bool IsPrimary { get; set; }
|
||||
}
|
||||
|
||||
public class CreateNoteRequestModel
|
||||
{
|
||||
[JsonPropertyName("note")]
|
||||
public EditNoteModel Note { get; set; }
|
||||
}
|
||||
|
||||
public class EditNoteModel
|
||||
{
|
||||
[JsonPropertyName("description")]
|
||||
public string Description { get; set; }
|
||||
|
||||
[JsonPropertyName("targetable_type")]
|
||||
public string TargetableType { get; set; }
|
||||
|
||||
[JsonPropertyName("targetable_id")]
|
||||
public long TargetableId { get; set; }
|
||||
}
|
||||
@@ -39,15 +39,11 @@ public class ReconcileAdditionalStorageJob(
|
||||
logger.LogInformation("Starting ReconcileAdditionalStorageJob (live mode: {LiveMode})", liveMode);
|
||||
|
||||
var priceIds = new[] { _storageGbMonthlyPriceId, _storageGbAnnuallyPriceId, _personalStorageGbAnnuallyPriceId };
|
||||
var stripeStatusesToProcess = new[] { StripeConstants.SubscriptionStatus.Active, StripeConstants.SubscriptionStatus.Trialing, StripeConstants.SubscriptionStatus.PastDue };
|
||||
|
||||
foreach (var priceId in priceIds)
|
||||
{
|
||||
var options = new SubscriptionListOptions
|
||||
{
|
||||
Limit = 100,
|
||||
Status = StripeConstants.SubscriptionStatus.Active,
|
||||
Price = priceId
|
||||
};
|
||||
var options = new SubscriptionListOptions { Limit = 100, Price = priceId };
|
||||
|
||||
await foreach (var subscription in stripeFacade.ListSubscriptionsAutoPagingAsync(options))
|
||||
{
|
||||
@@ -64,7 +60,7 @@ public class ReconcileAdditionalStorageJob(
|
||||
failures.Count > 0
|
||||
? $", Failures: {Environment.NewLine}{string.Join(Environment.NewLine, failures)}"
|
||||
: string.Empty
|
||||
);
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -73,6 +69,12 @@ public class ReconcileAdditionalStorageJob(
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!stripeStatusesToProcess.Contains(subscription.Status))
|
||||
{
|
||||
logger.LogInformation("Skipping subscription with unsupported status: {SubscriptionId} - {Status}", subscription.Id, subscription.Status);
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.LogInformation("Processing subscription: {SubscriptionId}", subscription.Id);
|
||||
subscriptionsFound++;
|
||||
|
||||
@@ -133,7 +135,7 @@ public class ReconcileAdditionalStorageJob(
|
||||
failures.Count > 0
|
||||
? $", Failures: {Environment.NewLine}{string.Join(Environment.NewLine, failures)}"
|
||||
: string.Empty
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
private SubscriptionUpdateOptions? BuildSubscriptionUpdateOptions(
|
||||
@@ -145,15 +147,7 @@ public class ReconcileAdditionalStorageJob(
|
||||
return null;
|
||||
}
|
||||
|
||||
var updateOptions = new SubscriptionUpdateOptions
|
||||
{
|
||||
ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations,
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
[StripeConstants.MetadataKeys.StorageReconciled2025] = DateTime.UtcNow.ToString("o")
|
||||
},
|
||||
Items = []
|
||||
};
|
||||
var updateOptions = new SubscriptionUpdateOptions { ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations, Metadata = new Dictionary<string, string> { [StripeConstants.MetadataKeys.StorageReconciled2025] = DateTime.UtcNow.ToString("o") }, Items = [] };
|
||||
|
||||
var hasUpdates = false;
|
||||
|
||||
@@ -172,11 +166,7 @@ public class ReconcileAdditionalStorageJob(
|
||||
newQuantity,
|
||||
item.Price.Id);
|
||||
|
||||
updateOptions.Items.Add(new SubscriptionItemOptions
|
||||
{
|
||||
Id = item.Id,
|
||||
Quantity = newQuantity
|
||||
});
|
||||
updateOptions.Items.Add(new SubscriptionItemOptions { Id = item.Id, Quantity = newQuantity });
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -185,11 +175,7 @@ public class ReconcileAdditionalStorageJob(
|
||||
currentQuantity,
|
||||
item.Price.Id);
|
||||
|
||||
updateOptions.Items.Add(new SubscriptionItemOptions
|
||||
{
|
||||
Id = item.Id,
|
||||
Deleted = true
|
||||
});
|
||||
updateOptions.Items.Add(new SubscriptionItemOptions { Id = item.Id, Deleted = true });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Bit.Billing.Models;
|
||||
|
||||
public class FreshdeskReplyRequestModel
|
||||
{
|
||||
[JsonPropertyName("body")]
|
||||
public required string Body { get; set; }
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Bit.Billing.Models;
|
||||
|
||||
public class FreshdeskWebhookModel
|
||||
{
|
||||
[JsonPropertyName("ticket_id")]
|
||||
public string TicketId { get; set; }
|
||||
|
||||
[JsonPropertyName("ticket_contact_email")]
|
||||
public string TicketContactEmail { get; set; }
|
||||
|
||||
[JsonPropertyName("ticket_tags")]
|
||||
public string TicketTags { get; set; }
|
||||
}
|
||||
|
||||
public class FreshdeskOnyxAiWebhookModel : FreshdeskWebhookModel
|
||||
{
|
||||
[JsonPropertyName("ticket_description_text")]
|
||||
public string TicketDescriptionText { get; set; }
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using static Bit.Billing.BillingSettings;
|
||||
|
||||
namespace Bit.Billing.Models;
|
||||
|
||||
public class OnyxRequestModel
|
||||
{
|
||||
[JsonPropertyName("persona_id")]
|
||||
public int PersonaId { get; set; } = 1;
|
||||
|
||||
[JsonPropertyName("retrieval_options")]
|
||||
public RetrievalOptions RetrievalOptions { get; set; } = new RetrievalOptions();
|
||||
|
||||
public OnyxRequestModel(OnyxSettings onyxSettings)
|
||||
{
|
||||
PersonaId = onyxSettings.PersonaId;
|
||||
RetrievalOptions.RunSearch = onyxSettings.SearchSettings.RunSearch;
|
||||
RetrievalOptions.RealTime = onyxSettings.SearchSettings.RealTime;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This is used with the onyx endpoint /query/answer-with-citation
|
||||
/// which has been deprecated. This can be removed once later
|
||||
/// </summary>
|
||||
public class OnyxAnswerWithCitationRequestModel : OnyxRequestModel
|
||||
{
|
||||
[JsonPropertyName("messages")]
|
||||
public List<Message> Messages { get; set; } = new List<Message>();
|
||||
|
||||
public OnyxAnswerWithCitationRequestModel(string message, OnyxSettings onyxSettings) : base(onyxSettings)
|
||||
{
|
||||
message = message.Replace(Environment.NewLine, " ").Replace('\r', ' ').Replace('\n', ' ');
|
||||
Messages = new List<Message>() { new Message() { MessageText = message } };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This is used with the onyx endpoint /chat/send-message-simple-api
|
||||
/// </summary>
|
||||
public class OnyxSendMessageSimpleApiRequestModel : OnyxRequestModel
|
||||
{
|
||||
[JsonPropertyName("message")]
|
||||
public string Message { get; set; } = string.Empty;
|
||||
|
||||
public OnyxSendMessageSimpleApiRequestModel(string message, OnyxSettings onyxSettings) : base(onyxSettings)
|
||||
{
|
||||
Message = message.Replace(Environment.NewLine, " ").Replace('\r', ' ').Replace('\n', ' ');
|
||||
}
|
||||
}
|
||||
|
||||
public class Message
|
||||
{
|
||||
[JsonPropertyName("message")]
|
||||
public string MessageText { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("sender")]
|
||||
public string Sender { get; set; } = "user";
|
||||
}
|
||||
|
||||
public class RetrievalOptions
|
||||
{
|
||||
[JsonPropertyName("run_search")]
|
||||
public string RunSearch { get; set; } = RetrievalOptionsRunSearch.Auto;
|
||||
|
||||
[JsonPropertyName("real_time")]
|
||||
public bool RealTime { get; set; } = true;
|
||||
}
|
||||
|
||||
public class RetrievalOptionsRunSearch
|
||||
{
|
||||
public const string Always = "always";
|
||||
public const string Never = "never";
|
||||
public const string Auto = "auto";
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Bit.Billing.Models;
|
||||
|
||||
public class OnyxResponseModel
|
||||
{
|
||||
[JsonPropertyName("answer")]
|
||||
public string Answer { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("answer_citationless")]
|
||||
public string AnswerCitationless { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("error_msg")]
|
||||
public string ErrorMsg { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ public class Program
|
||||
{
|
||||
Host
|
||||
.CreateDefaultBuilder(args)
|
||||
.UseBitwardenSdk()
|
||||
.ConfigureWebHostDefaults(webBuilder =>
|
||||
{
|
||||
webBuilder.UseStartup<Startup>();
|
||||
|
||||
@@ -36,7 +36,7 @@ public interface IStripeEventUtilityService
|
||||
/// <param name="userId"></param>
|
||||
/// /// <param name="providerId"></param>
|
||||
/// <returns></returns>
|
||||
Transaction FromChargeToTransaction(Charge charge, Guid? organizationId, Guid? userId, Guid? providerId);
|
||||
Task<Transaction> FromChargeToTransactionAsync(Charge charge, Guid? organizationId, Guid? userId, Guid? providerId);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to pay the specified invoice. If a customer is eligible, the invoice is paid using Braintree or Stripe.
|
||||
|
||||
@@ -20,6 +20,12 @@ public interface IStripeFacade
|
||||
RequestOptions requestOptions = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
IAsyncEnumerable<CustomerCashBalanceTransaction> GetCustomerCashBalanceTransactions(
|
||||
string customerId,
|
||||
CustomerCashBalanceTransactionListOptions customerCashBalanceTransactionListOptions = null,
|
||||
RequestOptions requestOptions = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<Customer> UpdateCustomer(
|
||||
string customerId,
|
||||
CustomerUpdateOptions customerUpdateOptions = null,
|
||||
|
||||
@@ -38,7 +38,7 @@ public class ChargeRefundedHandler : IChargeRefundedHandler
|
||||
{
|
||||
// Attempt to create a transaction for the charge if it doesn't exist
|
||||
var (organizationId, userId, providerId) = await _stripeEventUtilityService.GetEntityIdsFromChargeAsync(charge);
|
||||
var tx = _stripeEventUtilityService.FromChargeToTransaction(charge, organizationId, userId, providerId);
|
||||
var tx = await _stripeEventUtilityService.FromChargeToTransactionAsync(charge, organizationId, userId, providerId);
|
||||
try
|
||||
{
|
||||
parentTransaction = await _transactionRepository.CreateAsync(tx);
|
||||
|
||||
@@ -46,7 +46,7 @@ public class ChargeSucceededHandler : IChargeSucceededHandler
|
||||
return;
|
||||
}
|
||||
|
||||
var transaction = _stripeEventUtilityService.FromChargeToTransaction(charge, organizationId, userId, providerId);
|
||||
var transaction = await _stripeEventUtilityService.FromChargeToTransactionAsync(charge, organizationId, userId, providerId);
|
||||
if (!transaction.PaymentMethodType.HasValue)
|
||||
{
|
||||
_logger.LogWarning("Charge success from unsupported source/method. {ChargeId}", charge.Id);
|
||||
|
||||
@@ -124,7 +124,7 @@ public class StripeEventUtilityService : IStripeEventUtilityService
|
||||
/// <param name="userId"></param>
|
||||
/// /// <param name="providerId"></param>
|
||||
/// <returns></returns>
|
||||
public Transaction FromChargeToTransaction(Charge charge, Guid? organizationId, Guid? userId, Guid? providerId)
|
||||
public async Task<Transaction> FromChargeToTransactionAsync(Charge charge, Guid? organizationId, Guid? userId, Guid? providerId)
|
||||
{
|
||||
var transaction = new Transaction
|
||||
{
|
||||
@@ -209,6 +209,24 @@ public class StripeEventUtilityService : IStripeEventUtilityService
|
||||
transaction.PaymentMethodType = PaymentMethodType.BankAccount;
|
||||
transaction.Details = $"ACH => {achCreditTransfer.BankName}, {achCreditTransfer.AccountNumber}";
|
||||
}
|
||||
else if (charge.PaymentMethodDetails.CustomerBalance != null)
|
||||
{
|
||||
var bankTransferType = await GetFundingBankTransferTypeAsync(charge);
|
||||
|
||||
if (!string.IsNullOrEmpty(bankTransferType))
|
||||
{
|
||||
transaction.PaymentMethodType = PaymentMethodType.BankAccount;
|
||||
transaction.Details = bankTransferType switch
|
||||
{
|
||||
"eu_bank_transfer" => "EU Bank Transfer",
|
||||
"gb_bank_transfer" => "GB Bank Transfer",
|
||||
"jp_bank_transfer" => "JP Bank Transfer",
|
||||
"mx_bank_transfer" => "MX Bank Transfer",
|
||||
"us_bank_transfer" => "US Bank Transfer",
|
||||
_ => "Bank Transfer"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
@@ -289,20 +307,13 @@ public class StripeEventUtilityService : IStripeEventUtilityService
|
||||
}
|
||||
var btInvoiceAmount = Math.Round(invoice.AmountDue / 100M, 2);
|
||||
|
||||
var existingTransactions = organizationId.HasValue
|
||||
? await _transactionRepository.GetManyByOrganizationIdAsync(organizationId.Value)
|
||||
: userId.HasValue
|
||||
? await _transactionRepository.GetManyByUserIdAsync(userId.Value)
|
||||
: await _transactionRepository.GetManyByProviderIdAsync(providerId.Value);
|
||||
|
||||
var duplicateTimeSpan = TimeSpan.FromHours(24);
|
||||
var now = DateTime.UtcNow;
|
||||
var duplicateTransaction = existingTransactions?
|
||||
.FirstOrDefault(t => (now - t.CreationDate) < duplicateTimeSpan);
|
||||
if (duplicateTransaction != null)
|
||||
// Check if this invoice already has a Braintree transaction ID to prevent duplicate charges
|
||||
if (invoice.Metadata?.ContainsKey("btTransactionId") ?? false)
|
||||
{
|
||||
_logger.LogWarning("There is already a recent PayPal transaction ({0}). " +
|
||||
"Do not charge again to prevent possible duplicate.", duplicateTransaction.GatewayId);
|
||||
_logger.LogWarning("Invoice {InvoiceId} already has a Braintree transaction ({TransactionId}). " +
|
||||
"Do not charge again to prevent duplicate.",
|
||||
invoice.Id,
|
||||
invoice.Metadata["btTransactionId"]);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -413,4 +424,55 @@ public class StripeEventUtilityService : IStripeEventUtilityService
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the bank transfer type that funded a charge paid via customer balance.
|
||||
/// </summary>
|
||||
/// <param name="charge">The charge to analyze.</param>
|
||||
/// <returns>
|
||||
/// The bank transfer type (e.g., "us_bank_transfer", "eu_bank_transfer") if the charge was funded
|
||||
/// by a bank transfer via customer balance, otherwise null.
|
||||
/// </returns>
|
||||
private async Task<string> GetFundingBankTransferTypeAsync(Charge charge)
|
||||
{
|
||||
if (charge is not
|
||||
{
|
||||
CustomerId: not null,
|
||||
PaymentIntentId: not null,
|
||||
PaymentMethodDetails: { Type: "customer_balance" }
|
||||
})
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var cashBalanceTransactions = _stripeFacade.GetCustomerCashBalanceTransactions(charge.CustomerId);
|
||||
|
||||
string bankTransferType = null;
|
||||
var matchingPaymentIntentFound = false;
|
||||
|
||||
await foreach (var cashBalanceTransaction in cashBalanceTransactions)
|
||||
{
|
||||
switch (cashBalanceTransaction)
|
||||
{
|
||||
case { Type: "funded", Funded: not null }:
|
||||
{
|
||||
bankTransferType = cashBalanceTransaction.Funded.BankTransfer.Type;
|
||||
break;
|
||||
}
|
||||
case { Type: "applied_to_payment", AppliedToPayment: not null }
|
||||
when cashBalanceTransaction.AppliedToPayment.PaymentIntentId == charge.PaymentIntentId:
|
||||
{
|
||||
matchingPaymentIntentFound = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (matchingPaymentIntentFound && !string.IsNullOrEmpty(bankTransferType))
|
||||
{
|
||||
return bankTransferType;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ public class StripeFacade : IStripeFacade
|
||||
{
|
||||
private readonly ChargeService _chargeService = new();
|
||||
private readonly CustomerService _customerService = new();
|
||||
private readonly CustomerCashBalanceTransactionService _customerCashBalanceTransactionService = new();
|
||||
private readonly EventService _eventService = new();
|
||||
private readonly InvoiceService _invoiceService = new();
|
||||
private readonly PaymentMethodService _paymentMethodService = new();
|
||||
@@ -41,6 +42,13 @@ public class StripeFacade : IStripeFacade
|
||||
CancellationToken cancellationToken = default) =>
|
||||
await _customerService.GetAsync(customerId, customerGetOptions, requestOptions, cancellationToken);
|
||||
|
||||
public IAsyncEnumerable<CustomerCashBalanceTransaction> GetCustomerCashBalanceTransactions(
|
||||
string customerId,
|
||||
CustomerCashBalanceTransactionListOptions customerCashBalanceTransactionListOptions = null,
|
||||
RequestOptions requestOptions = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> _customerCashBalanceTransactionService.ListAutoPagingAsync(customerId, customerCashBalanceTransactionListOptions, requestOptions, cancellationToken);
|
||||
|
||||
public async Task<Customer> UpdateCustomer(
|
||||
string customerId,
|
||||
CustomerUpdateOptions customerUpdateOptions = null,
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
#nullable disable
|
||||
|
||||
using System.Globalization;
|
||||
using System.Net.Http.Headers;
|
||||
using Bit.Billing.Services;
|
||||
using Bit.Billing.Services.Implementations;
|
||||
using Bit.Commercial.Core.Utilities;
|
||||
@@ -98,13 +97,6 @@ public class Startup
|
||||
// Authentication
|
||||
services.AddAuthentication();
|
||||
|
||||
// Set up HttpClients
|
||||
services.AddHttpClient("FreshdeskApi");
|
||||
services.AddHttpClient("OnyxApi", client =>
|
||||
{
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", billingSettings.Onyx.ApiKey);
|
||||
});
|
||||
|
||||
services.AddScoped<IStripeFacade, StripeFacade>();
|
||||
services.AddScoped<IStripeEventService, StripeEventService>();
|
||||
services.AddScoped<IProviderEventService, ProviderEventService>();
|
||||
|
||||
@@ -32,10 +32,5 @@
|
||||
"connectionString": "UseDevelopmentStorage=true"
|
||||
}
|
||||
},
|
||||
"billingSettings": {
|
||||
"onyx": {
|
||||
"personaId": 68
|
||||
}
|
||||
},
|
||||
"pricingUri": "https://billingpricing.qa.bitwarden.pw"
|
||||
}
|
||||
|
||||
@@ -26,10 +26,7 @@
|
||||
"payPal": {
|
||||
"production": true,
|
||||
"businessId": "4ZDA7DLUUJGMN"
|
||||
},
|
||||
"onyx": {
|
||||
"personaId": 7
|
||||
}
|
||||
}
|
||||
},
|
||||
"Logging": {
|
||||
"IncludeScopes": false,
|
||||
|
||||
@@ -61,27 +61,6 @@
|
||||
"production": false,
|
||||
"businessId": "AD3LAUZSNVPJY",
|
||||
"webhookKey": "SECRET"
|
||||
},
|
||||
"freshdesk": {
|
||||
"apiKey": "SECRET",
|
||||
"webhookKey": "SECRET",
|
||||
"region": "US",
|
||||
"userFieldName": "cf_user",
|
||||
"orgFieldName": "cf_org",
|
||||
"removeNewlinesInReplies": true,
|
||||
"autoReplyGreeting": "<b>Greetings,</b><br /><br />Thank you for contacting Bitwarden. The reply below was generated by our AI agent based on your message:<br /><br />",
|
||||
"autoReplySalutation": "<br /><br />If this response doesn’t fully address your question, simply reply to this email and a member of our Customer Success team will be happy to assist you further.<br /><p><b>Best Regards,</b><br />The Bitwarden Customer Success Team</p>"
|
||||
},
|
||||
"onyx": {
|
||||
"apiKey": "SECRET",
|
||||
"baseUrl": "https://cloud.onyx.app/api",
|
||||
"path": "/chat/send-message-simple-api",
|
||||
"useAnswerWithCitationModels": true,
|
||||
"personaId": 7,
|
||||
"searchSettings": {
|
||||
"runSearch": "always",
|
||||
"realTime": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public record DatadogIntegration(string ApiKey, Uri Uri);
|
||||
@@ -1,16 +0,0 @@
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public class IntegrationHandlerResult
|
||||
{
|
||||
public IntegrationHandlerResult(bool success, IIntegrationMessage message)
|
||||
{
|
||||
Success = success;
|
||||
Message = message;
|
||||
}
|
||||
|
||||
public bool Success { get; set; } = false;
|
||||
public bool Retryable { get; set; } = false;
|
||||
public IIntegrationMessage Message { get; set; }
|
||||
public DateTime? DelayUntilDate { get; set; }
|
||||
public string FailureReason { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public record SlackIntegration(string Token);
|
||||
@@ -1,3 +0,0 @@
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public record SlackIntegrationConfiguration(string ChannelId);
|
||||
@@ -20,6 +20,12 @@ public class OrganizationUserUserDetails : IExternal, ITwoFactorProvidersUser, I
|
||||
public string Email { get; set; }
|
||||
public string AvatarColor { get; set; }
|
||||
public string TwoFactorProviders { get; set; }
|
||||
/// <summary>
|
||||
/// Indicates whether the user has a personal premium subscription.
|
||||
/// Does not include premium access from organizations -
|
||||
/// do not use this to check whether the user can access premium features.
|
||||
/// Null when the organization user is in Invited status (UserId is null).
|
||||
/// </summary>
|
||||
public bool? Premium { get; set; }
|
||||
public OrganizationUserStatusType Status { get; set; }
|
||||
public OrganizationUserType Type { get; set; }
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Auth.Models.Business.Tokenables;
|
||||
@@ -34,6 +35,7 @@ public class AcceptOrgUserCommand : IAcceptOrgUserCommand
|
||||
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IPolicyRequirementQuery _policyRequirementQuery;
|
||||
private readonly IAutomaticUserConfirmationPolicyEnforcementValidator _automaticUserConfirmationPolicyEnforcementValidator;
|
||||
|
||||
public AcceptOrgUserCommand(
|
||||
IDataProtectionProvider dataProtectionProvider,
|
||||
@@ -46,7 +48,8 @@ public class AcceptOrgUserCommand : IAcceptOrgUserCommand
|
||||
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
|
||||
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory,
|
||||
IFeatureService featureService,
|
||||
IPolicyRequirementQuery policyRequirementQuery)
|
||||
IPolicyRequirementQuery policyRequirementQuery,
|
||||
IAutomaticUserConfirmationPolicyEnforcementValidator automaticUserConfirmationPolicyEnforcementValidator)
|
||||
{
|
||||
// TODO: remove data protector when old token validation removed
|
||||
_dataProtector = dataProtectionProvider.CreateProtector(OrgUserInviteTokenable.DataProtectorPurpose);
|
||||
@@ -60,6 +63,7 @@ public class AcceptOrgUserCommand : IAcceptOrgUserCommand
|
||||
_orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory;
|
||||
_featureService = featureService;
|
||||
_policyRequirementQuery = policyRequirementQuery;
|
||||
_automaticUserConfirmationPolicyEnforcementValidator = automaticUserConfirmationPolicyEnforcementValidator;
|
||||
}
|
||||
|
||||
public async Task<OrganizationUser> AcceptOrgUserByEmailTokenAsync(Guid organizationUserId, User user, string emailToken,
|
||||
@@ -186,13 +190,19 @@ public class AcceptOrgUserCommand : IAcceptOrgUserCommand
|
||||
}
|
||||
}
|
||||
|
||||
// Enforce Single Organization Policy of organization user is trying to join
|
||||
var allOrgUsers = await _organizationUserRepository.GetManyByUserAsync(user.Id);
|
||||
var hasOtherOrgs = allOrgUsers.Any(ou => ou.OrganizationId != orgUser.OrganizationId);
|
||||
|
||||
if (_featureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers))
|
||||
{
|
||||
await ValidateAutomaticUserConfirmationPolicyAsync(orgUser, allOrgUsers, user);
|
||||
}
|
||||
|
||||
// Enforce Single Organization Policy of organization user is trying to join
|
||||
var invitedSingleOrgPolicies = await _policyService.GetPoliciesApplicableToUserAsync(user.Id,
|
||||
PolicyType.SingleOrg, OrganizationUserStatusType.Invited);
|
||||
|
||||
if (hasOtherOrgs && invitedSingleOrgPolicies.Any(p => p.OrganizationId == orgUser.OrganizationId))
|
||||
if (allOrgUsers.Any(ou => ou.OrganizationId != orgUser.OrganizationId)
|
||||
&& invitedSingleOrgPolicies.Any(p => p.OrganizationId == orgUser.OrganizationId))
|
||||
{
|
||||
throw new BadRequestException("You may not join this organization until you leave or remove all other organizations.");
|
||||
}
|
||||
@@ -255,4 +265,22 @@ public class AcceptOrgUserCommand : IAcceptOrgUserCommand
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ValidateAutomaticUserConfirmationPolicyAsync(OrganizationUser orgUser,
|
||||
ICollection<OrganizationUser> allOrgUsers, User user)
|
||||
{
|
||||
var error = (await _automaticUserConfirmationPolicyEnforcementValidator.IsCompliantAsync(
|
||||
new AutomaticUserConfirmationPolicyEnforcementRequest(orgUser.OrganizationId,
|
||||
allOrgUsers.Append(orgUser),
|
||||
user)))
|
||||
.Match(
|
||||
error => error.Message,
|
||||
_ => string.Empty
|
||||
);
|
||||
|
||||
if (!string.IsNullOrEmpty(error))
|
||||
{
|
||||
throw new BadRequestException(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.AdminConsole.Utilities.v2;
|
||||
@@ -8,6 +9,7 @@ using Bit.Core.AdminConsole.Utilities.v2.Validation;
|
||||
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using static Bit.Core.AdminConsole.Utilities.v2.Validation.ValidationResultHelpers;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser;
|
||||
@@ -16,6 +18,8 @@ public class AutomaticallyConfirmOrganizationUsersValidator(
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
|
||||
IPolicyRequirementQuery policyRequirementQuery,
|
||||
IAutomaticUserConfirmationPolicyEnforcementValidator automaticUserConfirmationPolicyEnforcementValidator,
|
||||
IUserService userService,
|
||||
IPolicyRepository policyRepository) : IAutomaticallyConfirmOrganizationUsersValidator
|
||||
{
|
||||
public async Task<ValidationResult<AutomaticallyConfirmOrganizationUserValidationRequest>> ValidateAsync(
|
||||
@@ -61,7 +65,7 @@ public class AutomaticallyConfirmOrganizationUsersValidator(
|
||||
return Invalid(request, new UserDoesNotHaveTwoFactorEnabled());
|
||||
}
|
||||
|
||||
if (await OrganizationUserConformsToSingleOrgPolicyAsync(request) is { } error)
|
||||
if (await OrganizationUserConformsToAutomaticUserConfirmationPolicyAsync(request) is { } error)
|
||||
{
|
||||
return Invalid(request, error);
|
||||
}
|
||||
@@ -69,10 +73,8 @@ public class AutomaticallyConfirmOrganizationUsersValidator(
|
||||
return Valid(request);
|
||||
}
|
||||
|
||||
private async Task<bool> OrganizationHasAutomaticallyConfirmUsersPolicyEnabledAsync(
|
||||
AutomaticallyConfirmOrganizationUserValidationRequest request) =>
|
||||
await policyRepository.GetByOrganizationIdTypeAsync(request.OrganizationId,
|
||||
PolicyType.AutomaticUserConfirmation) is { Enabled: true }
|
||||
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)
|
||||
@@ -87,30 +89,37 @@ public class AutomaticallyConfirmOrganizationUsersValidator(
|
||||
.IsTwoFactorRequiredForOrganization(request.Organization!.Id);
|
||||
}
|
||||
|
||||
private async Task<Error?> OrganizationUserConformsToSingleOrgPolicyAsync(
|
||||
/// <summary>
|
||||
/// Validates whether the specified organization user complies with the automatic user confirmation policy.
|
||||
/// This includes checks across all organizations the user is associated with to ensure they meet the compliance criteria.
|
||||
///
|
||||
/// We are not checking single organization policy compliance here because automatically confirm users policy enforces
|
||||
/// a stricter version and applies to all users. If you are compliant with Auto Confirm, you'll be in compliance with
|
||||
/// Single Org.
|
||||
/// </summary>
|
||||
/// <param name="request">
|
||||
/// The request model encapsulates the current organization, the user being validated, and all organization users associated
|
||||
/// with that user.
|
||||
/// </param>
|
||||
/// <returns>
|
||||
/// An <see cref="Error"/> if the user fails to meet the automatic user confirmation policy, or null if the validation succeeds.
|
||||
/// </returns>
|
||||
private async Task<Error?> OrganizationUserConformsToAutomaticUserConfirmationPolicyAsync(
|
||||
AutomaticallyConfirmOrganizationUserValidationRequest request)
|
||||
{
|
||||
var allOrganizationUsersForUser = await organizationUserRepository
|
||||
.GetManyByUserAsync(request.OrganizationUser!.UserId!.Value);
|
||||
|
||||
if (allOrganizationUsersForUser.Count == 1)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
var user = await userService.GetUserByIdAsync(request.OrganizationUser!.UserId!.Value);
|
||||
|
||||
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;
|
||||
return (await automaticUserConfirmationPolicyEnforcementValidator.IsCompliantAsync(
|
||||
new AutomaticUserConfirmationPolicyEnforcementRequest(
|
||||
request.OrganizationId,
|
||||
allOrganizationUsersForUser,
|
||||
user)))
|
||||
.Match<Error?>(
|
||||
error => error,
|
||||
_ => null
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@ public record UserIsNotUserType() : BadRequestError("Only organization users wit
|
||||
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 UserCannotBelongToAnotherOrganization() : BadRequestError("Cannot confirm this member to the organization until they leave or remove all other organizations");
|
||||
public record OtherOrganizationDoesNotAllowOtherMembership() : 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.");
|
||||
public record ProviderUsersCannotJoin() : BadRequestError("An organization the user is a part of has enabled Automatic User Confirmation policy, and it does not support provider users joining.");
|
||||
public record UserCannotJoinProvider() : BadRequestError("An organization the user is a part of has enabled Automatic User Confirmation policy, and it does not support the user joining a provider.");
|
||||
public record CurrentOrganizationUserIsNotPresentInRequest() : BadRequestError("The current organization user does not exist in the request.");
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
||||
@@ -33,6 +34,7 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand
|
||||
private readonly IPolicyRequirementQuery _policyRequirementQuery;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly ICollectionRepository _collectionRepository;
|
||||
private readonly IAutomaticUserConfirmationPolicyEnforcementValidator _automaticUserConfirmationPolicyEnforcementValidator;
|
||||
|
||||
public ConfirmOrganizationUserCommand(
|
||||
IOrganizationRepository organizationRepository,
|
||||
@@ -47,7 +49,8 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand
|
||||
IDeviceRepository deviceRepository,
|
||||
IPolicyRequirementQuery policyRequirementQuery,
|
||||
IFeatureService featureService,
|
||||
ICollectionRepository collectionRepository)
|
||||
ICollectionRepository collectionRepository,
|
||||
IAutomaticUserConfirmationPolicyEnforcementValidator automaticUserConfirmationPolicyEnforcementValidator)
|
||||
{
|
||||
_organizationRepository = organizationRepository;
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
@@ -62,6 +65,7 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand
|
||||
_policyRequirementQuery = policyRequirementQuery;
|
||||
_featureService = featureService;
|
||||
_collectionRepository = collectionRepository;
|
||||
_automaticUserConfirmationPolicyEnforcementValidator = automaticUserConfirmationPolicyEnforcementValidator;
|
||||
}
|
||||
|
||||
public async Task<OrganizationUser> ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key,
|
||||
@@ -127,6 +131,7 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand
|
||||
|
||||
var organization = await _organizationRepository.GetByIdAsync(organizationId);
|
||||
var allUsersOrgs = await _organizationUserRepository.GetManyByManyUsersAsync(validSelectedUserIds);
|
||||
|
||||
var users = await _userRepository.GetManyAsync(validSelectedUserIds);
|
||||
var usersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(validSelectedUserIds);
|
||||
|
||||
@@ -188,6 +193,25 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand
|
||||
await ValidateTwoFactorAuthenticationPolicyAsync(user, organizationId, userTwoFactorEnabled);
|
||||
|
||||
var hasOtherOrgs = userOrgs.Any(ou => ou.OrganizationId != organizationId);
|
||||
|
||||
if (_featureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers))
|
||||
{
|
||||
var error = (await _automaticUserConfirmationPolicyEnforcementValidator.IsCompliantAsync(
|
||||
new AutomaticUserConfirmationPolicyEnforcementRequest(
|
||||
organizationId,
|
||||
userOrgs,
|
||||
user)))
|
||||
.Match(
|
||||
error => new BadRequestException(error.Message),
|
||||
_ => null
|
||||
);
|
||||
|
||||
if (error is not null)
|
||||
{
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
var singleOrgPolicies = await _policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg);
|
||||
var otherSingleOrgPolicies =
|
||||
singleOrgPolicies.Where(p => p.OrganizationId != organizationId);
|
||||
@@ -267,8 +291,7 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand
|
||||
return;
|
||||
}
|
||||
|
||||
var organizationDataOwnershipPolicy =
|
||||
await _policyRequirementQuery.GetAsync<OrganizationDataOwnershipPolicyRequirement>(organizationUser.UserId!.Value);
|
||||
var organizationDataOwnershipPolicy = await _policyRequirementQuery.GetAsync<OrganizationDataOwnershipPolicyRequirement>(organizationUser.UserId!.Value);
|
||||
if (!organizationDataOwnershipPolicy.RequiresDefaultCollectionOnConfirm(organizationUser.OrganizationId))
|
||||
{
|
||||
return;
|
||||
@@ -311,8 +334,8 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand
|
||||
return;
|
||||
}
|
||||
|
||||
var policyEligibleOrganizationUserIds =
|
||||
await _policyRequirementQuery.GetManyByOrganizationIdAsync<OrganizationDataOwnershipPolicyRequirement>(organizationId);
|
||||
var policyEligibleOrganizationUserIds = await _policyRequirementQuery
|
||||
.GetManyByOrganizationIdAsync<OrganizationDataOwnershipPolicyRequirement>(organizationId);
|
||||
|
||||
var eligibleOrganizationUserIds = confirmedOrganizationUsers
|
||||
.Where(ou => policyEligibleOrganizationUserIds.Contains(ou.Id))
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
||||
@@ -29,7 +30,8 @@ public class RestoreOrganizationUserCommand(
|
||||
IUserRepository userRepository,
|
||||
IOrganizationService organizationService,
|
||||
IFeatureService featureService,
|
||||
IPolicyRequirementQuery policyRequirementQuery) : IRestoreOrganizationUserCommand
|
||||
IPolicyRequirementQuery policyRequirementQuery,
|
||||
IAutomaticUserConfirmationPolicyEnforcementValidator automaticUserConfirmationPolicyEnforcementValidator) : IRestoreOrganizationUserCommand
|
||||
{
|
||||
public async Task RestoreUserAsync(OrganizationUser organizationUser, Guid? restoringUserId)
|
||||
{
|
||||
@@ -300,6 +302,25 @@ public class RestoreOrganizationUserCommand(
|
||||
{
|
||||
throw new BadRequestException(user.Email + " is not compliant with the two-step login policy");
|
||||
}
|
||||
|
||||
if (featureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers))
|
||||
{
|
||||
var validationResult = await automaticUserConfirmationPolicyEnforcementValidator.IsCompliantAsync(
|
||||
new AutomaticUserConfirmationPolicyEnforcementRequest(orgUser.OrganizationId,
|
||||
allOrgUsers,
|
||||
user!));
|
||||
|
||||
var badRequestException = validationResult.Match(
|
||||
error => new BadRequestException(user.Email +
|
||||
" is not compliant with the automatic user confirmation policy: " +
|
||||
error.Message),
|
||||
_ => null);
|
||||
|
||||
if (badRequestException is not null)
|
||||
{
|
||||
throw badRequestException;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> IsTwoFactorRequiredForOrganizationAsync(Guid userId, Guid organizationId)
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Organizations.Models;
|
||||
@@ -43,7 +45,9 @@ public class CloudOrganizationSignUpCommand(
|
||||
IPushNotificationService pushNotificationService,
|
||||
ICollectionRepository collectionRepository,
|
||||
IDeviceRepository deviceRepository,
|
||||
IPricingClient pricingClient) : ICloudOrganizationSignUpCommand
|
||||
IPricingClient pricingClient,
|
||||
IPolicyRequirementQuery policyRequirementQuery,
|
||||
IFeatureService featureService) : ICloudOrganizationSignUpCommand
|
||||
{
|
||||
public async Task<SignUpOrganizationResponse> SignUpOrganizationAsync(OrganizationSignup signup)
|
||||
{
|
||||
@@ -95,8 +99,8 @@ public class CloudOrganizationSignUpCommand(
|
||||
ReferenceData = signup.Owner.ReferenceData,
|
||||
Enabled = true,
|
||||
LicenseKey = CoreHelpers.SecureRandomString(20),
|
||||
PublicKey = signup.PublicKey,
|
||||
PrivateKey = signup.PrivateKey,
|
||||
PublicKey = signup.Keys?.PublicKey,
|
||||
PrivateKey = signup.Keys?.WrappedPrivateKey,
|
||||
CreationDate = DateTime.UtcNow,
|
||||
RevisionDate = DateTime.UtcNow,
|
||||
Status = OrganizationStatusType.Created,
|
||||
@@ -237,6 +241,17 @@ public class CloudOrganizationSignUpCommand(
|
||||
|
||||
private async Task ValidateSignUpPoliciesAsync(Guid ownerId)
|
||||
{
|
||||
if (featureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers))
|
||||
{
|
||||
var requirement = await policyRequirementQuery.GetAsync<AutomaticUserConfirmationPolicyRequirement>(ownerId);
|
||||
|
||||
if (requirement.CannotCreateNewOrganization())
|
||||
{
|
||||
throw new BadRequestException("You may not create an organization. You belong to an organization " +
|
||||
"which has a policy that prohibits you from being a member of any other organization.");
|
||||
}
|
||||
}
|
||||
|
||||
var anySingleOrgPolicies = await policyService.AnyPoliciesApplicableToUserAsync(ownerId, PolicyType.SingleOrg);
|
||||
if (anySingleOrgPolicies)
|
||||
{
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user