diff --git a/.claude/prompts/review-code.md b/.claude/prompts/review-code.md deleted file mode 100644 index 4e5f40b274..0000000000 --- a/.claude/prompts/review-code.md +++ /dev/null @@ -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
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. diff --git a/.editorconfig b/.editorconfig index fd68808456..71dd40de98 100644 --- a/.editorconfig +++ b/.editorconfig @@ -71,10 +71,10 @@ dotnet_naming_symbols.any_async_methods.applicable_kinds = method dotnet_naming_symbols.any_async_methods.applicable_accessibilities = * dotnet_naming_symbols.any_async_methods.required_modifiers = async -dotnet_naming_style.end_in_async.required_prefix = +dotnet_naming_style.end_in_async.required_prefix = dotnet_naming_style.end_in_async.required_suffix = Async dotnet_naming_style.end_in_async.capitalization = pascal_case -dotnet_naming_style.end_in_async.word_separator = +dotnet_naming_style.end_in_async.word_separator = # Obsolete warnings, this should be removed or changed to warning once we address some of the obsolete items. dotnet_diagnostic.CS0618.severity = suggestion @@ -85,6 +85,12 @@ dotnet_diagnostic.CS0612.severity = suggestion # Remove unnecessary using directives https://docs.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0005 dotnet_diagnostic.IDE0005.severity = warning +# Specify CultureInfo https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1304 +dotnet_diagnostic.CA1304.severity = warning + +# Specify IFormatProvider https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1305 +dotnet_diagnostic.CA1305.severity = warning + # CSharp code style settings: [*.cs] # Prefer "var" everywhere diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 65780bdb63..f0c85d98c1 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -36,6 +36,7 @@ util/Setup/** @bitwarden/dept-bre @bitwarden/team-platform-dev # UIF src/Core/MailTemplates/Mjml @bitwarden/team-ui-foundation # Teams are expected to own sub-directories of this project +src/Core/MailTemplates/Mjml/.mjmlconfig # This change allows teams to add components within their own subdirectories without requiring a code review from UIF. # Auth team **/Auth @bitwarden/team-auth-dev @@ -52,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 @@ -62,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 diff --git a/.github/ISSUE_TEMPLATE/bw-lite.yml b/.github/ISSUE_TEMPLATE/bw-lite.yml index f46f4b3e37..0c43fa5835 100644 --- a/.github/ISSUE_TEMPLATE/bw-lite.yml +++ b/.github/ISSUE_TEMPLATE/bw-lite.yml @@ -1,4 +1,4 @@ -name: Bitwarden Lite Deployment Bug Report +name: Bitwarden lite Deployment Bug Report description: File a bug report labels: [bug, bw-lite-deploy] body: @@ -70,15 +70,6 @@ body: mariadb:10 # Postgres Example postgres:14 - - type: textarea - id: epic-label - attributes: - label: Issue-Link - description: Link to our pinned issue, tracking all Bitwarden Lite - value: | - https://github.com/bitwarden/server/issues/2480 - validations: - required: true - type: checkboxes id: issue-tracking-info attributes: diff --git a/.github/renovate.json5 b/.github/renovate.json5 index bc377ed46c..77539ef839 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -10,41 +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, - }, - { - matchSourceUrls: ["https://github.com/bitwarden/sdk-internal"], - groupName: "sdk-internal", - }, + // ==================== Team Ownership Rules ==================== { matchManagers: ["dockerfile", "docker-compose"], commitMessagePrefix: "[deps] BRE:", @@ -63,11 +29,11 @@ }, { matchPackageNames: [ - "Azure.Extensions.AspNetCore.DataProtection.Blobs", "DuoUniversal", "Fido2.AspNet", "Duende.IdentityServer", "Microsoft.AspNetCore.Authentication.JwtBearer", + "Microsoft.Extensions.Caching.Cosmos", "Microsoft.Extensions.Identity.Stores", "Otp.NET", "Sustainsys.Saml2.AspNetCore2", @@ -90,11 +56,7 @@ "Microsoft.AspNetCore.Mvc.Testing", "Newtonsoft.Json", "NSubstitute", - "Sentry.Serilog", - "Serilog.AspNetCore", - "Serilog.Extensions.Logging", "Serilog.Extensions.Logging.File", - "Serilog.Sinks.SyslogMessages", "Stripe.net", "Swashbuckle.AspNetCore", "Swashbuckle.AspNetCore.SwaggerGen", @@ -105,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", @@ -141,6 +98,7 @@ "AspNetCoreRateLimit", "AspNetCoreRateLimit.Redis", "Azure.Data.Tables", + "Azure.Extensions.AspNetCore.DataProtection.Blobs", "Azure.Messaging.EventGrid", "Azure.Messaging.ServiceBus", "Azure.Storage.Blobs", @@ -156,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", @@ -165,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", @@ -194,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"], } diff --git a/.github/workflows/_move_edd_db_scripts.yml b/.github/workflows/_move_edd_db_scripts.yml index 7e97fa2a07..742e7b897e 100644 --- a/.github/workflows/_move_edd_db_scripts.yml +++ b/.github/workflows/_move_edd_db_scripts.yml @@ -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 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 877281ccb0..f3cc279a58 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,13 +25,13 @@ 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 - name: Set up .NET - uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0 + uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0 - name: Verify format run: dotnet format --verify-no-changes @@ -39,8 +39,7 @@ jobs: build-artifacts: name: Build Docker images runs-on: ubuntu-22.04 - needs: - - lint + needs: lint outputs: has_secrets: ${{ steps.check-secrets.outputs.has_secrets }} permissions: @@ -102,7 +101,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 @@ -120,10 +119,10 @@ jobs: fi - name: Set up .NET - uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0 + uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0 - name: Set up Node - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: "npm" cache-dependency-path: "**/package-lock.json" @@ -160,7 +159,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 +168,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 @@ -185,13 +184,6 @@ jobs: - name: Log in to ACR - production subscription run: az acr login -n bitwardenprod - - name: Retrieve GitHub PAT secrets - id: retrieve-secret-pat - uses: bitwarden/gh-actions/get-keyvault-secrets@main - with: - keyvault: "bitwarden-ci" - secrets: "github-pat-bitwarden-devops-bot-repo-scope" - ########## Generate image tag and build Docker image ########## - name: Generate Docker image tag id: tag @@ -250,12 +242,10 @@ jobs: linux/arm64 push: true tags: ${{ steps.image-tags.outputs.tags }} - secrets: | - "GH_PAT=${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}" - 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' @@ -273,14 +263,14 @@ 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 output-format: sarif - name: Upload Grype results to GitHub - uses: github/codeql-action/upload-sarif@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2 + uses: github/codeql-action/upload-sarif@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 with: sarif_file: ${{ steps.container-scan.outputs.sarif }} sha: ${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.sha || github.sha }} @@ -298,13 +288,13 @@ 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 - name: Set up .NET - uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0 + uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0 - name: Log in to Azure uses: bitwarden/gh-actions/azure-login@main @@ -365,7 +355,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 @@ -375,7 +365,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 @@ -387,21 +377,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 @@ -410,8 +400,7 @@ jobs: build-mssqlmigratorutility: name: Build MSSQL migrator utility runs-on: ubuntu-22.04 - needs: - - lint + needs: lint defaults: run: shell: bash @@ -425,13 +414,13 @@ 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 - name: Set up .NET - uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0 + uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0 - name: Print environment run: | @@ -447,7 +436,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 @@ -455,20 +444,19 @@ 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 if-no-files-found: error - self-host-build: - name: Trigger self-host build + bitwarden-lite-build: + name: Trigger Bitwarden lite build if: | github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') runs-on: ubuntu-22.04 - needs: - - build-artifacts + needs: build-artifacts permissions: id-token: write steps: @@ -479,20 +467,29 @@ jobs: tenant_id: ${{ secrets.AZURE_TENANT_ID }} client_id: ${{ secrets.AZURE_CLIENT_ID }} - - name: Retrieve GitHub PAT secrets - id: retrieve-secret-pat + - name: Get Azure Key Vault secrets + id: get-kv-secrets uses: bitwarden/gh-actions/get-keyvault-secrets@main with: - keyvault: "bitwarden-ci" - secrets: "github-pat-bitwarden-devops-bot-repo-scope" + keyvault: gh-org-bitwarden + secrets: "BW-GHAPP-ID,BW-GHAPP-KEY" - name: Log out from Azure uses: bitwarden/gh-actions/azure-logout@main - - name: Trigger Bitwarden Lite build + - name: Generate GH App token + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 + id: app-token + with: + app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} + private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }} + owner: ${{ github.repository_owner }} + repositories: self-host + + - name: Trigger Bitwarden lite build uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: - github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }} + github-token: ${{ steps.app-token.outputs.token }} script: | await github.rest.actions.createWorkflowDispatch({ owner: 'bitwarden', @@ -505,11 +502,10 @@ jobs: }); trigger-k8s-deploy: - name: Trigger k8s deploy + name: Trigger K8s deploy if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' runs-on: ubuntu-22.04 - needs: - - build-artifacts + needs: build-artifacts permissions: id-token: write steps: @@ -520,20 +516,29 @@ jobs: tenant_id: ${{ secrets.AZURE_TENANT_ID }} client_id: ${{ secrets.AZURE_CLIENT_ID }} - - name: Retrieve GitHub PAT secrets - id: retrieve-secret-pat + - name: Get Azure Key Vault secrets + id: get-kv-secrets uses: bitwarden/gh-actions/get-keyvault-secrets@main with: - keyvault: "bitwarden-ci" - secrets: "github-pat-bitwarden-devops-bot-repo-scope" + keyvault: gh-org-bitwarden + secrets: "BW-GHAPP-ID,BW-GHAPP-KEY" - name: Log out from Azure uses: bitwarden/gh-actions/azure-logout@main - - name: Trigger k8s deploy + - name: Generate GH App token + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 + id: app-token + with: + app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} + private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }} + owner: ${{ github.repository_owner }} + repositories: devops + + - name: Trigger K8s deploy uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: - github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }} + github-token: ${{ steps.app-token.outputs.token }} script: | await github.rest.actions.createWorkflowDispatch({ owner: 'bitwarden', @@ -548,8 +553,7 @@ jobs: setup-ephemeral-environment: name: Setup Ephemeral Environment - needs: - - build-artifacts + needs: build-artifacts if: | needs.build-artifacts.outputs.has_secrets == 'true' && github.event_name == 'pull_request' @@ -572,7 +576,7 @@ jobs: - build-artifacts - upload - build-mssqlmigratorutility - - self-host-build + - bitwarden-lite-build - trigger-k8s-deploy permissions: id-token: write diff --git a/.github/workflows/cleanup-after-pr.yml b/.github/workflows/cleanup-after-pr.yml deleted file mode 100644 index 4e59f1fa96..0000000000 --- a/.github/workflows/cleanup-after-pr.yml +++ /dev/null @@ -1,71 +0,0 @@ -name: Container registry cleanup - -on: - pull_request: - types: [closed] - -env: - _AZ_REGISTRY: "bitwardenprod.azurecr.io" - -jobs: - build-docker: - name: Remove branch-specific Docker images - runs-on: ubuntu-22.04 - permissions: - id-token: write - steps: - - name: Log in to Azure - uses: bitwarden/gh-actions/azure-login@main - with: - subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - tenant_id: ${{ secrets.AZURE_TENANT_ID }} - client_id: ${{ secrets.AZURE_CLIENT_ID }} - - - name: Log in to Azure ACR - run: az acr login -n "$_AZ_REGISTRY" --only-show-errors - - ########## Remove Docker images ########## - - name: Remove the Docker image from ACR - env: - REF: ${{ github.event.pull_request.head.ref }} - SERVICES: | - services: - - Admin - - Api - - Attachments - - Events - - EventsProcessor - - Icons - - Identity - - K8S-Proxy - - MsSql - - Nginx - - Notifications - - Server - - Setup - - Sso - run: | - for SERVICE in $(echo "${SERVICES}" | yq e ".services[]" - ) - do - SERVICE_NAME=$(echo "$SERVICE" | awk '{print tolower($0)}') - IMAGE_TAG=$(echo "${REF}" | sed "s#/#-#g") # slash safe branch name - - echo "[*] Checking if remote exists: $_AZ_REGISTRY/$SERVICE_NAME:$IMAGE_TAG" - TAG_EXISTS=$( - az acr repository show-tags --name "$_AZ_REGISTRY" --repository "$SERVICE_NAME" \ - | jq --arg TAG "$IMAGE_TAG" -e '. | any(. == $TAG)' - ) - - if [[ "$TAG_EXISTS" == "true" ]]; then - echo "[*] Tag exists. Removing tag" - az acr repository delete --name "$_AZ_REGISTRY" --image "$SERVICE_NAME:$IMAGE_TAG" --yes - else - echo "[*] Tag does not exist. No action needed" - fi - done - - - name: Log out of Docker - run: docker logout - - - name: Log out from Azure - uses: bitwarden/gh-actions/azure-logout@main diff --git a/.github/workflows/cleanup-rc-branch.yml b/.github/workflows/cleanup-rc-branch.yml index 63079826c7..ae482ef4e6 100644 --- a/.github/workflows/cleanup-rc-branch.yml +++ b/.github/workflows/cleanup-rc-branch.yml @@ -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 }} diff --git a/.github/workflows/code-references.yml b/.github/workflows/code-references.yml index 35e6cfdd40..cb7ca9e200 100644 --- a/.github/workflows/code-references.yml +++ b/.github/workflows/code-references.yml @@ -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 diff --git a/.github/workflows/load-test.yml b/.github/workflows/load-test.yml index cdb53109f5..10bfe50d10 100644 --- a/.github/workflows/load-test.yml +++ b/.github/workflows/load-test.yml @@ -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_ diff --git a/.github/workflows/protect-files.yml b/.github/workflows/protect-files.yml index a939be6fdb..4b137eb221 100644 --- a/.github/workflows/protect-files.yml +++ b/.github/workflows/protect-files.yml @@ -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 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2272387d84..7983bef2bc 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 75b4df4e5c..a3c4fb1ffd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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, diff --git a/.github/workflows/repository-management.yml b/.github/workflows/repository-management.yml index 92452102cf..c98faed340 100644 --- a/.github/workflows/repository-management.yml +++ b/.github/workflows/repository-management.yml @@ -22,9 +22,7 @@ on: required: false type: string -permissions: - pull-requests: write - contents: write +permissions: {} jobs: setup: @@ -32,6 +30,7 @@ jobs: runs-on: ubuntu-24.04 outputs: branch: ${{ steps.set-branch.outputs.branch }} + permissions: {} steps: - name: Set branch id: set-branch @@ -84,14 +83,15 @@ 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 }} private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }} + 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,14 +207,15 @@ 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 }} private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }} + 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 }} @@ -240,10 +241,5 @@ jobs: move_edd_db_scripts: name: Move EDD database scripts needs: cut_branch - permissions: - actions: read - contents: write - id-token: write - pull-requests: write + permissions: {} uses: ./.github/workflows/_move_edd_db_scripts.yml - secrets: inherit diff --git a/.github/workflows/review-code.yml b/.github/workflows/review-code.yml index 0e0597fccf..000f402096 100644 --- a/.github/workflows/review-code.yml +++ b/.github/workflows/review-code.yml @@ -2,7 +2,7 @@ name: Code Review on: pull_request: - types: [opened, synchronize, reopened, ready_for_review] + types: [opened, synchronize, reopened] permissions: {} diff --git a/.github/workflows/stale-bot.yml b/.github/workflows/stale-bot.yml index 83d492645e..c683400a60 100644 --- a/.github/workflows/stale-bot.yml +++ b/.github/workflows/stale-bot.yml @@ -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" diff --git a/.github/workflows/test-database.yml b/.github/workflows/test-database.yml index 20bc67bc6b..25ff9d0488 100644 --- a/.github/workflows/test-database.yml +++ b/.github/workflows/test-database.yml @@ -44,12 +44,12 @@ 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 - name: Set up .NET - uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0 + uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0 - name: Restore tools run: dotnet tool restore @@ -62,7 +62,7 @@ jobs: docker compose --profile mssql --profile postgres --profile mysql up -d shell: pwsh - - name: Add MariaDB for Bitwarden Lite + - name: Add MariaDB for Bitwarden lite # Use a different port than MySQL run: | docker run --detach --name mariadb --env MARIADB_ROOT_PASSWORD=mariadb-password -p 4306:3306 mariadb:10 @@ -133,7 +133,7 @@ jobs: # Default Sqlite BW_TEST_DATABASES__3__TYPE: "Sqlite" BW_TEST_DATABASES__3__CONNECTIONSTRING: "Data Source=${{ runner.temp }}/test.db" - # Bitwarden Lite MariaDB + # Bitwarden lite MariaDB BW_TEST_DATABASES__4__TYPE: "MySql" BW_TEST_DATABASES__4__CONNECTIONSTRING: "server=localhost;port=4306;uid=root;pwd=mariadb-password;database=vault_dev;Allow User Variables=true" run: dotnet test --logger "trx;LogFileName=infrastructure-test-results.trx" /p:CoverletOutputFormatter="cobertura" --collect:"XPlat Code Coverage" @@ -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@b082adf0eced0765477756c2a610396589b8c637 # v2.5.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,12 +178,12 @@ 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 - name: Set up .NET - uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0 + uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0 - name: Print environment run: | @@ -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: | @@ -262,3 +262,26 @@ jobs: working-directory: "dev" run: docker compose down shell: pwsh + + validate-migration-naming: + name: Validate new migration naming and order + runs-on: ubuntu-22.04 + + steps: + - name: Check out repo + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Validate new migrations for pull request + if: github.event_name == 'pull_request' + run: | + git fetch origin main:main + pwsh dev/verify_migrations.ps1 -BaseRef main + shell: pwsh + + - name: Validate new migrations for push + if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' + run: pwsh dev/verify_migrations.ps1 -BaseRef HEAD~1 + shell: pwsh diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 36ab8785d5..12b5355c33 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -27,20 +27,20 @@ jobs: steps: - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false - name: Set up .NET - uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0 + uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0 - name: Install rust - uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable + uses: dtolnay/rust-toolchain@f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561 # stable with: 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@b082adf0eced0765477756c2a610396589b8c637 # v2.5.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 diff --git a/.gitignore b/.gitignore index 60fc894285..db8cb50f84 100644 --- a/.gitignore +++ b/.gitignore @@ -234,6 +234,7 @@ bitwarden_license/src/Sso/Sso.zip /identity.json /api.json /api.public.json +.serena/ # Serena .serena/ diff --git a/.vscode/launch.json b/.vscode/launch.json index c407ba5604..74115dcc86 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -69,6 +69,28 @@ "preLaunchTask": "buildFullServer", "stopAll": true }, + { + "name": "Full Server with Seeder API", + "configurations": [ + "run-Admin", + "run-API", + "run-Events", + "run-EventsProcessor", + "run-Identity", + "run-Sso", + "run-Icons", + "run-Billing", + "run-Notifications", + "run-SeederAPI" + ], + "presentation": { + "hidden": false, + "group": "AA_compounds", + "order": 6 + }, + "preLaunchTask": "buildFullServerWithSeederApi", + "stopAll": true + }, { "name": "Self Host: Bit", "configurations": [ @@ -204,6 +226,17 @@ }, "preLaunchTask": "buildSso", }, + { + "name": "Seeder API", + "configurations": [ + "run-SeederAPI" + ], + "presentation": { + "hidden": false, + "group": "cloud", + }, + "preLaunchTask": "buildSeederAPI", + }, { "name": "Admin Self Host", "configurations": [ @@ -270,6 +303,17 @@ }, "preLaunchTask": "buildSso", }, + { + "name": "Seeder API Self Host", + "configurations": [ + "run-SeederAPI-SelfHost" + ], + "presentation": { + "hidden": false, + "group": "self-host", + }, + "preLaunchTask": "buildSeederAPI", + } ], "configurations": [ // Configurations represent run-only scenarios so that they can be used in multiple compounds @@ -311,6 +355,25 @@ "/Views": "${workspaceFolder}/Views" } }, + { + "name": "run-SeederAPI", + "presentation": { + "hidden": true, + }, + "requireExactSource": true, + "type": "coreclr", + "request": "launch", + "program": "${workspaceFolder}/util/SeederApi/bin/Debug/net8.0/SeederApi.dll", + "args": [], + "cwd": "${workspaceFolder}/util/SeederApi", + "stopAtEntry": false, + "env": { + "ASPNETCORE_ENVIRONMENT": "Development", + }, + "sourceFileMap": { + "/Views": "${workspaceFolder}/Views" + } + }, { "name": "run-Billing", "presentation": { @@ -488,6 +551,27 @@ "/Views": "${workspaceFolder}/Views" } }, + { + "name": "run-SeederAPI-SelfHost", + "presentation": { + "hidden": true, + }, + "requireExactSource": true, + "type": "coreclr", + "request": "launch", + "program": "${workspaceFolder}/util/SeederApi/bin/Debug/net8.0/SeederApi.dll", + "args": [], + "cwd": "${workspaceFolder}/util/SeederApi", + "stopAtEntry": false, + "env": { + "ASPNETCORE_ENVIRONMENT": "Development", + "ASPNETCORE_URLS": "http://localhost:5048", + "developSelfHosted": "true", + }, + "sourceFileMap": { + "/Views": "${workspaceFolder}/Views" + } + }, { "name": "run-Admin-SelfHost", "presentation": { diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 567f9b6e58..07a55fdeb3 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -43,6 +43,21 @@ "label": "buildFullServer", "hide": true, "dependsOrder": "sequence", + "dependsOn": [ + "buildAdmin", + "buildAPI", + "buildEventsProcessor", + "buildIdentity", + "buildSso", + "buildIcons", + "buildBilling", + "buildNotifications" + ], + }, + { + "label": "buildFullServerWithSeederApi", + "hide": true, + "dependsOrder": "sequence", "dependsOn": [ "buildAdmin", "buildAPI", @@ -52,6 +67,7 @@ "buildIcons", "buildBilling", "buildNotifications", + "buildSeederAPI" ], }, { @@ -89,6 +105,9 @@ "/property:GenerateFullPaths=true", "/consoleloggerparameters:NoSummary" ], + "options": { + "cwd": "${workspaceFolder}" + }, "problemMatcher": "$msCompile" }, { @@ -102,6 +121,9 @@ "/property:GenerateFullPaths=true", "/consoleloggerparameters:NoSummary" ], + "options": { + "cwd": "${workspaceFolder}" + }, "problemMatcher": "$msCompile" }, { @@ -115,6 +137,9 @@ "/property:GenerateFullPaths=true", "/consoleloggerparameters:NoSummary" ], + "options": { + "cwd": "${workspaceFolder}" + }, "problemMatcher": "$msCompile" }, { @@ -128,6 +153,9 @@ "/property:GenerateFullPaths=true", "/consoleloggerparameters:NoSummary" ], + "options": { + "cwd": "${workspaceFolder}" + }, "problemMatcher": "$msCompile" }, { @@ -141,6 +169,9 @@ "/property:GenerateFullPaths=true", "/consoleloggerparameters:NoSummary" ], + "options": { + "cwd": "${workspaceFolder}" + }, "problemMatcher": "$msCompile" }, { @@ -154,6 +185,9 @@ "/property:GenerateFullPaths=true", "/consoleloggerparameters:NoSummary" ], + "options": { + "cwd": "${workspaceFolder}" + }, "problemMatcher": "$msCompile" }, { @@ -167,6 +201,9 @@ "/property:GenerateFullPaths=true", "/consoleloggerparameters:NoSummary" ], + "options": { + "cwd": "${workspaceFolder}" + }, "problemMatcher": "$msCompile" }, { @@ -180,6 +217,29 @@ "/property:GenerateFullPaths=true", "/consoleloggerparameters:NoSummary" ], + "options": { + "cwd": "${workspaceFolder}" + }, + "problemMatcher": "$msCompile", + "group": { + "kind": "build", + "isDefault": true + } + }, + { + "label": "buildSeederAPI", + "hide": true, + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/util/SeederApi/SeederApi.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "options": { + "cwd": "${workspaceFolder}" + }, "problemMatcher": "$msCompile", "group": { "kind": "build", @@ -197,6 +257,9 @@ "/property:GenerateFullPaths=true", "/consoleloggerparameters:NoSummary" ], + "options": { + "cwd": "${workspaceFolder}" + }, "problemMatcher": "$msCompile", "group": { "kind": "build", @@ -214,6 +277,9 @@ "/property:GenerateFullPaths=true", "/consoleloggerparameters:NoSummary" ], + "options": { + "cwd": "${workspaceFolder}" + }, "problemMatcher": "$msCompile", "group": { "kind": "build", @@ -224,6 +290,9 @@ "label": "test", "type": "shell", "command": "dotnet test", + "options": { + "cwd": "${workspaceFolder}" + }, "group": { "kind": "test", "isDefault": true diff --git a/Directory.Build.props b/Directory.Build.props index 3e55b8a8cc..e7a8422605 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,7 +3,7 @@ net8.0 - 2025.11.1 + 2026.1.0 Bit.$(MSBuildProjectName) enable @@ -13,21 +13,21 @@ true - + - - 17.8.0 - + + 18.0.1 + 2.6.6 - + 2.5.6 - + 6.0.0 - + 5.1.0 - + 4.18.1 - + 4.18.1 \ No newline at end of file diff --git a/README.md b/README.md index c817931c67..6aa609bc8c 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,42 @@ Invoke-RestMethod -OutFile bitwarden.ps1 ` .\bitwarden.ps1 -start ``` +## Production Container Images + +
+View Current Production Image Hashes (click to expand) +
+ +### US Production Cluster + +| Service | Image Hash | +|---------|------------| +| **Admin** | ![admin](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fbitwarden%2Fserver%2Frefs%2Fheads%2Fmetadata%2Fbadges%2Fshieldsio-badge-us.json&query=%24.admin&style=flat-square&logo=docker&logoColor=white&label=&color=2496ED) | +| **API** | ![api](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fbitwarden%2Fserver%2Frefs%2Fheads%2Fmetadata%2Fbadges%2Fshieldsio-badge-us.json&query=%24.api&style=flat-square&logo=docker&logoColor=white&label=&color=2496ED) | +| **Billing** | ![billing](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fbitwarden%2Fserver%2Frefs%2Fheads%2Fmetadata%2Fbadges%2Fshieldsio-badge-us.json&query=%24.billing&style=flat-square&logo=docker&logoColor=white&label=&color=2496ED) | +| **Events** | ![events](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fbitwarden%2Fserver%2Frefs%2Fheads%2Fmetadata%2Fbadges%2Fshieldsio-badge-us.json&query=%24.events&style=flat-square&logo=docker&logoColor=white&label=&color=2496ED) | +| **EventsProcessor** | ![eventsprocessor](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fbitwarden%2Fserver%2Frefs%2Fheads%2Fmetadata%2Fbadges%2Fshieldsio-badge-us.json&query=%24.eventsprocessor&style=flat-square&logo=docker&logoColor=white&label=&color=2496ED) | +| **Identity** | ![identity](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fbitwarden%2Fserver%2Frefs%2Fheads%2Fmetadata%2Fbadges%2Fshieldsio-badge-us.json&query=%24.identity&style=flat-square&logo=docker&logoColor=white&label=&color=2496ED) | +| **Notifications** | ![notifications](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fbitwarden%2Fserver%2Frefs%2Fheads%2Fmetadata%2Fbadges%2Fshieldsio-badge-us.json&query=%24.notifications&style=flat-square&logo=docker&logoColor=white&label=&color=2496ED) | +| **SCIM** | ![scim](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fbitwarden%2Fserver%2Frefs%2Fheads%2Fmetadata%2Fbadges%2Fshieldsio-badge-us.json&query=%24.scim&style=flat-square&logo=docker&logoColor=white&label=&color=2496ED) | +| **SSO** | ![sso](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fbitwarden%2Fserver%2Frefs%2Fheads%2Fmetadata%2Fbadges%2Fshieldsio-badge-us.json&query=%24.sso&style=flat-square&logo=docker&logoColor=white&label=&color=2496ED) | + +### EU Production Cluster + +| Service | Image Hash | +|---------|------------| +| **Admin** | ![admin](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fbitwarden%2Fserver%2Frefs%2Fheads%2Fmetadata%2Fbadges%2Fshieldsio-badge-eu.json&query=%24.admin&style=flat-square&logo=docker&logoColor=white&label=&color=2496ED) | +| **API** | ![api](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fbitwarden%2Fserver%2Frefs%2Fheads%2Fmetadata%2Fbadges%2Fshieldsio-badge-eu.json&query=%24.api&style=flat-square&logo=docker&logoColor=white&label=&color=2496ED) | +| **Billing** | ![billing](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fbitwarden%2Fserver%2Frefs%2Fheads%2Fmetadata%2Fbadges%2Fshieldsio-badge-eu.json&query=%24.billing&style=flat-square&logo=docker&logoColor=white&label=&color=2496ED) | +| **Events** | ![events](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fbitwarden%2Fserver%2Frefs%2Fheads%2Fmetadata%2Fbadges%2Fshieldsio-badge-eu.json&query=%24.events&style=flat-square&logo=docker&logoColor=white&label=&color=2496ED) | +| **EventsProcessor** | ![eventsprocessor](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fbitwarden%2Fserver%2Frefs%2Fheads%2Fmetadata%2Fbadges%2Fshieldsio-badge-eu.json&query=%24.eventsprocessor&style=flat-square&logo=docker&logoColor=white&label=&color=2496ED) | +| **Identity** | ![identity](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fbitwarden%2Fserver%2Frefs%2Fheads%2Fmetadata%2Fbadges%2Fshieldsio-badge-eu.json&query=%24.identity&style=flat-square&logo=docker&logoColor=white&label=&color=2496ED) | +| **Notifications** | ![notifications](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fbitwarden%2Fserver%2Frefs%2Fheads%2Fmetadata%2Fbadges%2Fshieldsio-badge-eu.json&query=%24.notifications&style=flat-square&logo=docker&logoColor=white&label=&color=2496ED) | +| **SCIM** | ![scim](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fbitwarden%2Fserver%2Frefs%2Fheads%2Fmetadata%2Fbadges%2Fshieldsio-badge-eu.json&query=%24.scim&style=flat-square&logo=docker&logoColor=white&label=&color=2496ED) | +| **SSO** | ![sso](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fbitwarden%2Fserver%2Frefs%2Fheads%2Fmetadata%2Fbadges%2Fshieldsio-badge-eu.json&query=%24.sso&style=flat-square&logo=docker&logoColor=white&label=&color=2496ED) | + +
+ ## 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. diff --git a/bitwarden-server.sln b/bitwarden-server.sln index 6786ad610c..409906e2d0 100644 --- a/bitwarden-server.sln +++ b/bitwarden-server.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.29102.190 +# Visual Studio Version 17 +VisualStudioVersion = 17.14.36705.20 d17.14 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src - AGPL", "src - AGPL", "{DD5BD056-4AAE-43EF-BBD2-0B569B8DA84D}" EndProject @@ -11,19 +11,19 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{DD5BD056-4 EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{458155D3-BCBC-481D-B37A-40D2ED10F0A4}" ProjectSection(SolutionItems) = preProject + .dockerignore = .dockerignore + .editorconfig = .editorconfig + .gitignore = .gitignore + CONTRIBUTING.md = CONTRIBUTING.md Directory.Build.props = Directory.Build.props global.json = global.json - .gitignore = .gitignore - README.md = README.md - .editorconfig = .editorconfig - TRADEMARK_GUIDELINES.md = TRADEMARK_GUIDELINES.md - SECURITY.md = SECURITY.md - LICENSE_FAQ.md = LICENSE_FAQ.md - LICENSE_BITWARDEN.txt = LICENSE_BITWARDEN.txt - LICENSE_AGPL.txt = LICENSE_AGPL.txt LICENSE.txt = LICENSE.txt - CONTRIBUTING.md = CONTRIBUTING.md - .dockerignore = .dockerignore + LICENSE_AGPL.txt = LICENSE_AGPL.txt + LICENSE_BITWARDEN.txt = LICENSE_BITWARDEN.txt + LICENSE_FAQ.md = LICENSE_FAQ.md + README.md = README.md + SECURITY.md = SECURITY.md + TRADEMARK_GUIDELINES.md = TRADEMARK_GUIDELINES.md EndProjectSection EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Core", "src\Core\Core.csproj", "{3973D21B-A692-4B60-9B70-3631C057423A}" @@ -134,10 +134,19 @@ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DbSeederUtility", "util\DbSeederUtility\DbSeederUtility.csproj", "{17A89266-260A-4A03-81AE-C0468C6EE06E}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RustSdk", "util\RustSdk\RustSdk.csproj", "{D1513D90-E4F5-44A9-9121-5E46E3E4A3F7}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharedWeb.Test", "test\SharedWeb.Test\SharedWeb.Test.csproj", "{AD59537D-5259-4B7A-948F-0CF58E80B359}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SeederApi", "util\SeederApi\SeederApi.csproj", "{9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SeederApi.IntegrationTest", "test\SeederApi.IntegrationTest\SeederApi.IntegrationTest.csproj", "{A2E067EF-609C-4D13-895A-E054C61D48BB}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SSO.Test", "bitwarden_license\test\SSO.Test\SSO.Test.csproj", "{7D98784C-C253-43FB-9873-25B65C6250D6}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sso.IntegrationTest", "bitwarden_license\test\Sso.IntegrationTest\Sso.IntegrationTest.csproj", "{FFB09376-595B-6F93-36F0-70CAE90AFECB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Server.IntegrationTest", "test\Server.IntegrationTest\Server.IntegrationTest.csproj", "{E75E1F10-BC6F-4EB1-BA75-D897C45AEA0D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -350,10 +359,26 @@ Global {AD59537D-5259-4B7A-948F-0CF58E80B359}.Debug|Any CPU.Build.0 = Debug|Any CPU {AD59537D-5259-4B7A-948F-0CF58E80B359}.Release|Any CPU.ActiveCfg = Release|Any CPU {AD59537D-5259-4B7A-948F-0CF58E80B359}.Release|Any CPU.Build.0 = Release|Any CPU + {9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F}.Release|Any CPU.Build.0 = Release|Any CPU + {A2E067EF-609C-4D13-895A-E054C61D48BB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A2E067EF-609C-4D13-895A-E054C61D48BB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A2E067EF-609C-4D13-895A-E054C61D48BB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A2E067EF-609C-4D13-895A-E054C61D48BB}.Release|Any CPU.Build.0 = Release|Any CPU {7D98784C-C253-43FB-9873-25B65C6250D6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {7D98784C-C253-43FB-9873-25B65C6250D6}.Debug|Any CPU.Build.0 = Debug|Any CPU {7D98784C-C253-43FB-9873-25B65C6250D6}.Release|Any CPU.ActiveCfg = Release|Any CPU {7D98784C-C253-43FB-9873-25B65C6250D6}.Release|Any CPU.Build.0 = Release|Any CPU + {FFB09376-595B-6F93-36F0-70CAE90AFECB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FFB09376-595B-6F93-36F0-70CAE90AFECB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FFB09376-595B-6F93-36F0-70CAE90AFECB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FFB09376-595B-6F93-36F0-70CAE90AFECB}.Release|Any CPU.Build.0 = Release|Any CPU + {E75E1F10-BC6F-4EB1-BA75-D897C45AEA0D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E75E1F10-BC6F-4EB1-BA75-D897C45AEA0D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E75E1F10-BC6F-4EB1-BA75-D897C45AEA0D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E75E1F10-BC6F-4EB1-BA75-D897C45AEA0D}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -410,7 +435,11 @@ Global {17A89266-260A-4A03-81AE-C0468C6EE06E} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E} {D1513D90-E4F5-44A9-9121-5E46E3E4A3F7} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E} {AD59537D-5259-4B7A-948F-0CF58E80B359} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F} + {9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E} + {A2E067EF-609C-4D13-895A-E054C61D48BB} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F} {7D98784C-C253-43FB-9873-25B65C6250D6} = {287CFF34-BBDB-4BC4-AF88-1E19A5A4679B} + {FFB09376-595B-6F93-36F0-70CAE90AFECB} = {287CFF34-BBDB-4BC4-AF88-1E19A5A4679B} + {E75E1F10-BC6F-4EB1-BA75-D897C45AEA0D} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {E01CBF68-2E20-425F-9EDB-E0A6510CA92F} diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs index 994b305349..12d370395c 100644 --- a/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs +++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs @@ -113,7 +113,7 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv await _providerBillingService.CreateCustomerForClientOrganization(provider, organization); } - var customer = await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, new CustomerUpdateOptions + var customer = await _stripeAdapter.UpdateCustomerAsync(organization.GatewayCustomerId, new CustomerUpdateOptions { Description = string.Empty, Email = organization.BillingEmail, @@ -138,7 +138,7 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }; - var subscription = await _stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions); + var subscription = await _stripeAdapter.CreateSubscriptionAsync(subscriptionCreateOptions); organization.GatewaySubscriptionId = subscription.Id; organization.Status = OrganizationStatusType.Created; @@ -148,27 +148,26 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv } else if (organization.IsStripeEnabled()) { - var subscription = await _stripeAdapter.SubscriptionGetAsync(organization.GatewaySubscriptionId, new SubscriptionGetOptions + var subscription = await _stripeAdapter.GetSubscriptionAsync(organization.GatewaySubscriptionId, new SubscriptionGetOptions { Expand = ["customer"] }); - if (subscription.Status is StripeConstants.SubscriptionStatus.Canceled or StripeConstants.SubscriptionStatus.IncompleteExpired) { return; } - await _stripeAdapter.CustomerUpdateAsync(subscription.CustomerId, new CustomerUpdateOptions + await _stripeAdapter.UpdateCustomerAsync(subscription.CustomerId, new CustomerUpdateOptions { Email = organization.BillingEmail }); if (subscription.Customer.Discount?.Coupon != null) { - await _stripeAdapter.CustomerDeleteDiscountAsync(subscription.CustomerId); + await _stripeAdapter.DeleteCustomerDiscountAsync(subscription.CustomerId); } - await _stripeAdapter.SubscriptionUpdateAsync(organization.GatewaySubscriptionId, new SubscriptionUpdateOptions + await _stripeAdapter.UpdateSubscriptionAsync(organization.GatewaySubscriptionId, new SubscriptionUpdateOptions { CollectionMethod = StripeConstants.CollectionMethod.SendInvoice, DaysUntilDue = 30, diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs index 89ef251fd6..4e8a23cf4e 100644 --- a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs +++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs @@ -9,12 +9,16 @@ 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; using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Providers.Services; +using Bit.Core.Billing.Services; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; @@ -59,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, @@ -68,7 +73,8 @@ public class ProviderService : IProviderService ICurrentContext currentContext, IStripeAdapter stripeAdapter, IFeatureService featureService, IDataProtectorTokenFactory providerDeleteTokenDataFactory, IApplicationCacheService applicationCacheService, IProviderBillingService providerBillingService, IPricingClient pricingClient, - IProviderClientOrganizationSignUpCommand providerClientOrganizationSignUpCommand) + IProviderClientOrganizationSignUpCommand providerClientOrganizationSignUpCommand, + IPolicyRequirementQuery policyRequirementQuery) { _providerRepository = providerRepository; _providerUserRepository = providerUserRepository; @@ -89,6 +95,7 @@ public class ProviderService : IProviderService _providerBillingService = providerBillingService; _pricingClient = pricingClient; _providerClientOrganizationSignUpCommand = providerClientOrganizationSignUpCommand; + _policyRequirementQuery = policyRequirementQuery; } public async Task CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TokenizedPaymentMethod paymentMethod, BillingAddress billingAddress) @@ -116,6 +123,18 @@ public class ProviderService : IProviderService throw new BadRequestException("Invalid owner."); } + if (_featureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)) + { + var organizationAutoConfirmPolicyRequirement = await _policyRequirementQuery + .GetAsync(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); @@ -248,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(user.Id); + + if (organizationAutoConfirmPolicyRequirement + .CannotJoinProvider()) + { + throw new BadRequestException(new UserCannotJoinProvider().Message); + } + } + providerUser.Status = ProviderUserStatusType.Accepted; providerUser.UserId = user.Id; providerUser.Email = null; @@ -293,6 +324,19 @@ public class ProviderService : IProviderService throw new BadRequestException("Invalid user."); } + if (_featureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)) + { + var organizationAutoConfirmPolicyRequirement = await _policyRequirementQuery + .GetAsync(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; @@ -427,7 +471,7 @@ public class ProviderService : IProviderService if (!string.IsNullOrEmpty(organization.GatewayCustomerId)) { - await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, new CustomerUpdateOptions + await _stripeAdapter.UpdateCustomerAsync(organization.GatewayCustomerId, new CustomerUpdateOptions { Email = provider.BillingEmail }); @@ -487,7 +531,7 @@ public class ProviderService : IProviderService private async Task GetSubscriptionItemAsync(string subscriptionId, string oldPlanId) { - var subscriptionDetails = await _stripeAdapter.SubscriptionGetAsync(subscriptionId); + var subscriptionDetails = await _stripeAdapter.GetSubscriptionAsync(subscriptionId); return subscriptionDetails.Items.Data.FirstOrDefault(item => item.Price.Id == oldPlanId); } @@ -497,7 +541,7 @@ public class ProviderService : IProviderService { if (subscriptionItem.Price.Id != extractedPlanType) { - await _stripeAdapter.SubscriptionUpdateAsync(subscriptionItem.Subscription, + await _stripeAdapter.UpdateSubscriptionAsync(subscriptionItem.Subscription, new Stripe.SubscriptionUpdateOptions { Items = new List diff --git a/bitwarden_license/src/Commercial.Core/Billing/Providers/Queries/GetProviderWarningsQuery.cs b/bitwarden_license/src/Commercial.Core/Billing/Providers/Queries/GetProviderWarningsQuery.cs index cc77797307..e140a13841 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/Providers/Queries/GetProviderWarningsQuery.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/Providers/Queries/GetProviderWarningsQuery.cs @@ -4,7 +4,6 @@ using Bit.Core.Billing.Providers.Models; using Bit.Core.Billing.Providers.Queries; using Bit.Core.Billing.Services; using Bit.Core.Context; -using Bit.Core.Services; using Stripe; using Stripe.Tax; @@ -76,8 +75,8 @@ public class GetProviderWarningsQuery( // Get active and scheduled registrations var registrations = (await Task.WhenAll( - stripeAdapter.TaxRegistrationsListAsync(new RegistrationListOptions { Status = TaxRegistrationStatus.Active }), - stripeAdapter.TaxRegistrationsListAsync(new RegistrationListOptions { Status = TaxRegistrationStatus.Scheduled }))) + stripeAdapter.ListTaxRegistrationsAsync(new RegistrationListOptions { Status = TaxRegistrationStatus.Active }), + stripeAdapter.ListTaxRegistrationsAsync(new RegistrationListOptions { Status = TaxRegistrationStatus.Scheduled }))) .SelectMany(registrations => registrations.Data); // Find the matching registration for the customer diff --git a/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/BusinessUnitConverter.cs b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/BusinessUnitConverter.cs index 8e8a89ae58..ce2f7a941f 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/BusinessUnitConverter.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/BusinessUnitConverter.cs @@ -101,7 +101,7 @@ public class BusinessUnitConverter( providerUser.Status = ProviderUserStatusType.Confirmed; // Stripe requires that we clear all the custom fields from the invoice settings if we want to replace them. - await stripeAdapter.CustomerUpdateAsync(subscription.CustomerId, new CustomerUpdateOptions + await stripeAdapter.UpdateCustomerAsync(subscription.CustomerId, new CustomerUpdateOptions { InvoiceSettings = new CustomerInvoiceSettingsOptions { @@ -116,7 +116,7 @@ public class BusinessUnitConverter( ["convertedFrom"] = organization.Id.ToString() }; - var updateCustomer = stripeAdapter.CustomerUpdateAsync(subscription.CustomerId, new CustomerUpdateOptions + var updateCustomer = stripeAdapter.UpdateCustomerAsync(subscription.CustomerId, new CustomerUpdateOptions { InvoiceSettings = new CustomerInvoiceSettingsOptions { @@ -148,7 +148,7 @@ public class BusinessUnitConverter( // Replace the existing password manager price with the new business unit price. var updateSubscription = - stripeAdapter.SubscriptionUpdateAsync(subscription.Id, + stripeAdapter.UpdateSubscriptionAsync(subscription.Id, new SubscriptionUpdateOptions { Items = [ diff --git a/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs index e352297f1e..7042a531d0 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs @@ -61,11 +61,11 @@ public class ProviderBillingService( Organization organization, string key) { - await stripeAdapter.SubscriptionUpdateAsync(organization.GatewaySubscriptionId, + await stripeAdapter.UpdateSubscriptionAsync(organization.GatewaySubscriptionId, new SubscriptionUpdateOptions { CancelAtPeriodEnd = false }); var subscription = - await stripeAdapter.SubscriptionCancelAsync(organization.GatewaySubscriptionId, + await stripeAdapter.CancelSubscriptionAsync(organization.GatewaySubscriptionId, new SubscriptionCancelOptions { CancellationDetails = new SubscriptionCancellationDetailsOptions @@ -83,7 +83,7 @@ public class ProviderBillingService( if (!wasTrialing && subscription.LatestInvoice.Status == InvoiceStatus.Draft) { - await stripeAdapter.InvoiceFinalizeInvoiceAsync(subscription.LatestInvoiceId, + await stripeAdapter.FinalizeInvoiceAsync(subscription.LatestInvoiceId, new InvoiceFinalizeOptions { AutoAdvance = true }); } @@ -138,7 +138,7 @@ public class ProviderBillingService( if (clientCustomer.Balance != 0) { - await stripeAdapter.CustomerBalanceTransactionCreate(provider.GatewayCustomerId, + await stripeAdapter.CreateCustomerBalanceTransactionAsync(provider.GatewayCustomerId, new CustomerBalanceTransactionCreateOptions { Amount = clientCustomer.Balance, @@ -187,7 +187,7 @@ public class ProviderBillingService( ] }; - await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId, updateOptions); + await stripeAdapter.UpdateSubscriptionAsync(provider.GatewaySubscriptionId, updateOptions); // Refactor later to ?ChangeClientPlanCommand? (ProviderPlanId, ProviderId, OrganizationId) // 1. Retrieve PlanType and PlanName for ProviderPlan @@ -275,7 +275,7 @@ public class ProviderBillingService( customerCreateOptions.TaxExempt = TaxExempt.Reverse; } - var customer = await stripeAdapter.CustomerCreateAsync(customerCreateOptions); + var customer = await stripeAdapter.CreateCustomerAsync(customerCreateOptions); organization.GatewayCustomerId = customer.Id; @@ -525,7 +525,7 @@ public class ProviderBillingService( case TokenizablePaymentMethodType.BankAccount: { var setupIntent = - (await stripeAdapter.SetupIntentList(new SetupIntentListOptions + (await stripeAdapter.ListSetupIntentsAsync(new SetupIntentListOptions { PaymentMethod = paymentMethod.Token })) @@ -558,7 +558,7 @@ public class ProviderBillingService( try { - return await stripeAdapter.CustomerCreateAsync(options); + return await stripeAdapter.CreateCustomerAsync(options); } catch (StripeException stripeException) when (stripeException.StripeError?.Code == ErrorCodes.TaxIdInvalid) { @@ -580,7 +580,7 @@ public class ProviderBillingService( case TokenizablePaymentMethodType.BankAccount: { var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(provider.Id); - await stripeAdapter.SetupIntentCancel(setupIntentId, + await stripeAdapter.CancelSetupIntentAsync(setupIntentId, new SetupIntentCancelOptions { CancellationReason = "abandoned" }); await setupIntentCache.RemoveSetupIntentForSubscriber(provider.Id); break; @@ -638,7 +638,7 @@ public class ProviderBillingService( var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(provider.Id); var setupIntent = !string.IsNullOrEmpty(setupIntentId) - ? await stripeAdapter.SetupIntentGet(setupIntentId, + ? await stripeAdapter.GetSetupIntentAsync(setupIntentId, new SetupIntentGetOptions { Expand = ["payment_method"] }) : null; @@ -673,7 +673,7 @@ public class ProviderBillingService( try { - var subscription = await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions); + var subscription = await stripeAdapter.CreateSubscriptionAsync(subscriptionCreateOptions); if (subscription is { @@ -708,7 +708,7 @@ public class ProviderBillingService( subscriberService.UpdatePaymentSource(provider, tokenizedPaymentSource), subscriberService.UpdateTaxInformation(provider, taxInformation)); - await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId, + await stripeAdapter.UpdateSubscriptionAsync(provider.GatewaySubscriptionId, new SubscriptionUpdateOptions { CollectionMethod = CollectionMethod.ChargeAutomatically }); } @@ -791,11 +791,49 @@ public class ProviderBillingService( if (subscriptionItemOptionsList.Count > 0) { - await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId, + await stripeAdapter.UpdateSubscriptionAsync(provider.GatewaySubscriptionId, new SubscriptionUpdateOptions { Items = subscriptionItemOptionsList }); } } + 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 CurrySeatScalingUpdate( Provider provider, ProviderPlan providerPlan, @@ -807,7 +845,7 @@ public class ProviderBillingService( var item = subscription.Items.First(item => item.Price.Id == priceId); - await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId, new SubscriptionUpdateOptions + await stripeAdapter.UpdateSubscriptionAsync(provider.GatewaySubscriptionId, new SubscriptionUpdateOptions { Items = [ diff --git a/bitwarden_license/src/Commercial.Infrastructure.EntityFramework/SecretsManager/Repositories/SecretVersionRepository.cs b/bitwarden_license/src/Commercial.Infrastructure.EntityFramework/SecretsManager/Repositories/SecretVersionRepository.cs new file mode 100644 index 0000000000..22421f9921 --- /dev/null +++ b/bitwarden_license/src/Commercial.Infrastructure.EntityFramework/SecretsManager/Repositories/SecretVersionRepository.cs @@ -0,0 +1,94 @@ +using AutoMapper; +using Bit.Core.SecretsManager.Repositories; +using Bit.Infrastructure.EntityFramework.Repositories; +using Bit.Infrastructure.EntityFramework.SecretsManager.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace Bit.Commercial.Infrastructure.EntityFramework.SecretsManager.Repositories; + +public class SecretVersionRepository : Repository, ISecretVersionRepository +{ + public SecretVersionRepository(IServiceScopeFactory serviceScopeFactory, IMapper mapper) + : base(serviceScopeFactory, mapper, db => db.SecretVersion) + { } + + public override async Task GetByIdAsync(Guid id) + { + using var scope = ServiceScopeFactory.CreateScope(); + var dbContext = GetDatabaseContext(scope); + var secretVersion = await dbContext.SecretVersion + .Where(sv => sv.Id == id) + .FirstOrDefaultAsync(); + return Mapper.Map(secretVersion); + } + + public async Task> GetManyBySecretIdAsync(Guid secretId) + { + using var scope = ServiceScopeFactory.CreateScope(); + var dbContext = GetDatabaseContext(scope); + var secretVersions = await dbContext.SecretVersion + .Where(sv => sv.SecretId == secretId) + .OrderByDescending(sv => sv.VersionDate) + .ToListAsync(); + return Mapper.Map>(secretVersions); + } + + public async Task> GetManyByIdsAsync(IEnumerable ids) + { + using var scope = ServiceScopeFactory.CreateScope(); + var dbContext = GetDatabaseContext(scope); + var versionIds = ids.ToList(); + var secretVersions = await dbContext.SecretVersion + .Where(sv => versionIds.Contains(sv.Id)) + .OrderByDescending(sv => sv.VersionDate) + .ToListAsync(); + return Mapper.Map>(secretVersions); + } + + public override async Task CreateAsync(Core.SecretsManager.Entities.SecretVersion secretVersion) + { + const int maxVersionsToKeep = 10; + + await using var scope = ServiceScopeFactory.CreateAsyncScope(); + var dbContext = GetDatabaseContext(scope); + + await using var transaction = await dbContext.Database.BeginTransactionAsync(); + + // Get the IDs of the most recent (maxVersionsToKeep - 1) versions to keep + var versionsToKeepIds = await dbContext.SecretVersion + .Where(sv => sv.SecretId == secretVersion.SecretId) + .OrderByDescending(sv => sv.VersionDate) + .Take(maxVersionsToKeep - 1) + .Select(sv => sv.Id) + .ToListAsync(); + + // Delete all versions for this secret that are not in the "keep" list + if (versionsToKeepIds.Any()) + { + await dbContext.SecretVersion + .Where(sv => sv.SecretId == secretVersion.SecretId && !versionsToKeepIds.Contains(sv.Id)) + .ExecuteDeleteAsync(); + } + + secretVersion.SetNewId(); + var entity = Mapper.Map(secretVersion); + + await dbContext.AddAsync(entity); + await dbContext.SaveChangesAsync(); + await transaction.CommitAsync(); + + return secretVersion; + } + + public async Task DeleteManyByIdAsync(IEnumerable ids) + { + await using var scope = ServiceScopeFactory.CreateAsyncScope(); + var dbContext = GetDatabaseContext(scope); + + var secretVersionIds = ids.ToList(); + await dbContext.SecretVersion + .Where(sv => secretVersionIds.Contains(sv.Id)) + .ExecuteDeleteAsync(); + } +} diff --git a/bitwarden_license/src/Commercial.Infrastructure.EntityFramework/SecretsManager/SecretsManagerEFServiceCollectionExtensions.cs b/bitwarden_license/src/Commercial.Infrastructure.EntityFramework/SecretsManager/SecretsManagerEFServiceCollectionExtensions.cs index d6c8848079..ac52c40ba6 100644 --- a/bitwarden_license/src/Commercial.Infrastructure.EntityFramework/SecretsManager/SecretsManagerEFServiceCollectionExtensions.cs +++ b/bitwarden_license/src/Commercial.Infrastructure.EntityFramework/SecretsManager/SecretsManagerEFServiceCollectionExtensions.cs @@ -10,6 +10,7 @@ public static class SecretsManagerEfServiceCollectionExtensions { services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); } diff --git a/bitwarden_license/src/Scim/Controllers/v2/GroupsController.cs b/bitwarden_license/src/Scim/Controllers/v2/GroupsController.cs index e3c290c85f..88d6858cb8 100644 --- a/bitwarden_license/src/Scim/Controllers/v2/GroupsController.cs +++ b/bitwarden_license/src/Scim/Controllers/v2/GroupsController.cs @@ -61,17 +61,15 @@ public class GroupsController : Controller [HttpGet("")] public async Task Get( Guid organizationId, - [FromQuery] string filter, - [FromQuery] int? count, - [FromQuery] int? startIndex) + [FromQuery] GetGroupsQueryParamModel model) { - var groupsListQueryResult = await _getGroupsListQuery.GetGroupsListAsync(organizationId, filter, count, startIndex); + var groupsListQueryResult = await _getGroupsListQuery.GetGroupsListAsync(organizationId, model); var scimListResponseModel = new ScimListResponseModel { Resources = groupsListQueryResult.groupList.Select(g => new ScimGroupResponseModel(g)).ToList(), - ItemsPerPage = count.GetValueOrDefault(groupsListQueryResult.groupList.Count()), + ItemsPerPage = model.Count, TotalResults = groupsListQueryResult.totalResults, - StartIndex = startIndex.GetValueOrDefault(1), + StartIndex = model.StartIndex, }; return Ok(scimListResponseModel); } diff --git a/bitwarden_license/src/Scim/Controllers/v2/UsersController.cs b/bitwarden_license/src/Scim/Controllers/v2/UsersController.cs index afbfa50bb4..91d79542b5 100644 --- a/bitwarden_license/src/Scim/Controllers/v2/UsersController.cs +++ b/bitwarden_license/src/Scim/Controllers/v2/UsersController.cs @@ -3,6 +3,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v1; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; diff --git a/bitwarden_license/src/Scim/Groups/GetGroupsListQuery.cs b/bitwarden_license/src/Scim/Groups/GetGroupsListQuery.cs index cc6546700b..f0a561a29f 100644 --- a/bitwarden_license/src/Scim/Groups/GetGroupsListQuery.cs +++ b/bitwarden_license/src/Scim/Groups/GetGroupsListQuery.cs @@ -4,6 +4,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Repositories; using Bit.Scim.Groups.Interfaces; +using Bit.Scim.Models; namespace Bit.Scim.Groups; @@ -16,10 +17,16 @@ public class GetGroupsListQuery : IGetGroupsListQuery _groupRepository = groupRepository; } - public async Task<(IEnumerable groupList, int totalResults)> GetGroupsListAsync(Guid organizationId, string filter, int? count, int? startIndex) + public async Task<(IEnumerable groupList, int totalResults)> GetGroupsListAsync( + Guid organizationId, GetGroupsQueryParamModel groupQueryParams) { string nameFilter = null; string externalIdFilter = null; + + int count = groupQueryParams.Count; + int startIndex = groupQueryParams.StartIndex; + string filter = groupQueryParams.Filter; + if (!string.IsNullOrWhiteSpace(filter)) { if (filter.StartsWith("displayName eq ")) @@ -53,11 +60,11 @@ public class GetGroupsListQuery : IGetGroupsListQuery } totalResults = groupList.Count; } - else if (string.IsNullOrWhiteSpace(filter) && startIndex.HasValue && count.HasValue) + else if (string.IsNullOrWhiteSpace(filter)) { groupList = groups.OrderBy(g => g.Name) - .Skip(startIndex.Value - 1) - .Take(count.Value) + .Skip(startIndex - 1) + .Take(count) .ToList(); totalResults = groups.Count; } diff --git a/bitwarden_license/src/Scim/Groups/Interfaces/IGetGroupsListQuery.cs b/bitwarden_license/src/Scim/Groups/Interfaces/IGetGroupsListQuery.cs index 07ff044701..4b4ba09e1d 100644 --- a/bitwarden_license/src/Scim/Groups/Interfaces/IGetGroupsListQuery.cs +++ b/bitwarden_license/src/Scim/Groups/Interfaces/IGetGroupsListQuery.cs @@ -1,8 +1,9 @@ using Bit.Core.AdminConsole.Entities; +using Bit.Scim.Models; namespace Bit.Scim.Groups.Interfaces; public interface IGetGroupsListQuery { - Task<(IEnumerable groupList, int totalResults)> GetGroupsListAsync(Guid organizationId, string filter, int? count, int? startIndex); + Task<(IEnumerable groupList, int totalResults)> GetGroupsListAsync(Guid organizationId, GetGroupsQueryParamModel model); } diff --git a/bitwarden_license/src/Scim/Models/GetGroupsQueryParamModel.cs b/bitwarden_license/src/Scim/Models/GetGroupsQueryParamModel.cs new file mode 100644 index 0000000000..5389727917 --- /dev/null +++ b/bitwarden_license/src/Scim/Models/GetGroupsQueryParamModel.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Scim.Models; + +public class GetGroupsQueryParamModel +{ + public string Filter { get; init; } = string.Empty; + + [Range(1, int.MaxValue)] + public int Count { get; init; } = 50; + + [Range(1, int.MaxValue)] + public int StartIndex { get; init; } = 1; +} diff --git a/bitwarden_license/src/Scim/Models/GetUserQueryParamModel.cs b/bitwarden_license/src/Scim/Models/GetUsersQueryParamModel.cs similarity index 91% rename from bitwarden_license/src/Scim/Models/GetUserQueryParamModel.cs rename to bitwarden_license/src/Scim/Models/GetUsersQueryParamModel.cs index 27d7b6d9a1..cd50dbca61 100644 --- a/bitwarden_license/src/Scim/Models/GetUserQueryParamModel.cs +++ b/bitwarden_license/src/Scim/Models/GetUsersQueryParamModel.cs @@ -1,5 +1,7 @@ using System.ComponentModel.DataAnnotations; +namespace Bit.Scim.Models; + public class GetUsersQueryParamModel { public string Filter { get; init; } = string.Empty; diff --git a/bitwarden_license/src/Scim/Program.cs b/bitwarden_license/src/Scim/Program.cs index 92f12f59dd..02f2e00d32 100644 --- a/bitwarden_license/src/Scim/Program.cs +++ b/bitwarden_license/src/Scim/Program.cs @@ -11,21 +11,8 @@ public class Program .ConfigureWebHostDefaults(webBuilder => { webBuilder.UseStartup(); - webBuilder.ConfigureLogging((hostingContext, logging) => - logging.AddSerilog(hostingContext, (e, globalSettings) => - { - var context = e.Properties["SourceContext"].ToString(); - - if (e.Properties.TryGetValue("RequestPath", out var requestPath) && - !string.IsNullOrWhiteSpace(requestPath?.ToString()) && - (context.Contains(".Server.Kestrel") || context.Contains(".Core.IISHttpServer"))) - { - return false; - } - - return e.Level >= globalSettings.MinLogLevel.ScimSettings.Default; - })); }) + .AddSerilogFileLogging() .Build() .Run(); } diff --git a/bitwarden_license/src/Scim/Startup.cs b/bitwarden_license/src/Scim/Startup.cs index edbbf34aea..a912562f72 100644 --- a/bitwarden_license/src/Scim/Startup.cs +++ b/bitwarden_license/src/Scim/Startup.cs @@ -44,6 +44,7 @@ public class Startup // Repositories services.AddDatabaseRepositories(globalSettings); + services.AddTestPlayIdTracking(globalSettings); // Context services.AddScoped(); @@ -94,11 +95,8 @@ public class Startup public void Configure( IApplicationBuilder app, IWebHostEnvironment env, - IHostApplicationLifetime appLifetime, GlobalSettings globalSettings) { - app.UseSerilog(env, appLifetime, globalSettings); - // Add general security headers app.UseMiddleware(); diff --git a/bitwarden_license/src/Scim/Users/GetUsersListQuery.cs b/bitwarden_license/src/Scim/Users/GetUsersListQuery.cs index a734635ebf..c7085eb6b9 100644 --- a/bitwarden_license/src/Scim/Users/GetUsersListQuery.cs +++ b/bitwarden_license/src/Scim/Users/GetUsersListQuery.cs @@ -3,6 +3,7 @@ using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Repositories; +using Bit.Scim.Models; using Bit.Scim.Users.Interfaces; namespace Bit.Scim.Users; diff --git a/bitwarden_license/src/Scim/Users/Interfaces/IGetUsersListQuery.cs b/bitwarden_license/src/Scim/Users/Interfaces/IGetUsersListQuery.cs index f584cb8e7b..04133c89eb 100644 --- a/bitwarden_license/src/Scim/Users/Interfaces/IGetUsersListQuery.cs +++ b/bitwarden_license/src/Scim/Users/Interfaces/IGetUsersListQuery.cs @@ -1,4 +1,5 @@ using Bit.Core.Models.Data.Organizations.OrganizationUsers; +using Bit.Scim.Models; namespace Bit.Scim.Users.Interfaces; diff --git a/bitwarden_license/src/Scim/Users/PatchUserCommand.cs b/bitwarden_license/src/Scim/Users/PatchUserCommand.cs index 6c983611ee..474557a9cb 100644 --- a/bitwarden_license/src/Scim/Users/PatchUserCommand.cs +++ b/bitwarden_license/src/Scim/Users/PatchUserCommand.cs @@ -1,5 +1,5 @@ -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v1; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; diff --git a/bitwarden_license/src/Scim/Users/PostUserCommand.cs b/bitwarden_license/src/Scim/Users/PostUserCommand.cs index 5b4a0c29cd..696d600348 100644 --- a/bitwarden_license/src/Scim/Users/PostUserCommand.cs +++ b/bitwarden_license/src/Scim/Users/PostUserCommand.cs @@ -8,6 +8,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.E using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; using Bit.Core.AdminConsole.Utilities.Commands; using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Services; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Data.Organizations.OrganizationUsers; @@ -24,7 +25,7 @@ public class PostUserCommand( IOrganizationRepository organizationRepository, IOrganizationUserRepository organizationUserRepository, IOrganizationService organizationService, - IPaymentService paymentService, + IStripePaymentService paymentService, IScimContext scimContext, IFeatureService featureService, IInviteOrganizationUsersCommand inviteOrganizationUsersCommand, diff --git a/bitwarden_license/src/Scim/appsettings.Production.json b/bitwarden_license/src/Scim/appsettings.Production.json index d9efbcda12..a6578c08dc 100644 --- a/bitwarden_license/src/Scim/appsettings.Production.json +++ b/bitwarden_license/src/Scim/appsettings.Production.json @@ -23,11 +23,9 @@ } }, "Logging": { - "IncludeScopes": false, "LogLevel": { - "Default": "Debug", - "System": "Information", - "Microsoft": "Information" + "Default": "Information", + "Microsoft.AspNetCore": "Warning" }, "Console": { "IncludeScopes": true, diff --git a/bitwarden_license/src/Scim/appsettings.json b/bitwarden_license/src/Scim/appsettings.json index dcdfeb3ede..18b7a7ca7b 100644 --- a/bitwarden_license/src/Scim/appsettings.json +++ b/bitwarden_license/src/Scim/appsettings.json @@ -30,9 +30,6 @@ "connectionString": "SECRET", "applicationCacheTopicName": "SECRET" }, - "sentry": { - "dsn": "SECRET" - }, "notificationHub": { "connectionString": "SECRET", "hubName": "SECRET" diff --git a/bitwarden_license/src/Sso/Controllers/AccountController.cs b/bitwarden_license/src/Sso/Controllers/AccountController.cs index bc26fb270a..dde2ac7a46 100644 --- a/bitwarden_license/src/Sso/Controllers/AccountController.cs +++ b/bitwarden_license/src/Sso/Controllers/AccountController.cs @@ -201,12 +201,15 @@ public class AccountController : Controller returnUrl, state = context.Parameters["state"], userIdentifier = context.Parameters["session_state"], + ssoToken }); } [HttpGet] - public IActionResult ExternalChallenge(string scheme, string returnUrl, string state, string userIdentifier) + public IActionResult ExternalChallenge(string scheme, string returnUrl, string state, string userIdentifier, string ssoToken) { + ValidateSchemeAgainstSsoToken(scheme, ssoToken); + if (string.IsNullOrEmpty(returnUrl)) { returnUrl = "~/"; @@ -235,6 +238,31 @@ public class AccountController : Controller return Challenge(props, scheme); } + /// + /// Validates the scheme (organization ID) against the organization ID found in the ssoToken. + /// + /// The authentication scheme (organization ID) to validate. + /// The SSO token to validate against. + /// Thrown if the scheme (organization ID) does not match the organization ID found in the ssoToken. + private void ValidateSchemeAgainstSsoToken(string scheme, string ssoToken) + { + SsoTokenable tokenable; + + try + { + tokenable = _dataProtector.Unprotect(ssoToken); + } + catch + { + throw new Exception(_i18nService.T("InvalidSsoToken")); + } + + if (!Guid.TryParse(scheme, out var schemeOrgId) || tokenable.OrganizationId != schemeOrgId) + { + throw new Exception(_i18nService.T("SsoOrganizationIdMismatch")); + } + } + [HttpGet] public async Task ExternalCallback() { @@ -434,6 +462,7 @@ public class AccountController : Controller // FIXME: Update this file to be null safe and then delete the line below #nullable disable var provider = result.Properties.Items["scheme"]; + //Todo: Validate provider is a valid GUID with TryParse instead. When this is invalid it throws an exception var orgId = new Guid(provider); var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(orgId); if (ssoConfig == null || !ssoConfig.Enabled) @@ -587,7 +616,7 @@ public class AccountController : Controller // Since we're in the auto-provisioning logic, this means that the user exists, but they have not // authenticated with the org's SSO provider before now (otherwise we wouldn't be auto-provisioning them). - // We've verified that the user is Accepted or Confnirmed, so we can create an SsoUser link and proceed + // We've verified that the user is Accepted or Confirmed, so we can create an SsoUser link and proceed // with authentication. await CreateSsoUserRecordAsync(providerUserId, guaranteedExistingUser.Id, organization.Id, guaranteedOrgUser); @@ -652,22 +681,10 @@ public class AccountController : Controller ApiKey = CoreHelpers.SecureRandomString(30) }; - /* - The feature flag is checked here so that we can send the new MJML welcome email templates. - The other organization invites flows have an OrganizationUser allowing the RegisterUserCommand the ability - to fetch the Organization. The old method RegisterUser(User) here does not have that context, so we need - to use a new method RegisterSSOAutoProvisionedUserAsync(User, Organization) to send the correct email. - [PM-28057]: Prefer RegisterSSOAutoProvisionedUserAsync for SSO auto-provisioned users. - TODO: Remove Feature flag: PM-28221 - */ - if (_featureService.IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates)) - { - await _registerUserCommand.RegisterSSOAutoProvisionedUserAsync(newUser, organization); - } - else - { - await _registerUserCommand.RegisterUser(newUser); - } + // Always use RegisterSSOAutoProvisionedUserAsync to ensure organization context is available + // for domain validation (BlockClaimedDomainAccountCreation policy) and welcome emails. + // The feature flag logic for welcome email templates is handled internally by RegisterUserCommand. + await _registerUserCommand.RegisterSSOAutoProvisionedUserAsync(newUser, organization); // If the organization has 2fa policy enabled, make sure to default jit user 2fa to email var twoFactorPolicy = diff --git a/bitwarden_license/src/Sso/IdentityServer/DistributedCachePersistedGrantStore.cs b/bitwarden_license/src/Sso/IdentityServer/DistributedCachePersistedGrantStore.cs new file mode 100644 index 0000000000..ecb2f36cec --- /dev/null +++ b/bitwarden_license/src/Sso/IdentityServer/DistributedCachePersistedGrantStore.cs @@ -0,0 +1,102 @@ +using Bit.Sso.Utilities; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Stores; +using ZiggyCreatures.Caching.Fusion; + +namespace Bit.Sso.IdentityServer; + +/// +/// Distributed cache-backed persisted grant store for short-lived grants. +/// Uses IFusionCache (which wraps IDistributedCache) for horizontal scaling support, +/// and fall back to in-memory caching if Redis is not configured. +/// Designed for SSO authorization codes which are short-lived (5 minutes) and single-use. +/// +/// +/// This is purposefully a different implementation from how Identity solves Persisted Grants. +/// Because even flavored grant store, e.g., AuthorizationCodeGrantStore, can add intermediary +/// logic to a grant's handling by type, the fact that they all wrap IdentityServer's IPersistedGrantStore +/// leans on IdentityServer's opinion that all grants, regardless of type, go to the same persistence +/// mechanism (cache, database). +/// +/// +public class DistributedCachePersistedGrantStore : IPersistedGrantStore +{ + private readonly IFusionCache _cache; + + public DistributedCachePersistedGrantStore( + [FromKeyedServices(PersistedGrantsDistributedCacheConstants.CacheKey)] IFusionCache cache) + { + _cache = cache; + } + + public async Task GetAsync(string key) + { + var result = await _cache.TryGetAsync(key); + + if (!result.HasValue) + { + return null; + } + + var grant = result.Value; + + // Check if grant has expired - remove expired grants from cache + if (grant.Expiration.HasValue && grant.Expiration.Value < DateTime.UtcNow) + { + await RemoveAsync(key); + return null; + } + + return grant; + } + + public Task> GetAllAsync(PersistedGrantFilter filter) + { + // Cache stores are key-value based and don't support querying by filter criteria. + // This method is typically used for cleanup operations on long-lived grants in databases. + // For SSO's short-lived authorization codes, we rely on TTL expiration instead. + + return Task.FromResult(Enumerable.Empty()); + } + + public Task RemoveAllAsync(PersistedGrantFilter filter) + { + // Revocation Strategy: SSO's logout flow (AccountController.LogoutAsync) only clears local + // authentication cookies and performs federated logout with external IdPs. It does not invoke + // Duende's EndSession or TokenRevocation endpoints. Authorization codes are single-use and expire + // within 5 minutes, making explicit revocation unnecessary for SSO's security model. + // https://docs.duendesoftware.com/identityserver/reference/stores/persisted-grant-store/ + + // Cache stores are key-value based and don't support bulk deletion by filter. + // This method is typically used for cleanup operations on long-lived grants in databases. + // For SSO's short-lived authorization codes, we rely on TTL expiration instead. + + return Task.FromResult(0); + } + + public async Task RemoveAsync(string key) + { + await _cache.RemoveAsync(key); + } + + public async Task StoreAsync(PersistedGrant grant) + { + // Calculate TTL based on grant expiration + var duration = grant.Expiration.HasValue + ? grant.Expiration.Value - DateTime.UtcNow + : TimeSpan.FromMinutes(5); // Default to 5 minutes if no expiration set + + // Ensure positive duration + if (duration <= TimeSpan.Zero) + { + return; + } + + // Cache key "sso-grants:" is configured by service registration. Going through the consumed KeyedService will + // give us a consistent cache key prefix for these grants. + await _cache.SetAsync( + grant.Key, + grant, + new FusionCacheEntryOptions { Duration = duration }); + } +} diff --git a/bitwarden_license/src/Sso/Program.cs b/bitwarden_license/src/Sso/Program.cs index 1a8ce6eb88..bac3bb3d13 100644 --- a/bitwarden_license/src/Sso/Program.cs +++ b/bitwarden_license/src/Sso/Program.cs @@ -1,5 +1,4 @@ using Bit.Core.Utilities; -using Serilog; namespace Bit.Sso; @@ -13,19 +12,8 @@ public class Program .ConfigureWebHostDefaults(webBuilder => { webBuilder.UseStartup(); - webBuilder.ConfigureLogging((hostingContext, logging) => - logging.AddSerilog(hostingContext, (e, globalSettings) => - { - var context = e.Properties["SourceContext"].ToString(); - if (e.Properties.TryGetValue("RequestPath", out var requestPath) && - !string.IsNullOrWhiteSpace(requestPath?.ToString()) && - (context.Contains(".Server.Kestrel") || context.Contains(".Core.IISHttpServer"))) - { - return false; - } - return e.Level >= globalSettings.MinLogLevel.SsoSettings.Default; - })); }) + .AddSerilogFileLogging() .Build() .Run(); } diff --git a/bitwarden_license/src/Sso/Startup.cs b/bitwarden_license/src/Sso/Startup.cs index 3ae8883ac4..a2f363d533 100644 --- a/bitwarden_license/src/Sso/Startup.cs +++ b/bitwarden_license/src/Sso/Startup.cs @@ -41,6 +41,7 @@ public class Startup // Repositories services.AddDatabaseRepositories(globalSettings); + services.AddTestPlayIdTracking(globalSettings); // Context services.AddScoped(); @@ -100,8 +101,6 @@ public class Startup IdentityModelEventSource.ShowPII = true; } - app.UseSerilog(env, appLifetime, globalSettings); - // Add general security headers app.UseMiddleware(); diff --git a/bitwarden_license/src/Sso/Utilities/PersistedGrantsDistributedCacheConstants.cs b/bitwarden_license/src/Sso/Utilities/PersistedGrantsDistributedCacheConstants.cs new file mode 100644 index 0000000000..3ec45377e3 --- /dev/null +++ b/bitwarden_license/src/Sso/Utilities/PersistedGrantsDistributedCacheConstants.cs @@ -0,0 +1,10 @@ +namespace Bit.Sso.Utilities; + +public static class PersistedGrantsDistributedCacheConstants +{ + /// + /// The SSO Persisted Grant cache key. Identifies the keyed service consumed by the SSO Persisted Grant Store as + /// well as the cache key/namespace for grant storage. + /// + public const string CacheKey = "sso-grants"; +} diff --git a/bitwarden_license/src/Sso/Utilities/ServiceCollectionExtensions.cs b/bitwarden_license/src/Sso/Utilities/ServiceCollectionExtensions.cs index a51a04f5c8..da7a79535e 100644 --- a/bitwarden_license/src/Sso/Utilities/ServiceCollectionExtensions.cs +++ b/bitwarden_license/src/Sso/Utilities/ServiceCollectionExtensions.cs @@ -9,6 +9,7 @@ using Bit.Sso.IdentityServer; using Bit.Sso.Models; using Duende.IdentityServer.Models; using Duende.IdentityServer.ResponseHandling; +using Duende.IdentityServer.Stores; using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Sustainsys.Saml2.AspNetCore2; @@ -77,6 +78,17 @@ public static class ServiceCollectionExtensions }) .AddIdentityServerCertificate(env, globalSettings); + // PM-23572 + // Register named FusionCache for SSO authorization code grants. + // Provides separation of concerns and automatic Redis/in-memory negotiation + // .AddInMemoryCaching should still persist above; this handles configuration caching, etc., + // and is separate from this keyed service, which only serves grant negotiation. + services.AddExtendedCache(PersistedGrantsDistributedCacheConstants.CacheKey, globalSettings); + + // Store authorization codes in distributed cache for horizontal scaling + // Uses named FusionCache which gracefully degrades to in-memory when Redis isn't configured + services.AddSingleton(); + return identityServerBuilder; } } diff --git a/bitwarden_license/src/Sso/appsettings.Development.json b/bitwarden_license/src/Sso/appsettings.Development.json index 6d9ec77815..8e24d82528 100644 --- a/bitwarden_license/src/Sso/appsettings.Development.json +++ b/bitwarden_license/src/Sso/appsettings.Development.json @@ -25,6 +25,12 @@ "connectionString": "UseDevelopmentStorage=true" }, "developmentDirectory": "../../../dev", - "pricingUri": "https://billingpricing.qa.bitwarden.pw" + "pricingUri": "https://billingpricing.qa.bitwarden.pw", + "mail": { + "smtp": { + "host": "localhost", + "port": 10250 + } + } } } diff --git a/bitwarden_license/src/Sso/appsettings.json b/bitwarden_license/src/Sso/appsettings.json index 73c85044cc..9a5df42f7f 100644 --- a/bitwarden_license/src/Sso/appsettings.json +++ b/bitwarden_license/src/Sso/appsettings.json @@ -13,7 +13,11 @@ "mail": { "sendGridApiKey": "SECRET", "amazonConfigSetName": "Email", - "replyToEmail": "no-reply@bitwarden.com" + "replyToEmail": "no-reply@bitwarden.com", + "smtp": { + "host": "localhost", + "port": 10250 + } }, "identityServer": { "certificateThumbprint": "SECRET" diff --git a/bitwarden_license/src/Sso/package-lock.json b/bitwarden_license/src/Sso/package-lock.json index f5e0468f87..efeee7f4ca 100644 --- a/bitwarden_license/src/Sso/package-lock.json +++ b/bitwarden_license/src/Sso/package-lock.json @@ -17,9 +17,9 @@ "css-loader": "7.1.2", "expose-loader": "5.0.1", "mini-css-extract-plugin": "2.9.2", - "sass": "1.93.2", + "sass": "1.97.2", "sass-loader": "16.0.5", - "webpack": "5.102.1", + "webpack": "5.104.1", "webpack-cli": "5.1.4" } }, @@ -749,9 +749,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.8.18", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.18.tgz", - "integrity": "sha512-UYmTpOBwgPScZpS4A+YbapwWuBwasxvO/2IOHArSsAhL/+ZdmATBXTex3t+l2hXwLVYK382ibr/nKoY9GKe86w==", + "version": "2.9.13", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.13.tgz", + "integrity": "sha512-WhtvB2NG2wjr04+h77sg3klAIwrgOqnjS49GGudnUPGFFgg7G17y7Qecqp+2Dr5kUDxNRBca0SK7cG8JwzkWDQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -792,9 +792,9 @@ } }, "node_modules/browserslist": { - "version": "4.26.3", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz", - "integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==", + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "dev": true, "funding": [ { @@ -813,11 +813,11 @@ "license": "MIT", "peer": true, "dependencies": { - "baseline-browser-mapping": "^2.8.9", - "caniuse-lite": "^1.0.30001746", - "electron-to-chromium": "^1.5.227", - "node-releases": "^2.0.21", - "update-browserslist-db": "^1.1.3" + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" @@ -834,9 +834,9 @@ "license": "MIT" }, "node_modules/caniuse-lite": { - "version": "1.0.30001751", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz", - "integrity": "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==", + "version": "1.0.30001763", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001763.tgz", + "integrity": "sha512-mh/dGtq56uN98LlNX9qdbKnzINhX0QzhiWBFEkFfsFO4QyCvL8YegrJAazCwXIeqkIob8BlZPGM3xdnY+sgmvQ==", "dev": true, "funding": [ { @@ -988,9 +988,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.237", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.237.tgz", - "integrity": "sha512-icUt1NvfhGLar5lSWH3tHNzablaA5js3HVHacQimfP8ViEBOQv+L7DKEuHdbTZ0SKCO1ogTJTIL1Gwk9S6Qvcg==", + "version": "1.5.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", "dev": true, "license": "ISC" }, @@ -1022,9 +1022,9 @@ } }, "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", "dev": true, "license": "MIT" }, @@ -1418,13 +1418,17 @@ } }, "node_modules/loader-runner": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", - "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz", + "integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==", "dev": true, "license": "MIT", "engines": { "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, "node_modules/locate-path": { @@ -1541,9 +1545,9 @@ "optional": true }, "node_modules/node-releases": { - "version": "2.0.26", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.26.tgz", - "integrity": "sha512-S2M9YimhSjBSvYnlr5/+umAnPHE++ODwt5e2Ij6FoX45HA/s4vHdkDx1eax2pAPeAOqu4s9b7ppahsyEFdVqQA==", + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", "dev": true, "license": "MIT" }, @@ -1874,9 +1878,9 @@ "license": "MIT" }, "node_modules/sass": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.93.2.tgz", - "integrity": "sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==", + "version": "1.97.2", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.97.2.tgz", + "integrity": "sha512-y5LWb0IlbO4e97Zr7c3mlpabcbBtS+ieiZ9iwDooShpFKWXf62zz5pEPdwrLYm+Bxn1fnbwFGzHuCLSA9tBmrw==", "dev": true, "license": "MIT", "peer": true, @@ -2109,9 +2113,9 @@ } }, "node_modules/terser-webpack-plugin": { - "version": "5.3.14", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz", - "integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==", + "version": "5.3.16", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz", + "integrity": "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==", "dev": true, "license": "MIT", "dependencies": { @@ -2165,9 +2169,9 @@ "license": "MIT" }, "node_modules/update-browserslist-db": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", - "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "dev": true, "funding": [ { @@ -2217,9 +2221,9 @@ } }, "node_modules/webpack": { - "version": "5.102.1", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.102.1.tgz", - "integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==", + "version": "5.104.1", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.104.1.tgz", + "integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==", "dev": true, "license": "MIT", "peer": true, @@ -2232,21 +2236,21 @@ "@webassemblyjs/wasm-parser": "^1.14.1", "acorn": "^8.15.0", "acorn-import-phases": "^1.0.3", - "browserslist": "^4.26.3", + "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.17.3", - "es-module-lexer": "^1.2.1", + "enhanced-resolve": "^5.17.4", + "es-module-lexer": "^2.0.0", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", "json-parse-even-better-errors": "^2.3.1", - "loader-runner": "^4.2.0", + "loader-runner": "^4.3.1", "mime-types": "^2.1.27", "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", - "terser-webpack-plugin": "^5.3.11", + "terser-webpack-plugin": "^5.3.16", "watchpack": "^2.4.4", "webpack-sources": "^3.3.3" }, diff --git a/bitwarden_license/src/Sso/package.json b/bitwarden_license/src/Sso/package.json index df46444aca..b0a1849421 100644 --- a/bitwarden_license/src/Sso/package.json +++ b/bitwarden_license/src/Sso/package.json @@ -16,9 +16,9 @@ "css-loader": "7.1.2", "expose-loader": "5.0.1", "mini-css-extract-plugin": "2.9.2", - "sass": "1.93.2", + "sass": "1.97.2", "sass-loader": "16.0.5", - "webpack": "5.102.1", + "webpack": "5.104.1", "webpack-cli": "5.1.4" } } diff --git a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs index b367b17c73..810429d658 100644 --- a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs @@ -131,7 +131,7 @@ public class RemoveOrganizationFromProviderCommandTests Arg.Is>(emails => emails.FirstOrDefault() == "a@example.com")); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() - .CustomerUpdateAsync(Arg.Any(), Arg.Any()); + .UpdateCustomerAsync(Arg.Any(), Arg.Any()); } [Theory, BitAutoData] @@ -156,7 +156,7 @@ public class RemoveOrganizationFromProviderCommandTests "b@example.com" ]); - sutProvider.GetDependency().SubscriptionGetAsync(organization.GatewaySubscriptionId, Arg.Is( + sutProvider.GetDependency().GetSubscriptionAsync(organization.GatewaySubscriptionId, Arg.Is( options => options.Expand.Contains("customer"))) .Returns(GetSubscription(organization.GatewaySubscriptionId, organization.GatewayCustomerId)); @@ -164,12 +164,14 @@ public class RemoveOrganizationFromProviderCommandTests var stripeAdapter = sutProvider.GetDependency(); - await stripeAdapter.Received(1).CustomerUpdateAsync(organization.GatewayCustomerId, + await stripeAdapter.Received(1).UpdateCustomerAsync(organization.GatewayCustomerId, Arg.Is(options => options.Email == "a@example.com")); - await stripeAdapter.Received(1).CustomerDeleteDiscountAsync(organization.GatewayCustomerId); + await stripeAdapter.Received(1).DeleteCustomerDiscountAsync(organization.GatewayCustomerId); - await stripeAdapter.Received(1).SubscriptionUpdateAsync(organization.GatewaySubscriptionId, + await stripeAdapter.Received(1).DeleteCustomerDiscountAsync(organization.GatewayCustomerId); + + await stripeAdapter.Received(1).UpdateSubscriptionAsync(organization.GatewaySubscriptionId, Arg.Is(options => options.CollectionMethod == StripeConstants.CollectionMethod.SendInvoice && options.DaysUntilDue == 30)); @@ -226,7 +228,7 @@ public class RemoveOrganizationFromProviderCommandTests var stripeAdapter = sutProvider.GetDependency(); - stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, Arg.Is(options => + stripeAdapter.UpdateCustomerAsync(organization.GatewayCustomerId, Arg.Is(options => options.Description == string.Empty && options.Email == organization.BillingEmail && options.Expand[0] == "tax" && @@ -239,14 +241,14 @@ public class RemoveOrganizationFromProviderCommandTests } }); - stripeAdapter.SubscriptionCreateAsync(Arg.Any()).Returns(new Subscription + stripeAdapter.CreateSubscriptionAsync(Arg.Any()).Returns(new Subscription { Id = "subscription_id" }); await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization); - await stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Is(options => + await stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Is(options => options.Customer == organization.GatewayCustomerId && options.CollectionMethod == StripeConstants.CollectionMethod.SendInvoice && options.DaysUntilDue == 30 && @@ -315,7 +317,7 @@ public class RemoveOrganizationFromProviderCommandTests var stripeAdapter = sutProvider.GetDependency(); - stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, Arg.Is(options => + stripeAdapter.UpdateCustomerAsync(organization.GatewayCustomerId, Arg.Is(options => options.Description == string.Empty && options.Email == organization.BillingEmail && options.Expand[0] == "tax" && @@ -328,14 +330,14 @@ public class RemoveOrganizationFromProviderCommandTests } }); - stripeAdapter.SubscriptionCreateAsync(Arg.Any()).Returns(new Subscription + stripeAdapter.CreateSubscriptionAsync(Arg.Any()).Returns(new Subscription { Id = "subscription_id" }); await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization); - await stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Is(options => + await stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Is(options => options.Customer == organization.GatewayCustomerId && options.CollectionMethod == StripeConstants.CollectionMethod.SendInvoice && options.DaysUntilDue == 30 && @@ -434,7 +436,7 @@ public class RemoveOrganizationFromProviderCommandTests var stripeAdapter = sutProvider.GetDependency(); - stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, Arg.Any()) + stripeAdapter.UpdateCustomerAsync(organization.GatewayCustomerId, Arg.Any()) .Returns(new Customer { Id = "customer_id", @@ -444,7 +446,7 @@ public class RemoveOrganizationFromProviderCommandTests } }); - stripeAdapter.SubscriptionCreateAsync(Arg.Any()).Returns(new Subscription + stripeAdapter.CreateSubscriptionAsync(Arg.Any()).Returns(new Subscription { Id = "new_subscription_id" }); diff --git a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs index 78376f6d98..7ec11894ad 100644 --- a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs @@ -1,17 +1,23 @@ 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; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Providers.Services; +using Bit.Core.Billing.Services; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; @@ -100,6 +106,57 @@ public class ProviderServiceTests .ReplaceAsync(Arg.Is(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 sutProvider) + { + providerUser.ProviderId = provider.Id; + providerUser.UserId = user.Id; + var userService = sutProvider.GetDependency(); + userService.GetUserByIdAsync(user.Id).Returns(user); + + var providerUserRepository = sutProvider.GetDependency(); + providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id).Returns(providerUser); + + var dataProtectionProvider = DataProtectionProvider.Create("ApplicationName"); + var protector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector"); + sutProvider.GetDependency().CreateProtector("ProviderServiceDataProtector") + .Returns(protector); + + var providerBillingService = sutProvider.GetDependency(); + + 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() + .IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers) + .Returns(true); + + var policyDetails = new List { new() { OrganizationId = Guid.NewGuid(), IsProvider = false } }; + var policyRequirement = new AutomaticUserConfirmationPolicyRequirement(policyDetails); + sutProvider.GetDependency() + .GetAsync(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(() => + 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 sutProvider) { @@ -579,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 sutProvider) + { + // Arrange + sutProvider.GetDependency() + .GetByIdAsync(providerUser.Id) + .Returns(providerUser); + + var protector = DataProtectionProvider + .Create("ApplicationName") + .CreateProtector("ProviderServiceDataProtector"); + + sutProvider.GetDependency() + .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() + .IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers) + .Returns(true); + + var policyDetails = new List + { + new() { OrganizationId = Guid.NewGuid(), IsProvider = false } + }; + var policyRequirement = new AutomaticUserConfirmationPolicyRequirement(policyDetails); + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(policyRequirement); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => 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 sutProvider) + { + // Arrange + sutProvider.GetDependency() + .GetByIdAsync(providerUser.Id) + .Returns(providerUser); + + var protector = DataProtectionProvider + .Create("ApplicationName") + .CreateProtector("ProviderServiceDataProtector"); + + sutProvider.GetDependency() + .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() + .IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers) + .Returns(true); + + var policyRequirement = new AutomaticUserConfirmationPolicyRequirement([]); + sutProvider.GetDependency() + .GetAsync(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 sutProvider) + { + // Arrange + sutProvider.GetDependency() + .GetByIdAsync(providerUser.Id) + .Returns(providerUser); + + var protector = DataProtectionProvider + .Create("ApplicationName") + .CreateProtector("ProviderServiceDataProtector"); + + sutProvider.GetDependency() + .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() + .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() + .DidNotReceive() + .GetAsync(user.Id); + } + [Theory, BitAutoData] public async Task ConfirmUsersAsync_NoValid( [ProviderUser(ProviderUserStatusType.Invited)] ProviderUser pu1, @@ -625,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 sutProvider) + { + // Arrange + pu1.ProviderId = provider.Id; + pu1.UserId = u1.Id; + var providerUsers = new[] { pu1 }; + + var providerUserRepository = sutProvider.GetDependency(); + providerUserRepository.GetManyAsync([]).ReturnsForAnyArgs(providerUsers); + sutProvider.GetDependency().GetByIdAsync(provider.Id).Returns(provider); + sutProvider.GetDependency().GetManyAsync([]).ReturnsForAnyArgs([u1]); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers) + .Returns(true); + + var policyDetails = new List + { + new() { OrganizationId = Guid.NewGuid(), IsProvider = false } + }; + var policyRequirement = new AutomaticUserConfirmationPolicyRequirement(policyDetails); + sutProvider.GetDependency() + .GetAsync(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()); + } + + [Theory, BitAutoData] + public async Task ConfirmUsersAsync_WithAutoConfirmEnabledButNoPolicyExists_Success( + [ProviderUser(ProviderUserStatusType.Accepted)] ProviderUser pu1, User u1, + Provider provider, User confirmingUser, SutProvider sutProvider) + { + // Arrange + pu1.ProviderId = provider.Id; + pu1.UserId = u1.Id; + var providerUsers = new[] { pu1 }; + + var providerUserRepository = sutProvider.GetDependency(); + providerUserRepository.GetManyAsync([]).ReturnsForAnyArgs(providerUsers); + sutProvider.GetDependency().GetByIdAsync(provider.Id).Returns(provider); + sutProvider.GetDependency().GetManyAsync([]).ReturnsForAnyArgs([u1]); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers) + .Returns(true); + + var policyRequirement = new AutomaticUserConfirmationPolicyRequirement(new List()); + sutProvider.GetDependency() + .GetAsync(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(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 sutProvider) + { + // Arrange + pu1.ProviderId = provider.Id; + pu1.UserId = u1.Id; + var providerUsers = new[] { pu1 }; + + var providerUserRepository = sutProvider.GetDependency(); + providerUserRepository.GetManyAsync([]).ReturnsForAnyArgs(providerUsers); + + sutProvider.GetDependency().GetByIdAsync(provider.Id).Returns(provider); + sutProvider.GetDependency().GetManyAsync([]).ReturnsForAnyArgs([u1]); + + sutProvider.GetDependency() + .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(pu => + pu.Status == ProviderUserStatusType.Confirmed)); + + // Verify that policy check was never called when feature flag is disabled + await sutProvider.GetDependency() + .DidNotReceive() + .GetAsync(Arg.Any()); + } + [Theory, BitAutoData] public async Task SaveUserAsync_UserIdIsInvalid_Throws(ProviderUser providerUser, SutProvider sutProvider) { - providerUser.Id = default; - var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.SaveUserAsync(providerUser, default)); + providerUser.Id = Guid.Empty; + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.SaveUserAsync(providerUser, Guid.Empty)); Assert.Equal("Invite the user first.", exception.Message); } @@ -757,7 +1058,7 @@ public class ProviderServiceTests await organizationRepository.Received(1) .ReplaceAsync(Arg.Is(org => org.BillingEmail == provider.BillingEmail)); - await sutProvider.GetDependency().Received(1).CustomerUpdateAsync( + await sutProvider.GetDependency().Received(1).UpdateCustomerAsync( organization.GatewayCustomerId, Arg.Is(options => options.Email == provider.BillingEmail)); @@ -828,9 +1129,9 @@ public class ProviderServiceTests sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); var subscriptionItem = GetSubscription(organization.GatewaySubscriptionId); - sutProvider.GetDependency().SubscriptionGetAsync(organization.GatewaySubscriptionId) + sutProvider.GetDependency().GetSubscriptionAsync(organization.GatewaySubscriptionId) .Returns(GetSubscription(organization.GatewaySubscriptionId)); - await sutProvider.GetDependency().SubscriptionUpdateAsync( + await sutProvider.GetDependency().UpdateSubscriptionAsync( organization.GatewaySubscriptionId, SubscriptionUpdateRequest(expectedPlanId, subscriptionItem)); await sutProvider.Sut.AddOrganization(provider.Id, organization.Id, key); diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Queries/GetProviderWarningsQueryTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Queries/GetProviderWarningsQueryTests.cs index a7f896ef7a..96dbacfa92 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Queries/GetProviderWarningsQueryTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Queries/GetProviderWarningsQueryTests.cs @@ -3,7 +3,6 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Services; using Bit.Core.Context; -using Bit.Core.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; @@ -63,7 +62,7 @@ public class GetProviderWarningsQueryTests }); sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(true); - sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Any()) + sutProvider.GetDependency().ListTaxRegistrationsAsync(Arg.Any()) .Returns(new StripeList { Data = [] }); var response = await sutProvider.Sut.Run(provider); @@ -95,7 +94,7 @@ public class GetProviderWarningsQueryTests }); sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(true); - sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Any()) + sutProvider.GetDependency().ListTaxRegistrationsAsync(Arg.Any()) .Returns(new StripeList { Data = [] }); var response = await sutProvider.Sut.Run(provider); @@ -129,7 +128,7 @@ public class GetProviderWarningsQueryTests }); sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(false); - sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Any()) + sutProvider.GetDependency().ListTaxRegistrationsAsync(Arg.Any()) .Returns(new StripeList { Data = [] }); var response = await sutProvider.Sut.Run(provider); @@ -163,7 +162,7 @@ public class GetProviderWarningsQueryTests }); sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(true); - sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Any()) + sutProvider.GetDependency().ListTaxRegistrationsAsync(Arg.Any()) .Returns(new StripeList { Data = [] }); var response = await sutProvider.Sut.Run(provider); @@ -224,7 +223,7 @@ public class GetProviderWarningsQueryTests }); sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(true); - sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Any()) + sutProvider.GetDependency().ListTaxRegistrationsAsync(Arg.Any()) .Returns(new StripeList { Data = [new Registration { Country = "GB" }] @@ -257,7 +256,7 @@ public class GetProviderWarningsQueryTests }); sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(true); - sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Any()) + sutProvider.GetDependency().ListTaxRegistrationsAsync(Arg.Any()) .Returns(new StripeList { Data = [new Registration { Country = "CA" }] @@ -296,7 +295,7 @@ public class GetProviderWarningsQueryTests }); sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(true); - sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Any()) + sutProvider.GetDependency().ListTaxRegistrationsAsync(Arg.Any()) .Returns(new StripeList { Data = [new Registration { Country = "CA" }] @@ -338,7 +337,7 @@ public class GetProviderWarningsQueryTests }); sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(true); - sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Any()) + sutProvider.GetDependency().ListTaxRegistrationsAsync(Arg.Any()) .Returns(new StripeList { Data = [new Registration { Country = "CA" }] @@ -383,7 +382,7 @@ public class GetProviderWarningsQueryTests }); sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(true); - sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Any()) + sutProvider.GetDependency().ListTaxRegistrationsAsync(Arg.Any()) .Returns(new StripeList { Data = [new Registration { Country = "CA" }] @@ -428,7 +427,7 @@ public class GetProviderWarningsQueryTests }); sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(true); - sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Any()) + sutProvider.GetDependency().ListTaxRegistrationsAsync(Arg.Any()) .Returns(new StripeList { Data = [new Registration { Country = "CA" }] @@ -461,7 +460,7 @@ public class GetProviderWarningsQueryTests }); sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(true); - sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Is(opt => opt.Status == TaxRegistrationStatus.Active)) + sutProvider.GetDependency().ListTaxRegistrationsAsync(Arg.Is(opt => opt.Status == TaxRegistrationStatus.Active)) .Returns(new StripeList { Data = [ @@ -470,7 +469,7 @@ public class GetProviderWarningsQueryTests new Registration { Country = "FR" } ] }); - sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Is(opt => opt.Status == TaxRegistrationStatus.Scheduled)) + sutProvider.GetDependency().ListTaxRegistrationsAsync(Arg.Is(opt => opt.Status == TaxRegistrationStatus.Scheduled)) .Returns(new StripeList { Data = [] }); var response = await sutProvider.Sut.Run(provider); @@ -505,7 +504,7 @@ public class GetProviderWarningsQueryTests }); sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(true); - sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Any()) + sutProvider.GetDependency().ListTaxRegistrationsAsync(Arg.Any()) .Returns(new StripeList { Data = [new Registration { Country = "CA" }] @@ -543,7 +542,7 @@ public class GetProviderWarningsQueryTests }); sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(true); - sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Any()) + sutProvider.GetDependency().ListTaxRegistrationsAsync(Arg.Any()) .Returns(new StripeList { Data = [new Registration { Country = "US" }] diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/BusinessUnitConverterTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/BusinessUnitConverterTests.cs index c893886083..48b971a032 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/BusinessUnitConverterTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/BusinessUnitConverterTests.cs @@ -144,11 +144,11 @@ public class BusinessUnitConverterTests await businessUnitConverter.FinalizeConversion(organization, userId, token, providerKey, organizationKey); - await _stripeAdapter.Received(2).CustomerUpdateAsync(subscription.CustomerId, Arg.Any()); + await _stripeAdapter.Received(2).UpdateCustomerAsync(subscription.CustomerId, Arg.Any()); var updatedPriceId = ProviderPriceAdapter.GetActivePriceId(provider, enterpriseAnnually.Type); - await _stripeAdapter.Received(1).SubscriptionUpdateAsync(subscription.Id, Arg.Is( + await _stripeAdapter.Received(1).UpdateSubscriptionAsync(subscription.Id, Arg.Is( arguments => arguments.Items.Count == 2 && arguments.Items[0].Id == "subscription_item_id" && diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderBillingServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderBillingServiceTests.cs index daf35e7ae9..93ce33edc4 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderBillingServiceTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderBillingServiceTests.cs @@ -20,7 +20,6 @@ using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; -using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Test.Billing.Mocks; using Bit.Test.Common.AutoFixture; @@ -85,7 +84,7 @@ public class ProviderBillingServiceTests // Assert await providerPlanRepository.Received(0).ReplaceAsync(Arg.Any()); - await stripeAdapter.Received(0).SubscriptionUpdateAsync(Arg.Any(), Arg.Any()); + await stripeAdapter.Received(0).UpdateSubscriptionAsync(Arg.Any(), Arg.Any()); } [Theory, BitAutoData] @@ -113,7 +112,7 @@ public class ProviderBillingServiceTests // Assert await providerPlanRepository.Received(0).ReplaceAsync(Arg.Any()); - await stripeAdapter.Received(0).SubscriptionUpdateAsync(Arg.Any(), Arg.Any()); + await stripeAdapter.Received(0).UpdateSubscriptionAsync(Arg.Any(), Arg.Any()); } [Theory, BitAutoData] @@ -180,14 +179,14 @@ public class ProviderBillingServiceTests var stripeAdapter = sutProvider.GetDependency(); await stripeAdapter.Received(1) - .SubscriptionUpdateAsync( + .UpdateSubscriptionAsync( Arg.Is(provider.GatewaySubscriptionId), Arg.Is(p => p.Items.Count(si => si.Id == "si_ent_annual" && si.Deleted == true) == 1)); var newPlanCfg = MockPlans.Get(command.NewPlan); await stripeAdapter.Received(1) - .SubscriptionUpdateAsync( + .UpdateSubscriptionAsync( Arg.Is(provider.GatewaySubscriptionId), Arg.Is(p => p.Items.Count(si => @@ -268,7 +267,7 @@ public class ProviderBillingServiceTests CloudRegion = "US" }); - sutProvider.GetDependency().CustomerCreateAsync(Arg.Is( + sutProvider.GetDependency().CreateCustomerAsync(Arg.Is( options => options.Address.Country == providerCustomer.Address.Country && options.Address.PostalCode == providerCustomer.Address.PostalCode && @@ -288,7 +287,7 @@ public class ProviderBillingServiceTests await sutProvider.Sut.CreateCustomerForClientOrganization(provider, organization); - await sutProvider.GetDependency().Received(1).CustomerCreateAsync(Arg.Is( + await sutProvider.GetDependency().Received(1).CreateCustomerAsync(Arg.Is( options => options.Address.Country == providerCustomer.Address.Country && options.Address.PostalCode == providerCustomer.Address.PostalCode && @@ -349,7 +348,7 @@ public class ProviderBillingServiceTests CloudRegion = "US" }); - sutProvider.GetDependency().CustomerCreateAsync(Arg.Is( + sutProvider.GetDependency().CreateCustomerAsync(Arg.Is( options => options.Address.Country == providerCustomer.Address.Country && options.Address.PostalCode == providerCustomer.Address.PostalCode && @@ -370,7 +369,7 @@ public class ProviderBillingServiceTests await sutProvider.Sut.CreateCustomerForClientOrganization(provider, organization); - await sutProvider.GetDependency().Received(1).CustomerCreateAsync(Arg.Is( + await sutProvider.GetDependency().Received(1).CreateCustomerAsync(Arg.Is( options => options.Address.Country == providerCustomer.Address.Country && options.Address.PostalCode == providerCustomer.Address.PostalCode && @@ -535,7 +534,7 @@ public class ProviderBillingServiceTests await sutProvider.Sut.ScaleSeats(provider, PlanType.TeamsMonthly, 10); // 50 assigned seats + 10 seat scale up = 60 seats, well below the 100 minimum - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().SubscriptionUpdateAsync( + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().UpdateSubscriptionAsync( Arg.Any(), Arg.Any()); @@ -619,7 +618,7 @@ public class ProviderBillingServiceTests await sutProvider.Sut.ScaleSeats(provider, PlanType.TeamsMonthly, 10); // 95 current + 10 seat scale = 105 seats, 5 above the minimum - await sutProvider.GetDependency().Received(1).SubscriptionUpdateAsync( + await sutProvider.GetDependency().Received(1).UpdateSubscriptionAsync( provider.GatewaySubscriptionId, Arg.Is( options => @@ -707,7 +706,7 @@ public class ProviderBillingServiceTests await sutProvider.Sut.ScaleSeats(provider, PlanType.TeamsMonthly, 10); // 110 current + 10 seat scale up = 120 seats - await sutProvider.GetDependency().Received(1).SubscriptionUpdateAsync( + await sutProvider.GetDependency().Received(1).UpdateSubscriptionAsync( provider.GatewaySubscriptionId, Arg.Is( options => @@ -795,7 +794,7 @@ public class ProviderBillingServiceTests await sutProvider.Sut.ScaleSeats(provider, PlanType.TeamsMonthly, -30); // 110 seats - 30 scale down seats = 80 seats, below the 100 seat minimum. - await sutProvider.GetDependency().Received(1).SubscriptionUpdateAsync( + await sutProvider.GetDependency().Received(1).UpdateSubscriptionAsync( provider.GatewaySubscriptionId, Arg.Is( options => @@ -914,12 +913,12 @@ public class ProviderBillingServiceTests var stripeAdapter = sutProvider.GetDependency(); var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.BankAccount, Token = "token" }; - stripeAdapter.SetupIntentList(Arg.Is(options => + stripeAdapter.ListSetupIntentsAsync(Arg.Is(options => options.PaymentMethod == tokenizedPaymentMethod.Token)).Returns([ new SetupIntent { Id = "setup_intent_id" } ]); - stripeAdapter.CustomerCreateAsync(Arg.Is(o => + stripeAdapter.CreateCustomerAsync(Arg.Is(o => o.Address.Country == billingAddress.Country && o.Address.PostalCode == billingAddress.PostalCode && o.Address.Line1 == billingAddress.Line1 && @@ -942,7 +941,7 @@ public class ProviderBillingServiceTests await sutProvider.GetDependency().Received(1).Set(provider.Id, "setup_intent_id"); - await stripeAdapter.Received(1).SetupIntentCancel("setup_intent_id", Arg.Is(options => + await stripeAdapter.Received(1).CancelSetupIntentAsync("setup_intent_id", Arg.Is(options => options.CancellationReason == "abandoned")); await sutProvider.GetDependency().Received(1).RemoveSetupIntentForSubscriber(provider.Id); @@ -964,7 +963,7 @@ public class ProviderBillingServiceTests sutProvider.GetDependency().CreateBraintreeCustomer(provider, tokenizedPaymentMethod.Token) .Returns("braintree_customer_id"); - stripeAdapter.CustomerCreateAsync(Arg.Is(o => + stripeAdapter.CreateCustomerAsync(Arg.Is(o => o.Address.Country == billingAddress.Country && o.Address.PostalCode == billingAddress.PostalCode && o.Address.Line1 == billingAddress.Line1 && @@ -1007,12 +1006,12 @@ public class ProviderBillingServiceTests var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.BankAccount, Token = "token" }; - stripeAdapter.SetupIntentList(Arg.Is(options => + stripeAdapter.ListSetupIntentsAsync(Arg.Is(options => options.PaymentMethod == tokenizedPaymentMethod.Token)).Returns([ new SetupIntent { Id = "setup_intent_id" } ]); - stripeAdapter.CustomerCreateAsync(Arg.Is(o => + stripeAdapter.CreateCustomerAsync(Arg.Is(o => o.Address.Country == billingAddress.Country && o.Address.PostalCode == billingAddress.PostalCode && o.Address.Line1 == billingAddress.Line1 && @@ -1058,7 +1057,7 @@ public class ProviderBillingServiceTests sutProvider.GetDependency().CreateBraintreeCustomer(provider, tokenizedPaymentMethod.Token) .Returns("braintree_customer_id"); - stripeAdapter.CustomerCreateAsync(Arg.Is(o => + stripeAdapter.CreateCustomerAsync(Arg.Is(o => o.Address.Country == billingAddress.Country && o.Address.PostalCode == billingAddress.PostalCode && o.Address.Line1 == billingAddress.Line1 && @@ -1100,7 +1099,7 @@ public class ProviderBillingServiceTests var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.Card, Token = "token" }; - stripeAdapter.CustomerCreateAsync(Arg.Is(o => + stripeAdapter.CreateCustomerAsync(Arg.Is(o => o.Address.Country == billingAddress.Country && o.Address.PostalCode == billingAddress.PostalCode && o.Address.Line1 == billingAddress.Line1 && @@ -1142,7 +1141,7 @@ public class ProviderBillingServiceTests var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.Card, Token = "token" }; - stripeAdapter.CustomerCreateAsync(Arg.Is(o => + stripeAdapter.CreateCustomerAsync(Arg.Is(o => o.Address.Country == billingAddress.Country && o.Address.PostalCode == billingAddress.PostalCode && o.Address.Line1 == billingAddress.Line1 && @@ -1178,7 +1177,7 @@ public class ProviderBillingServiceTests var stripeAdapter = sutProvider.GetDependency(); var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.Card, Token = "token" }; - stripeAdapter.CustomerCreateAsync(Arg.Any()) + stripeAdapter.CreateCustomerAsync(Arg.Any()) .Throws(new StripeException("Invalid tax ID") { StripeError = new StripeError { Code = "tax_id_invalid" } }); var actual = await Assert.ThrowsAsync(async () => @@ -1216,7 +1215,7 @@ public class ProviderBillingServiceTests await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .SubscriptionCreateAsync(Arg.Any()); + .CreateSubscriptionAsync(Arg.Any()); } [Theory, BitAutoData] @@ -1244,7 +1243,7 @@ public class ProviderBillingServiceTests await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .SubscriptionCreateAsync(Arg.Any()); + .CreateSubscriptionAsync(Arg.Any()); } [Theory, BitAutoData] @@ -1272,7 +1271,7 @@ public class ProviderBillingServiceTests await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .SubscriptionCreateAsync(Arg.Any()); + .CreateSubscriptionAsync(Arg.Any()); } [Theory, BitAutoData] @@ -1323,7 +1322,7 @@ public class ProviderBillingServiceTests sutProvider.GetDependency().GetByProviderId(provider.Id) .Returns(providerPlans); - sutProvider.GetDependency().SubscriptionCreateAsync(Arg.Any()) + sutProvider.GetDependency().CreateSubscriptionAsync(Arg.Any()) .Returns( new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Incomplete }); @@ -1381,7 +1380,7 @@ public class ProviderBillingServiceTests var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active }; - sutProvider.GetDependency().SubscriptionCreateAsync(Arg.Is( + sutProvider.GetDependency().CreateSubscriptionAsync(Arg.Is( sub => sub.AutomaticTax.Enabled == true && sub.CollectionMethod == StripeConstants.CollectionMethod.SendInvoice && @@ -1458,7 +1457,7 @@ public class ProviderBillingServiceTests var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active }; - sutProvider.GetDependency().SubscriptionCreateAsync(Arg.Is( + sutProvider.GetDependency().CreateSubscriptionAsync(Arg.Is( sub => sub.AutomaticTax.Enabled == true && sub.CollectionMethod == StripeConstants.CollectionMethod.ChargeAutomatically && @@ -1538,7 +1537,7 @@ public class ProviderBillingServiceTests sutProvider.GetDependency().GetSetupIntentIdForSubscriber(provider.Id).Returns(setupIntentId); - sutProvider.GetDependency().SetupIntentGet(setupIntentId, Arg.Is(options => + sutProvider.GetDependency().GetSetupIntentAsync(setupIntentId, Arg.Is(options => options.Expand.Contains("payment_method"))).Returns(new SetupIntent { Id = setupIntentId, @@ -1553,7 +1552,7 @@ public class ProviderBillingServiceTests } }); - sutProvider.GetDependency().SubscriptionCreateAsync(Arg.Is( + sutProvider.GetDependency().CreateSubscriptionAsync(Arg.Is( sub => sub.AutomaticTax.Enabled == true && sub.CollectionMethod == StripeConstants.CollectionMethod.ChargeAutomatically && @@ -1635,7 +1634,7 @@ public class ProviderBillingServiceTests var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active }; - sutProvider.GetDependency().SubscriptionCreateAsync(Arg.Is( + sutProvider.GetDependency().CreateSubscriptionAsync(Arg.Is( sub => sub.AutomaticTax.Enabled == true && sub.CollectionMethod == StripeConstants.CollectionMethod.ChargeAutomatically && @@ -1713,7 +1712,7 @@ public class ProviderBillingServiceTests var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active }; - sutProvider.GetDependency().SubscriptionCreateAsync(Arg.Is( + sutProvider.GetDependency().CreateSubscriptionAsync(Arg.Is( sub => sub.AutomaticTax.Enabled == true && sub.CollectionMethod == StripeConstants.CollectionMethod.ChargeAutomatically && @@ -1828,7 +1827,7 @@ public class ProviderBillingServiceTests await providerPlanRepository.Received(1).ReplaceAsync(Arg.Is( providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly && providerPlan.SeatMinimum == 20 && providerPlan.PurchasedSeats == 5)); - await stripeAdapter.Received(1).SubscriptionUpdateAsync(provider.GatewaySubscriptionId, + await stripeAdapter.Received(1).UpdateSubscriptionAsync(provider.GatewaySubscriptionId, Arg.Is( options => options.Items.Count == 2 && @@ -1908,7 +1907,7 @@ public class ProviderBillingServiceTests await providerPlanRepository.Received(1).ReplaceAsync(Arg.Is( providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly && providerPlan.SeatMinimum == 50)); - await stripeAdapter.Received(1).SubscriptionUpdateAsync(provider.GatewaySubscriptionId, + await stripeAdapter.Received(1).UpdateSubscriptionAsync(provider.GatewaySubscriptionId, Arg.Is( options => options.Items.Count == 2 && @@ -1989,7 +1988,7 @@ public class ProviderBillingServiceTests providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly && providerPlan.SeatMinimum == 60 && providerPlan.PurchasedSeats == 10)); await stripeAdapter.DidNotReceiveWithAnyArgs() - .SubscriptionUpdateAsync(Arg.Any(), Arg.Any()); + .UpdateSubscriptionAsync(Arg.Any(), Arg.Any()); } [Theory, BitAutoData] @@ -2062,7 +2061,7 @@ public class ProviderBillingServiceTests await providerPlanRepository.Received(1).ReplaceAsync(Arg.Is( providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly && providerPlan.SeatMinimum == 80 && providerPlan.PurchasedSeats == 0)); - await stripeAdapter.Received(1).SubscriptionUpdateAsync(provider.GatewaySubscriptionId, + await stripeAdapter.Received(1).UpdateSubscriptionAsync(provider.GatewaySubscriptionId, Arg.Is( options => options.Items.Count == 2 && @@ -2142,7 +2141,7 @@ public class ProviderBillingServiceTests await providerPlanRepository.DidNotReceive().ReplaceAsync(Arg.Is( providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly)); - await stripeAdapter.Received(1).SubscriptionUpdateAsync(provider.GatewaySubscriptionId, + await stripeAdapter.Received(1).UpdateSubscriptionAsync(provider.GatewaySubscriptionId, Arg.Is( options => options.Items.Count == 1 && @@ -2151,4 +2150,151 @@ public class ProviderBillingServiceTests } #endregion + + #region UpdateProviderNameAndEmail + + [Theory, BitAutoData] + public async Task UpdateProviderNameAndEmail_NullGatewayCustomerId_LogsWarningAndReturns( + Provider provider, + SutProvider sutProvider) + { + // Arrange + provider.GatewayCustomerId = null; + var stripeAdapter = sutProvider.GetDependency(); + + // Act + await sutProvider.Sut.UpdateProviderNameAndEmail(provider); + + // Assert + await stripeAdapter.DidNotReceive().UpdateCustomerAsync( + Arg.Any(), + Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateProviderNameAndEmail_EmptyGatewayCustomerId_LogsWarningAndReturns( + Provider provider, + SutProvider sutProvider) + { + // Arrange + provider.GatewayCustomerId = ""; + var stripeAdapter = sutProvider.GetDependency(); + + // Act + await sutProvider.Sut.UpdateProviderNameAndEmail(provider); + + // Assert + await stripeAdapter.DidNotReceive().UpdateCustomerAsync( + Arg.Any(), + Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateProviderNameAndEmail_NullProviderName_LogsWarningAndReturns( + Provider provider, + SutProvider sutProvider) + { + // Arrange + provider.Name = null; + provider.GatewayCustomerId = "cus_test123"; + var stripeAdapter = sutProvider.GetDependency(); + + // Act + await sutProvider.Sut.UpdateProviderNameAndEmail(provider); + + // Assert + await stripeAdapter.DidNotReceive().UpdateCustomerAsync( + Arg.Any(), + Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateProviderNameAndEmail_EmptyProviderName_LogsWarningAndReturns( + Provider provider, + SutProvider sutProvider) + { + // Arrange + provider.Name = ""; + provider.GatewayCustomerId = "cus_test123"; + var stripeAdapter = sutProvider.GetDependency(); + + // Act + await sutProvider.Sut.UpdateProviderNameAndEmail(provider); + + // Assert + await stripeAdapter.DidNotReceive().UpdateCustomerAsync( + Arg.Any(), + Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateProviderNameAndEmail_ValidProvider_CallsStripeWithCorrectParameters( + Provider provider, + SutProvider sutProvider) + { + // Arrange + provider.Name = "Test Provider"; + provider.BillingEmail = "billing@test.com"; + provider.GatewayCustomerId = "cus_test123"; + var stripeAdapter = sutProvider.GetDependency(); + + // Act + await sutProvider.Sut.UpdateProviderNameAndEmail(provider); + + // Assert + await stripeAdapter.Received(1).UpdateCustomerAsync( + provider.GatewayCustomerId, + Arg.Is(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 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(); + + // Act + await sutProvider.Sut.UpdateProviderNameAndEmail(provider); + + // Assert + await stripeAdapter.Received(1).UpdateCustomerAsync( + provider.GatewayCustomerId, + Arg.Is(options => + options.InvoiceSettings.CustomFields[0].Value == longName)); + } + + [Theory, BitAutoData] + public async Task UpdateProviderNameAndEmail_NullBillingEmail_UpdatesWithNull( + Provider provider, + SutProvider sutProvider) + { + // Arrange + provider.Name = "Test Provider"; + provider.BillingEmail = null; + provider.GatewayCustomerId = "cus_test123"; + var stripeAdapter = sutProvider.GetDependency(); + + // Act + await sutProvider.Sut.UpdateProviderNameAndEmail(provider); + + // Assert + await stripeAdapter.Received(1).UpdateCustomerAsync( + provider.GatewayCustomerId, + Arg.Is(options => + options.Email == null && + options.Description == provider.Name)); + } + + #endregion } diff --git a/bitwarden_license/test/Commercial.Core.Test/SecretsManager/Repositories/SecretVersionRepositoryTests.cs b/bitwarden_license/test/Commercial.Core.Test/SecretsManager/Repositories/SecretVersionRepositoryTests.cs new file mode 100644 index 0000000000..659a6d1233 --- /dev/null +++ b/bitwarden_license/test/Commercial.Core.Test/SecretsManager/Repositories/SecretVersionRepositoryTests.cs @@ -0,0 +1,130 @@ +using Bit.Core.SecretsManager.Entities; +using Bit.Test.Common.AutoFixture.Attributes; +using Xunit; + +namespace Bit.Commercial.Core.Test.SecretsManager.Repositories; + +public class SecretVersionRepositoryTests +{ + [Theory] + [BitAutoData] + public void SecretVersion_EntityCreation_Success(SecretVersion secretVersion) + { + // Arrange & Act + secretVersion.SetNewId(); + + // Assert + Assert.NotEqual(Guid.Empty, secretVersion.Id); + Assert.NotEqual(Guid.Empty, secretVersion.SecretId); + Assert.NotNull(secretVersion.Value); + Assert.NotEqual(default, secretVersion.VersionDate); + } + + [Theory] + [BitAutoData] + public void SecretVersion_WithServiceAccountEditor_Success(SecretVersion secretVersion, Guid serviceAccountId) + { + // Arrange & Act + secretVersion.EditorServiceAccountId = serviceAccountId; + secretVersion.EditorOrganizationUserId = null; + + // Assert + Assert.Equal(serviceAccountId, secretVersion.EditorServiceAccountId); + Assert.Null(secretVersion.EditorOrganizationUserId); + } + + [Theory] + [BitAutoData] + public void SecretVersion_WithOrganizationUserEditor_Success(SecretVersion secretVersion, Guid organizationUserId) + { + // Arrange & Act + secretVersion.EditorOrganizationUserId = organizationUserId; + secretVersion.EditorServiceAccountId = null; + + // Assert + Assert.Equal(organizationUserId, secretVersion.EditorOrganizationUserId); + Assert.Null(secretVersion.EditorServiceAccountId); + } + + [Theory] + [BitAutoData] + public void SecretVersion_NullableEditors_Success(SecretVersion secretVersion) + { + // Arrange & Act + secretVersion.EditorServiceAccountId = null; + secretVersion.EditorOrganizationUserId = null; + + // Assert + Assert.Null(secretVersion.EditorServiceAccountId); + Assert.Null(secretVersion.EditorOrganizationUserId); + } + + [Theory] + [BitAutoData] + public void SecretVersion_VersionDateSet_Success(SecretVersion secretVersion) + { + // Arrange + var versionDate = DateTime.UtcNow; + + // Act + secretVersion.VersionDate = versionDate; + + // Assert + Assert.Equal(versionDate, secretVersion.VersionDate); + } + + [Theory] + [BitAutoData] + public void SecretVersion_ValueEncrypted_Success(SecretVersion secretVersion, string encryptedValue) + { + // Arrange & Act + secretVersion.Value = encryptedValue; + + // Assert + Assert.Equal(encryptedValue, secretVersion.Value); + Assert.NotEmpty(secretVersion.Value); + } + + [Theory] + [BitAutoData] + public void SecretVersion_MultipleVersions_DifferentIds(List secretVersions, Guid secretId) + { + // Arrange & Act + foreach (var version in secretVersions) + { + version.SecretId = secretId; + version.SetNewId(); + } + + // Assert + var distinctIds = secretVersions.Select(v => v.Id).Distinct(); + Assert.Equal(secretVersions.Count, distinctIds.Count()); + Assert.All(secretVersions, v => Assert.Equal(secretId, v.SecretId)); + } + + [Theory] + [BitAutoData] + public void SecretVersion_VersionDateOrdering_Success(SecretVersion version1, SecretVersion version2, SecretVersion version3, Guid secretId) + { + // Arrange + var now = DateTime.UtcNow; + version1.SecretId = secretId; + version1.VersionDate = now.AddDays(-2); + + version2.SecretId = secretId; + version2.VersionDate = now.AddDays(-1); + + version3.SecretId = secretId; + version3.VersionDate = now; + + var versions = new List { version2, version3, version1 }; + + // Act + var orderedVersions = versions.OrderByDescending(v => v.VersionDate).ToList(); + + // Assert + Assert.Equal(version3.Id, orderedVersions[0].Id); // Most recent + Assert.Equal(version2.Id, orderedVersions[1].Id); + Assert.Equal(version1.Id, orderedVersions[2].Id); // Oldest + } +} diff --git a/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs b/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs index c04948e21f..66cb018923 100644 --- a/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs +++ b/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs @@ -3,13 +3,14 @@ using System.Security.Claims; using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Repositories; -using Bit.Core.Auth.UserFeatures.Registration; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Repositories; using Bit.Core.Services; +using Bit.Core.Tokens; using Bit.Sso.Controllers; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; @@ -19,7 +20,6 @@ using Duende.IdentityServer.Models; using Duende.IdentityServer.Services; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; using NSubstitute; @@ -1012,129 +1012,127 @@ public class AccountControllerTest } [Theory, BitAutoData] - public async Task AutoProvisionUserAsync_WithFeatureFlagEnabled_CallsRegisterSSOAutoProvisionedUser( - SutProvider sutProvider) + public void ExternalChallenge_WithMatchingOrgId_Succeeds( + SutProvider sutProvider, + Organization organization) { // Arrange - var orgId = Guid.NewGuid(); - var providerUserId = "ext-new-user"; - var email = "newuser@example.com"; - var organization = new Organization { Id = orgId, Name = "Test Org", Seats = null }; + var orgId = organization.Id; + var scheme = orgId.ToString(); + var returnUrl = "~/vault"; + var state = "test-state"; + var userIdentifier = "user-123"; + var ssoToken = "valid-sso-token"; - // No existing user (JIT provisioning scenario) - sutProvider.GetDependency().GetByEmailAsync(email).Returns((User?)null); - sutProvider.GetDependency().GetByIdAsync(orgId).Returns(organization); - sutProvider.GetDependency().GetByOrganizationEmailAsync(orgId, email) - .Returns((OrganizationUser?)null); + // Mock the data protector to return a tokenable with matching org ID + var dataProtector = sutProvider.GetDependency>(); + var tokenable = new SsoTokenable(organization, 3600); + dataProtector.Unprotect(ssoToken).Returns(tokenable); - // Feature flag enabled - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates) - .Returns(true); + // Mock URL helper for IsLocalUrl check + var urlHelper = Substitute.For(); + urlHelper.IsLocalUrl(returnUrl).Returns(true); + sutProvider.Sut.Url = urlHelper; - // Mock the RegisterSSOAutoProvisionedUserAsync to return success - sutProvider.GetDependency() - .RegisterSSOAutoProvisionedUserAsync(Arg.Any(), Arg.Any()) - .Returns(IdentityResult.Success); - - var claims = new[] - { - new Claim(JwtClaimTypes.Email, email), - new Claim(JwtClaimTypes.Name, "New User") - } as IEnumerable; - var config = new SsoConfigurationData(); - - var method = typeof(AccountController).GetMethod( - "CreateUserAndOrgUserConditionallyAsync", - BindingFlags.Instance | BindingFlags.NonPublic); - Assert.NotNull(method); + // Mock interaction service for IsValidReturnUrl check + var interactionService = sutProvider.GetDependency(); + interactionService.IsValidReturnUrl(returnUrl).Returns(true); // Act - var task = (Task<(User user, Organization organization, OrganizationUser orgUser)>)method!.Invoke( - sutProvider.Sut, - new object[] - { - orgId.ToString(), - providerUserId, - claims, - null!, - config - })!; - - var result = await task; + var result = sutProvider.Sut.ExternalChallenge(scheme, returnUrl, state, userIdentifier, ssoToken); // Assert - await sutProvider.GetDependency().Received(1) - .RegisterSSOAutoProvisionedUserAsync( - Arg.Is(u => u.Email == email && u.Name == "New User"), - Arg.Is(o => o.Id == orgId && o.Name == "Test Org")); - - Assert.NotNull(result.user); - Assert.Equal(email, result.user.Email); - Assert.Equal(organization.Id, result.organization.Id); + var challengeResult = Assert.IsType(result); + Assert.Contains(scheme, challengeResult.AuthenticationSchemes); + Assert.NotNull(challengeResult.Properties); + Assert.Equal(scheme, challengeResult.Properties.Items["scheme"]); + Assert.Equal(returnUrl, challengeResult.Properties.Items["return_url"]); + Assert.Equal(state, challengeResult.Properties.Items["state"]); + Assert.Equal(userIdentifier, challengeResult.Properties.Items["user_identifier"]); } [Theory, BitAutoData] - public async Task AutoProvisionUserAsync_WithFeatureFlagDisabled_CallsRegisterUserInstead( + public void ExternalChallenge_WithMismatchedOrgId_ThrowsSsoOrganizationIdMismatch( + SutProvider sutProvider, + Organization organization) + { + // Arrange + var correctOrgId = organization.Id; + var wrongOrgId = Guid.NewGuid(); + var scheme = wrongOrgId.ToString(); // Different from tokenable's org ID + var returnUrl = "~/vault"; + var state = "test-state"; + var userIdentifier = "user-123"; + var ssoToken = "valid-sso-token"; + + // Mock the data protector to return a tokenable with different org ID + var dataProtector = sutProvider.GetDependency>(); + var tokenable = new SsoTokenable(organization, 3600); // Contains correctOrgId + dataProtector.Unprotect(ssoToken).Returns(tokenable); + + // Mock i18n service to return the key + sutProvider.GetDependency() + .T(Arg.Any()) + .Returns(ci => (string)ci[0]!); + + // Act & Assert + var ex = Assert.Throws(() => + sutProvider.Sut.ExternalChallenge(scheme, returnUrl, state, userIdentifier, ssoToken)); + Assert.Equal("SsoOrganizationIdMismatch", ex.Message); + } + + [Theory, BitAutoData] + public void ExternalChallenge_WithInvalidSchemeFormat_ThrowsSsoOrganizationIdMismatch( + SutProvider sutProvider, + Organization organization) + { + // Arrange + var scheme = "not-a-valid-guid"; + var returnUrl = "~/vault"; + var state = "test-state"; + var userIdentifier = "user-123"; + var ssoToken = "valid-sso-token"; + + // Mock the data protector to return a valid tokenable + var dataProtector = sutProvider.GetDependency>(); + var tokenable = new SsoTokenable(organization, 3600); + dataProtector.Unprotect(ssoToken).Returns(tokenable); + + // Mock i18n service to return the key + sutProvider.GetDependency() + .T(Arg.Any()) + .Returns(ci => (string)ci[0]!); + + // Act & Assert + var ex = Assert.Throws(() => + sutProvider.Sut.ExternalChallenge(scheme, returnUrl, state, userIdentifier, ssoToken)); + Assert.Equal("SsoOrganizationIdMismatch", ex.Message); + } + + [Theory, BitAutoData] + public void ExternalChallenge_WithInvalidSsoToken_ThrowsInvalidSsoToken( SutProvider sutProvider) { // Arrange var orgId = Guid.NewGuid(); - var providerUserId = "ext-legacy-user"; - var email = "legacyuser@example.com"; - var organization = new Organization { Id = orgId, Name = "Test Org", Seats = null }; + var scheme = orgId.ToString(); + var returnUrl = "~/vault"; + var state = "test-state"; + var userIdentifier = "user-123"; + var ssoToken = "invalid-corrupted-token"; - // No existing user (JIT provisioning scenario) - sutProvider.GetDependency().GetByEmailAsync(email).Returns((User?)null); - sutProvider.GetDependency().GetByIdAsync(orgId).Returns(organization); - sutProvider.GetDependency().GetByOrganizationEmailAsync(orgId, email) - .Returns((OrganizationUser?)null); + // Mock the data protector to throw when trying to unprotect + var dataProtector = sutProvider.GetDependency>(); + dataProtector.Unprotect(ssoToken).Returns(_ => throw new Exception("Token validation failed")); - // Feature flag disabled - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates) - .Returns(false); + // Mock i18n service to return the key + sutProvider.GetDependency() + .T(Arg.Any()) + .Returns(ci => (string)ci[0]!); - // Mock the RegisterUser to return success - sutProvider.GetDependency() - .RegisterUser(Arg.Any()) - .Returns(IdentityResult.Success); - - var claims = new[] - { - new Claim(JwtClaimTypes.Email, email), - new Claim(JwtClaimTypes.Name, "Legacy User") - } as IEnumerable; - var config = new SsoConfigurationData(); - - var method = typeof(AccountController).GetMethod( - "CreateUserAndOrgUserConditionallyAsync", - BindingFlags.Instance | BindingFlags.NonPublic); - Assert.NotNull(method); - - // Act - var task = (Task<(User user, Organization organization, OrganizationUser orgUser)>)method!.Invoke( - sutProvider.Sut, - new object[] - { - orgId.ToString(), - providerUserId, - claims, - null!, - config - })!; - - var result = await task; - - // Assert - await sutProvider.GetDependency().Received(1) - .RegisterUser(Arg.Is(u => u.Email == email && u.Name == "Legacy User")); - - // Verify the new method was NOT called - await sutProvider.GetDependency().DidNotReceive() - .RegisterSSOAutoProvisionedUserAsync(Arg.Any(), Arg.Any()); - - Assert.NotNull(result.user); - Assert.Equal(email, result.user.Email); + // Act & Assert + var ex = Assert.Throws(() => + sutProvider.Sut.ExternalChallenge(scheme, returnUrl, state, userIdentifier, ssoToken)); + Assert.Equal("InvalidSsoToken", ex.Message); } } diff --git a/bitwarden_license/test/SSO.Test/IdentityServer/DistributedCachePersistedGrantStoreTests.cs b/bitwarden_license/test/SSO.Test/IdentityServer/DistributedCachePersistedGrantStoreTests.cs new file mode 100644 index 0000000000..c0aa93f068 --- /dev/null +++ b/bitwarden_license/test/SSO.Test/IdentityServer/DistributedCachePersistedGrantStoreTests.cs @@ -0,0 +1,257 @@ +using Bit.Sso.IdentityServer; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Stores; +using NSubstitute; +using ZiggyCreatures.Caching.Fusion; + +namespace Bit.SSO.Test.IdentityServer; + +public class DistributedCachePersistedGrantStoreTests +{ + private readonly IFusionCache _cache; + private readonly DistributedCachePersistedGrantStore _sut; + + public DistributedCachePersistedGrantStoreTests() + { + _cache = Substitute.For(); + _sut = new DistributedCachePersistedGrantStore(_cache); + } + + [Fact] + public async Task StoreAsync_StoresGrantWithCalculatedTTL() + { + // Arrange + var grant = CreateTestGrant("test-key", expiration: DateTime.UtcNow.AddMinutes(5)); + + // Act + await _sut.StoreAsync(grant); + + // Assert + await _cache.Received(1).SetAsync( + "test-key", + grant, + Arg.Is(opts => + opts.Duration >= TimeSpan.FromMinutes(4.9) && + opts.Duration <= TimeSpan.FromMinutes(5))); + } + + [Fact] + public async Task StoreAsync_WithNoExpiration_UsesDefaultFiveMinuteTTL() + { + // Arrange + var grant = CreateTestGrant("no-expiry-key", expiration: null); + + // Act + await _sut.StoreAsync(grant); + + // Assert + await _cache.Received(1).SetAsync( + "no-expiry-key", + grant, + Arg.Is(opts => opts.Duration == TimeSpan.FromMinutes(5))); + } + + [Fact] + public async Task StoreAsync_WithAlreadyExpiredGrant_DoesNotStore() + { + // Arrange + var expiredGrant = CreateTestGrant("expired-key", expiration: DateTime.UtcNow.AddMinutes(-1)); + + // Act + await _sut.StoreAsync(expiredGrant); + + // Assert + await _cache.DidNotReceive().SetAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task StoreAsync_EnablesDistributedCache() + { + // Arrange + var grant = CreateTestGrant("distributed-key", expiration: DateTime.UtcNow.AddMinutes(5)); + + // Act + await _sut.StoreAsync(grant); + + // Assert + await _cache.Received(1).SetAsync( + "distributed-key", + grant, + Arg.Is(opts => + opts.SkipDistributedCache == false && + opts.SkipDistributedCacheReadWhenStale == false)); + } + + [Fact] + public async Task GetAsync_WithValidGrant_ReturnsGrant() + { + // Arrange + var grant = CreateTestGrant("valid-key", expiration: DateTime.UtcNow.AddMinutes(5)); + _cache.TryGetAsync("valid-key") + .Returns(MaybeValue.FromValue(grant)); + + // Act + var result = await _sut.GetAsync("valid-key"); + + // Assert + Assert.NotNull(result); + Assert.Equal("valid-key", result.Key); + Assert.Equal("authorization_code", result.Type); + Assert.Equal("test-subject", result.SubjectId); + await _cache.DidNotReceive().RemoveAsync(Arg.Any()); + } + + [Fact] + public async Task GetAsync_WithNonExistentKey_ReturnsNull() + { + // Arrange + _cache.TryGetAsync("nonexistent-key") + .Returns(MaybeValue.None); + + // Act + var result = await _sut.GetAsync("nonexistent-key"); + + // Assert + Assert.Null(result); + await _cache.DidNotReceive().RemoveAsync(Arg.Any()); + } + + [Fact] + public async Task GetAsync_WithExpiredGrant_RemovesAndReturnsNull() + { + // Arrange + var expiredGrant = CreateTestGrant("expired-key", expiration: DateTime.UtcNow.AddMinutes(-1)); + _cache.TryGetAsync("expired-key") + .Returns(MaybeValue.FromValue(expiredGrant)); + + // Act + var result = await _sut.GetAsync("expired-key"); + + // Assert + Assert.Null(result); + await _cache.Received(1).RemoveAsync("expired-key"); + } + + [Fact] + public async Task GetAsync_WithNoExpiration_ReturnsGrant() + { + // Arrange + var grant = CreateTestGrant("no-expiry-key", expiration: null); + _cache.TryGetAsync("no-expiry-key") + .Returns(MaybeValue.FromValue(grant)); + + // Act + var result = await _sut.GetAsync("no-expiry-key"); + + // Assert + Assert.NotNull(result); + Assert.Equal("no-expiry-key", result.Key); + Assert.Null(result.Expiration); + await _cache.DidNotReceive().RemoveAsync(Arg.Any()); + } + + [Fact] + public async Task RemoveAsync_RemovesGrantFromCache() + { + // Act + await _sut.RemoveAsync("remove-key"); + + // Assert + await _cache.Received(1).RemoveAsync("remove-key"); + } + + [Fact] + public async Task GetAllAsync_ReturnsEmptyCollection() + { + // Arrange + var filter = new PersistedGrantFilter + { + SubjectId = "test-subject", + SessionId = "test-session", + ClientId = "test-client", + Type = "authorization_code" + }; + + // Act + var result = await _sut.GetAllAsync(filter); + + // Assert + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public async Task RemoveAllAsync_CompletesWithoutError() + { + // Arrange + var filter = new PersistedGrantFilter + { + SubjectId = "test-subject", + ClientId = "test-client" + }; + + // Act & Assert - should not throw + await _sut.RemoveAllAsync(filter); + + // Verify no cache operations were performed + await _cache.DidNotReceive().RemoveAsync(Arg.Any()); + } + + [Fact] + public async Task StoreAsync_PreservesAllGrantProperties() + { + // Arrange + var grant = new PersistedGrant + { + Key = "full-grant-key", + Type = "authorization_code", + SubjectId = "user-123", + SessionId = "session-456", + ClientId = "client-789", + Description = "Test grant", + CreationTime = DateTime.UtcNow.AddMinutes(-1), + Expiration = DateTime.UtcNow.AddMinutes(5), + ConsumedTime = null, + Data = "{\"test\":\"data\"}" + }; + + PersistedGrant? capturedGrant = null; + await _cache.SetAsync( + Arg.Any(), + Arg.Do(g => capturedGrant = g), + Arg.Any()); + + // Act + await _sut.StoreAsync(grant); + + // Assert + Assert.NotNull(capturedGrant); + Assert.Equal(grant.Key, capturedGrant.Key); + Assert.Equal(grant.Type, capturedGrant.Type); + Assert.Equal(grant.SubjectId, capturedGrant.SubjectId); + Assert.Equal(grant.SessionId, capturedGrant.SessionId); + Assert.Equal(grant.ClientId, capturedGrant.ClientId); + Assert.Equal(grant.Description, capturedGrant.Description); + Assert.Equal(grant.CreationTime, capturedGrant.CreationTime); + Assert.Equal(grant.Expiration, capturedGrant.Expiration); + Assert.Equal(grant.ConsumedTime, capturedGrant.ConsumedTime); + Assert.Equal(grant.Data, capturedGrant.Data); + } + + private static PersistedGrant CreateTestGrant(string key, DateTime? expiration) + { + return new PersistedGrant + { + Key = key, + Type = "authorization_code", + SubjectId = "test-subject", + ClientId = "test-client", + CreationTime = DateTime.UtcNow, + Expiration = expiration, + Data = "{\"test\":\"data\"}" + }; + } +} diff --git a/bitwarden_license/test/Scim.IntegrationTest/Controllers/v2/GroupsControllerTests.cs b/bitwarden_license/test/Scim.IntegrationTest/Controllers/v2/GroupsControllerTests.cs index 5f562a30c5..9ad231a63d 100644 --- a/bitwarden_license/test/Scim.IntegrationTest/Controllers/v2/GroupsControllerTests.cs +++ b/bitwarden_license/test/Scim.IntegrationTest/Controllers/v2/GroupsControllerTests.cs @@ -200,6 +200,38 @@ public class GroupsControllerTests : IClassFixture, IAsy AssertHelper.AssertPropertyEqual(expectedResponse, responseModel); } + [Fact] + public async Task GetList_SearchDisplayNameWithoutOptionalParameters_Success() + { + string filter = "displayName eq Test Group 2"; + int? itemsPerPage = null; + int? startIndex = null; + var expectedResponse = new ScimListResponseModel + { + ItemsPerPage = 50, //default value + TotalResults = 1, + StartIndex = 1, //default value + Resources = new List + { + new ScimGroupResponseModel + { + Id = ScimApplicationFactory.TestGroupId2, + DisplayName = "Test Group 2", + ExternalId = "B", + Schemas = new List { ScimConstants.Scim2SchemaGroup } + } + }, + Schemas = new List { ScimConstants.Scim2SchemaListResponse } + }; + + var context = await _factory.GroupsGetListAsync(ScimApplicationFactory.TestOrganizationId1, filter, itemsPerPage, startIndex); + + Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); + + var responseModel = JsonSerializer.Deserialize>(context.Response.Body, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + AssertHelper.AssertPropertyEqual(expectedResponse, responseModel); + } + [Fact] public async Task Post_Success() { diff --git a/bitwarden_license/test/Scim.IntegrationTest/Scim.IntegrationTest.csproj b/bitwarden_license/test/Scim.IntegrationTest/Scim.IntegrationTest.csproj index 4fc79f2025..d0d329397c 100644 --- a/bitwarden_license/test/Scim.IntegrationTest/Scim.IntegrationTest.csproj +++ b/bitwarden_license/test/Scim.IntegrationTest/Scim.IntegrationTest.csproj @@ -1,35 +1,37 @@ - - - - false - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - - - - - - true - PreserveNewest - Never - - - + + + + false + + $(WarningsNotAsErrors);CA1305 + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + true + PreserveNewest + Never + + + diff --git a/bitwarden_license/test/Scim.Test/Groups/GetGroupsListQueryTests.cs b/bitwarden_license/test/Scim.Test/Groups/GetGroupsListQueryTests.cs index 1599b6e390..b835e1fe6b 100644 --- a/bitwarden_license/test/Scim.Test/Groups/GetGroupsListQueryTests.cs +++ b/bitwarden_license/test/Scim.Test/Groups/GetGroupsListQueryTests.cs @@ -1,6 +1,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Repositories; using Bit.Scim.Groups; +using Bit.Scim.Models; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Bit.Test.Common.Helpers; @@ -24,7 +25,7 @@ public class GetGroupsListCommandTests .GetManyByOrganizationIdAsync(organizationId) .Returns(groups); - var result = await sutProvider.Sut.GetGroupsListAsync(organizationId, null, count, startIndex); + var result = await sutProvider.Sut.GetGroupsListAsync(organizationId, new GetGroupsQueryParamModel { Count = count, StartIndex = startIndex }); AssertHelper.AssertPropertyEqual(groups.Skip(startIndex - 1).Take(count).ToList(), result.groupList); AssertHelper.AssertPropertyEqual(groups.Count, result.totalResults); @@ -47,7 +48,7 @@ public class GetGroupsListCommandTests .GetManyByOrganizationIdAsync(organizationId) .Returns(groups); - var result = await sutProvider.Sut.GetGroupsListAsync(organizationId, filter, null, null); + var result = await sutProvider.Sut.GetGroupsListAsync(organizationId, new GetGroupsQueryParamModel { Filter = filter }); AssertHelper.AssertPropertyEqual(expectedGroupList, result.groupList); AssertHelper.AssertPropertyEqual(expectedTotalResults, result.totalResults); @@ -67,7 +68,7 @@ public class GetGroupsListCommandTests .GetManyByOrganizationIdAsync(organizationId) .Returns(groups); - var result = await sutProvider.Sut.GetGroupsListAsync(organizationId, filter, null, null); + var result = await sutProvider.Sut.GetGroupsListAsync(organizationId, new GetGroupsQueryParamModel { Filter = filter }); AssertHelper.AssertPropertyEqual(expectedGroupList, result.groupList); AssertHelper.AssertPropertyEqual(expectedTotalResults, result.totalResults); @@ -90,7 +91,7 @@ public class GetGroupsListCommandTests .GetManyByOrganizationIdAsync(organizationId) .Returns(groups); - var result = await sutProvider.Sut.GetGroupsListAsync(organizationId, filter, null, null); + var result = await sutProvider.Sut.GetGroupsListAsync(organizationId, new GetGroupsQueryParamModel { Filter = filter }); AssertHelper.AssertPropertyEqual(expectedGroupList, result.groupList); AssertHelper.AssertPropertyEqual(expectedTotalResults, result.totalResults); @@ -112,7 +113,7 @@ public class GetGroupsListCommandTests .GetManyByOrganizationIdAsync(organizationId) .Returns(groups); - var result = await sutProvider.Sut.GetGroupsListAsync(organizationId, filter, null, null); + var result = await sutProvider.Sut.GetGroupsListAsync(organizationId, new GetGroupsQueryParamModel { Filter = filter }); AssertHelper.AssertPropertyEqual(expectedGroupList, result.groupList); AssertHelper.AssertPropertyEqual(expectedTotalResults, result.totalResults); diff --git a/bitwarden_license/test/Scim.Test/Users/GetUsersListQueryTests.cs b/bitwarden_license/test/Scim.Test/Users/GetUsersListQueryTests.cs index 9352e5c202..7424b50c0d 100644 --- a/bitwarden_license/test/Scim.Test/Users/GetUsersListQueryTests.cs +++ b/bitwarden_license/test/Scim.Test/Users/GetUsersListQueryTests.cs @@ -1,5 +1,6 @@ using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Repositories; +using Bit.Scim.Models; using Bit.Scim.Users; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; diff --git a/bitwarden_license/test/Scim.Test/Users/PatchUserCommandTests.cs b/bitwarden_license/test/Scim.Test/Users/PatchUserCommandTests.cs index f391c93fe3..8b6c850c6f 100644 --- a/bitwarden_license/test/Scim.Test/Users/PatchUserCommandTests.cs +++ b/bitwarden_license/test/Scim.Test/Users/PatchUserCommandTests.cs @@ -1,6 +1,6 @@ using System.Text.Json; -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v1; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; diff --git a/bitwarden_license/test/Scim.Test/Users/PostUserCommandTests.cs b/bitwarden_license/test/Scim.Test/Users/PostUserCommandTests.cs index ac23e7ecc1..eb8804cac5 100644 --- a/bitwarden_license/test/Scim.Test/Users/PostUserCommandTests.cs +++ b/bitwarden_license/test/Scim.Test/Users/PostUserCommandTests.cs @@ -1,4 +1,5 @@ using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Services; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Business; @@ -36,7 +37,7 @@ public class PostUserCommandTests sutProvider.GetDependency().GetByIdAsync(organizationId).Returns(organization); - sutProvider.GetDependency().HasSecretsManagerStandalone(organization).Returns(true); + sutProvider.GetDependency().HasSecretsManagerStandalone(organization).Returns(true); sutProvider.GetDependency() .InviteUserAsync(organizationId, diff --git a/bitwarden_license/test/Sso.IntegrationTest/Controllers/AccountControllerTests.cs b/bitwarden_license/test/Sso.IntegrationTest/Controllers/AccountControllerTests.cs new file mode 100644 index 0000000000..7a1c9f9628 --- /dev/null +++ b/bitwarden_license/test/Sso.IntegrationTest/Controllers/AccountControllerTests.cs @@ -0,0 +1,952 @@ +using System.Net; +using Bit.Core; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Models.Data; +using Bit.Core.Auth.Repositories; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Sso.IntegrationTest.Utilities; +using Bit.Test.Common.AutoFixture.Attributes; +using Bitwarden.License.Test.Sso.IntegrationTest.Utilities; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc.Testing; +using NSubstitute; +using Xunit; +using AuthenticationSchemes = Bit.Core.AuthenticationSchemes; + +namespace Bit.Sso.IntegrationTest.Controllers; + +public class AccountControllerTests(SsoApplicationFactory factory) : IClassFixture +{ + private readonly SsoApplicationFactory _factory = factory; + + /* + * Test to verify the /Account/ExternalCallback endpoint exists and is reachable. + */ + [Fact] + public async Task ExternalCallback_EndpointExists_ReturnsExpectedStatusCode() + { + // Arrange + var client = _factory.CreateClient(); + + // Act - Verify the endpoint is accessible (even if it fails due to missing auth) + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - The endpoint should exist and return 500 (not 404) due to missing authentication + Assert.NotEqual(HttpStatusCode.NotFound, response.StatusCode); + } + + /* + * Test to verify calling /Account/ExternalCallback without an authentication cookie + * results in an error as expected. + */ + [Fact] + public async Task ExternalCallback_WithNoAuthenticationCookie_ReturnsError() + { + // Arrange + var client = _factory.CreateClient(); + + // Act - Call ExternalCallback without proper authentication setup + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because there's no external authentication cookie + Assert.False(response.IsSuccessStatusCode); + // The endpoint will throw an exception when authentication fails + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify behavior of /Account/ExternalCallback with PM24579 feature flag + */ + [Theory] + [BitAutoData(true)] + [BitAutoData(false)] + public async Task ExternalCallback_WithPM24579FeatureFlag_AndNoAuthCookie_ReturnsError + ( + bool featureFlagEnabled + ) + { + // Arrange + var client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + var featureService = Substitute.For(); + featureService.IsEnabled(FeatureFlagKeys.PM24579_PreventSsoOnExistingNonCompliantUsers).Returns(featureFlagEnabled); + services.AddSingleton(featureService); + }); + }).CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert + Assert.False(response.IsSuccessStatusCode); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify behavior of /Account/ExternalCallback simulating failed authentication. + */ + [Fact] + public async Task ExternalCallback_WithMockedAuthenticationService_FailedAuth_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithFailedAuthentication() + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert + Assert.False(response.IsSuccessStatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error when SSO config exists but is disabled. + */ + [Fact] + public async Task ExternalCallback_WithDisabledSsoConfig_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig(ssoConfig => ssoConfig!.Enabled = false) + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because SSO config is disabled + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("Organization not found or SSO configuration not enabled", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + [Fact] + public async Task ExternalCallback_FindUserFromExternalProviderAsync_OrganizationOrSsoConfigNotFound_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because user has invalid status + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("Organization not found or SSO configuration not enabled", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error when SSO config expects an ACR value + * but the authentication response has a missing or invalid ACR claim. + */ + [Fact] + public async Task ExternalCallback_WithExpectedAcrValue_AndInvalidAcr_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig(ssoConfig => ssoConfig!.SetData( + new SsoConfigurationData + { + ExpectedReturnAcrValue = "urn:expected:acr:value" + })) + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because ACR claim is missing or invalid + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("Expected authentication context class reference (acr) was not returned with the authentication response or is invalid", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error when the authentication response + * does not contain any recognizable user ID claim (sub, NameIdentifier, uid, upn, eppn). + */ + [Fact] + public async Task ExternalCallback_WithNoUserIdClaim_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .OmitProviderUserId() + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); ; + + // Assert - Should fail because no user ID claim was found + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("Unknown userid", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error when no email claim is found + * and the providerUserId cannot be used as a fallback email (doesn't contain @). + */ + [Fact] + public async Task ExternalCallback_WithNoEmailClaim_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithNullEmail() + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because no email claim was found + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("Cannot find email claim", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error when an existing user + * uses Key Connector but has no org user record (was removed from organization). + */ + [Fact] + public async Task ExternalCallback_WithExistingKeyConnectorUser_AndNoOrgUser_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithUser(user => + { + user.UsesKeyConnector = true; + }) + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because user uses Key Connector but has no org user record + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("You were removed from the organization managing single sign-on for your account", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error when an existing user + * uses Key Connector and has an org user record in the invited status. + */ + [Fact] + public async Task ExternalCallback_WithExistingKeyConnectorUser_AndInvitedOrgUser_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig(ssoConfig => { }) + .WithUser(user => + { + user.UsesKeyConnector = true; + }) + .WithOrganizationUser(orgUser => + { + orgUser.Status = OrganizationUserStatusType.Invited; + }) + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because user uses Key Connector but the Org user is in the invited status + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("You were removed from the organization managing single sign-on for your account", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error when an existing user + * (not using Key Connector) has no org user record - they were removed from the organization. + */ + [Fact] + public async Task ExternalCallback_WithExistingUser_AndNoOrgUser_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithUser() + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because user exists but has no org user record + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("You were removed from the organization managing single sign-on for your account. Contact the organization administrator", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error when an existing user + * has an org user record with Invited status - they must accept the invite first. + */ + [Fact] + public async Task ExternalCallback_WithExistingUser_AndInvitedOrgUserStatus_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithUser() + .WithOrganizationUser(orgUser => + { + orgUser.Status = OrganizationUserStatusType.Invited; + }) + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because user must accept invite before using SSO + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("you must first log in using your master password", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error when organization has no available seats + * and cannot auto-scale because it's a self-hosted instance. + */ + [Fact] + public async Task ExternalCallback_WithNoAvailableSeats_OnSelfHosted_ReturnsError() + { + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithOrganization(org => + { + org.Seats = 5; // Organization has seat limit + }) + .AsSelfHosted() + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because no seats available and cannot auto-scale on self-hosted + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("No seats available for organization", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error when organization has no available seats + * and auto-scaling fails (e.g., billing issue, max seats reached). + */ + [Fact] + public async Task ExternalCallback_WithNoAvailableSeats_AndAutoAddSeatsFails_ReturnsError() + { + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithOrganization(org => + { + org.Seats = 5; + org.MaxAutoscaleSeats = 5; + }) + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because auto-adding seats failed + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("No seats available for organization", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error when email cannot be found + * during new user provisioning (Scenario 2) after bypassing the first email check + * via manual linking path (userIdentifier is set). + */ + [Fact] + public async Task ExternalCallback_WithUserIdentifier_AndNoEmail_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithUserIdentifier("") + .WithNullEmail() + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because email cannot be found during new user provisioning + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("Cannot find email claim", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error when org user has an unknown/invalid status. + * This tests defensive code that handles future enum values or data corruption scenarios. + * We simulate this by casting an invalid integer to OrganizationUserStatusType. + */ + [Fact] + public async Task ExternalCallback_WithUnknownOrgUserStatus_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithUser() + .WithOrganizationUser(orgUser => + { + orgUser.Status = (OrganizationUserStatusType)99; // Invalid enum value - simulates future status or data corruption + }) + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because org user status is unknown/invalid + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("is in an unknown state", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + // Note: "User should be found ln 304" appears to be unreachable defensive code. + // CreateUserAndOrgUserConditionallyAsync always returns a non-null user or throws an exception, + // so possibleSsoLinkedUser cannot be null when the feature flag check executes. + + /* + * Test to verify /Account/ExternalCallback returns error when userIdentifier + * is malformed (doesn't contain comma separator for userId,token format). + * There is only a single test case here but in the future we may need to expand the + * tests to cover other invalid formats. + */ + [Theory] + [BitAutoData("No-Comas-Identifier")] + public async Task ExternalCallback_WithInvalidUserIdentifierFormat_ReturnsError( + string UserIdentifier + ) + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithUserIdentifier(UserIdentifier) + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because userIdentifier format is invalid + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("Invalid user identifier", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error when userIdentifier + * contains valid userId but invalid/mismatched token. + * + * NOTE: This test uses the substitute pattern instead of SsoTestDataBuilder because: + * - The userIdentifier in the auth result must contain a userId that matches a user in the system + * - User.SetNewId() always overwrites the Id (unlike Organization.SetNewId() which has a guard) + * - This means we cannot pre-set a User.Id before database insertion + * - The auth mock must be configured BEFORE accessing factory.Services (required by SubstituteService) + * - Therefore, we cannot coordinate the userId between the auth mock and the seeded user + * - Using substitutes allows us to control the exact userId and mock UserManager.VerifyUserTokenAsync + */ + [Fact] + public async Task ExternalCallback_WithUserIdentifier_AndInvalidToken_ReturnsError() + { + // Arrange + var organizationId = Guid.NewGuid(); + var providerUserId = Guid.NewGuid().ToString(); + var userId = Guid.NewGuid(); + var testEmail = "test_user@integration.test"; + var testName = "Test User"; + // Valid format but token won't verify + var userIdentifier = $"{userId},invalid-token"; + + var claimedUser = new User + { + Id = userId, + Email = testEmail, + Name = testName + }; + + var organization = new Organization + { + Id = organizationId, + Name = "Test Organization", + Enabled = true, + UseSso = true + }; + + var ssoConfig = new SsoConfig + { + OrganizationId = organizationId, + Enabled = true + }; + ssoConfig.SetData(new SsoConfigurationData()); + + var client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + var featureService = Substitute.For(); + featureService.IsEnabled(FeatureFlagKeys.PM24579_PreventSsoOnExistingNonCompliantUsers).Returns(true); + services.AddSingleton(featureService); + + // Mock organization repository + var orgRepo = Substitute.For(); + orgRepo.GetByIdAsync(organizationId).Returns(organization); + orgRepo.GetByIdentifierAsync(organizationId.ToString()).Returns(organization); + services.AddSingleton(orgRepo); + + // Mock SSO config repository + var ssoConfigRepo = Substitute.For(); + ssoConfigRepo.GetByOrganizationIdAsync(organizationId).Returns(ssoConfig); + services.AddSingleton(ssoConfigRepo); + + // Mock user repository - no existing user via SSO + var userRepo = Substitute.For(); + userRepo.GetBySsoUserAsync(providerUserId, organizationId).Returns((User?)null); + services.AddSingleton(userRepo); + + // Mock user service - returns user for manual linking lookup + var userService = Substitute.For(); + userService.GetUserByIdAsync(userId.ToString()).Returns(claimedUser); + services.AddSingleton(userService); + + // Mock UserManager to return false for token verification + var userManager = Substitute.For>( + Substitute.For>(), null, null, null, null, null, null, null, null); + userManager.VerifyUserTokenAsync( + claimedUser, + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(false); + services.AddSingleton(userManager); + + // Mock authentication service with userIdentifier that has valid format but invalid token + var authService = Substitute.For(); + authService.AuthenticateAsync( + Arg.Any(), + AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme) + .Returns(MockSuccessfulAuthResult.Build(organizationId, providerUserId, testEmail, testName, null, userIdentifier)); + services.AddSingleton(authService); + }); + }).CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because token verification failed + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("Supplied userId and token did not match", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error for revoked org user when PM24579 feature flag is enabled. + */ + [Fact] + public async Task ExternalCallback_WithRevokedOrgUser_WithPM24579FeatureFlagEnabled_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithUser() + .WithOrganizationUser(orgUser => + { + orgUser.Status = OrganizationUserStatusType.Revoked; + }) + .WithFeatureFlags(factoryService => + { + factoryService.SubstituteService(srv => + { + srv.IsEnabled(FeatureFlagKeys.PM24579_PreventSsoOnExistingNonCompliantUsers).Returns(true); + }); + }) + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because user state is invalid + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains( + $"Your access to organization {testData.Organization?.DisplayName()} has been revoked. Please contact your administrator for assistance.", + stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error for revoked org user when PM24579 feature flag is disabled. + */ + [Fact] + public async Task ExternalCallback_WithRevokedOrgUserStatus_WithPM24579FeatureFlagDisabled_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithUser() + .WithOrganizationUser(orgUser => + { + orgUser.Status = OrganizationUserStatusType.Revoked; + }) + .WithFeatureFlags(factoryService => + { + factoryService.SubstituteService(srv => + { + srv.IsEnabled(FeatureFlagKeys.PM24579_PreventSsoOnExistingNonCompliantUsers).Returns(false); + }); + }) + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because user has invalid status + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains( + $"Your access to organization {testData.Organization?.DisplayName()} has been revoked. Please contact your administrator for assistance.", + stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error for invited org user when PM24579 feature flag is disabled. + */ + [Fact] + public async Task ExternalCallback_WithInvitedOrgUserStatus_WithPM24579FeatureFlagDisabled_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithUser() + .WithOrganizationUser(orgUser => + { + orgUser.Status = OrganizationUserStatusType.Invited; + }) + .WithFeatureFlags(factoryService => + { + factoryService.SubstituteService(srv => + { + srv.IsEnabled(FeatureFlagKeys.PM24579_PreventSsoOnExistingNonCompliantUsers).Returns(false); + }); + }) + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because user has invalid status + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains( + $"To accept your invite to {testData.Organization?.DisplayName()}, you must first log in using your master password. Once your invite has been accepted, you will be able to log in using SSO.", + stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + + /* + * Test to verify /Account/ExternalCallback returns error when user is found via SSO + * but has no organization user record (with feature flag enabled). + */ + [Fact] + public async Task ExternalCallback_WithSsoUser_AndNoOrgUser_WithFeatureFlagEnabled_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithUser() + .WithSsoUser() + .WithFeatureFlags(factoryService => + { + factoryService.SubstituteService(srv => + { + srv.IsEnabled(FeatureFlagKeys.PM24579_PreventSsoOnExistingNonCompliantUsers).Returns(true); + }); + }) + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because org user cannot be found + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("Could not find organization user", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error when the provider scheme + * is not a valid GUID (SSOProviderIsNotAnOrgId). + * + * NOTE: This test uses the substitute pattern instead of SsoTestDataBuilder because: + * - Organization.Id is of type Guid and cannot be set to a non-GUID value + * - The auth mock scheme must be a non-GUID string to trigger this error path + * - This cannot be tested since ln 438 in AccountController.FindUserFromExternalProviderAsync throws a different exception + * before reaching the organization lookup exception. + */ + [Fact(Skip = "This test cannot be executed because the organization ID must be a GUID. See note in test summary.")] + public async Task ExternalCallback_WithInvalidProviderGuid_ReturnsError() + { + // Arrange + var invalidScheme = "not-a-valid-guid"; + var providerUserId = Guid.NewGuid().ToString(); + var testEmail = "test@example.com"; + var testName = "Test User"; + + var client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + // Mock authentication service with invalid (non-GUID) scheme + var authService = Substitute.For(); + authService.AuthenticateAsync( + Arg.Any(), + AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme) + .Returns(MockSuccessfulAuthResult.Build(invalidScheme, providerUserId, testEmail, testName)); + services.AddSingleton(authService); + }); + }).CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because provider is not a valid organization GUID + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("Organization not found from identifier.", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error when the organization ID + * in the auth result does not match any organization in the database. + * NOTE: This code path is unreachable because the SsoConfig must exist to proceed, but there is a circular dependency: + * - SsoConfig cannot exist without a valid Organization but the test is testing that an Organization cannot be found. + */ + [Fact(Skip = "This code path is unreachable because the SsoConfig must exist to proceed. But the SsoConfig cannot exist without a valid Organization.")] + public async Task ExternalCallback_WithNonExistentOrganization_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithNonExistentOrganizationInAuth() + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because organization cannot be found by the ID in auth result + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("Could not find organization", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * SUCCESS PATH: Test to verify /Account/ExternalCallback succeeds when an existing + * SSO-linked user logs in (user exists in SsoUser table). + */ + [Fact] + public async Task ExternalCallback_WithExistingSsoUser_ReturnsSuccess() + { + // Arrange - User with SSO link already exists + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithUser() + .WithOrganizationUser() + .WithSsoUser() + .BuildAsync(); + + var client = testData.Factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false // Prevent auto-redirects to capture initial response + }); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should succeed and redirect + Assert.True( + response.StatusCode == HttpStatusCode.Redirect, + $"Expected success/redirect but got {response.StatusCode}"); + + Assert.NotNull(response.Headers.Location); + } + + /* + * SUCCESS PATH: Test to verify /Account/ExternalCallback succeeds when JIT provisioning + * a new user (user doesn't exist, gets created automatically). + */ + [Fact] + public async Task ExternalCallback_WithJitProvisioning_ReturnsSuccess() + { + // Arrange - No user, no org user - JIT provisioning will create both + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .BuildAsync(); + + var client = testData.Factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false // Prevent auto-redirects to capture initial response + }); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should succeed and redirect + Assert.True( + response.StatusCode == HttpStatusCode.Redirect, + $"Expected success/redirect but got {response.StatusCode}"); + + Assert.NotNull(response.Headers.Location); + } + + /* + * SUCCESS PATH: Test to verify /Account/ExternalCallback succeeds when an existing user + * with a valid (Confirmed) organization user status logs in via SSO for the first time. + */ + [Fact] + public async Task ExternalCallback_WithExistingUserAndConfirmedOrgUser_ReturnsSuccess() + { + // Arrange - Existing user with confirmed org user status, no SSO link yet + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithUser() + .WithOrganizationUser(orgUser => + { + orgUser.Status = OrganizationUserStatusType.Confirmed; + }) + .BuildAsync(); + + var client = testData.Factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false // Prevent auto-redirects to capture initial response + }); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should succeed and redirect + Assert.True( + response.StatusCode == HttpStatusCode.Redirect, + $"Expected success/redirect but got {response.StatusCode}"); + + Assert.NotNull(response.Headers.Location); + } + + /* + * SUCCESS PATH: Test to verify /Account/ExternalCallback succeeds when an existing user + * with Accepted organization user status logs in via SSO. + */ + [Fact] + public async Task ExternalCallback_WithExistingUserAndAcceptedOrgUser_ReturnsSuccess() + { + // Arrange - Existing user with accepted org user status + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithUser() + .WithOrganizationUser(orgUser => + { + orgUser.Status = OrganizationUserStatusType.Accepted; + }) + .BuildAsync(); + + var client = testData.Factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false // Prevent auto-redirects to capture initial response + }); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should succeed and redirect + Assert.True( + response.StatusCode == HttpStatusCode.Redirect, + $"Expected success/redirect but got {response.StatusCode}"); + + Assert.NotNull(response.Headers.Location); + } + + /* + * SUCCESS PATH: Test to verify /Account/ExternalCallback returns a View with 200 status + * when the client is a native application (uses custom URI scheme like "bitwarden://callback"). + * Native clients get a different response for better UX - a 200 with redirect view instead of 302. + * See AccountController lines 371-378. + */ + [Fact] + public async Task ExternalCallback_WithNativeClient_ReturnsViewWith200Status() + { + // Arrange - Existing SSO user with native client context + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithUser() + .WithOrganizationUser() + .WithSsoUser() + .AsNativeClient() + .BuildAsync(); + + var client = testData.Factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false + }); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Native clients get 200 status with a redirect view instead of 302 + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + // The Location header should be empty for native clients (set in controller) + // and the response should contain the redirect view + var content = await response.Content.ReadAsStringAsync(); + Assert.NotEmpty(content); // View content should be present + } +} diff --git a/bitwarden_license/test/Sso.IntegrationTest/Properties/launchSettings.json b/bitwarden_license/test/Sso.IntegrationTest/Properties/launchSettings.json new file mode 100644 index 0000000000..63637a5304 --- /dev/null +++ b/bitwarden_license/test/Sso.IntegrationTest/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "Sso.IntegrationTest": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:59973;http://localhost:59974" + } + } +} \ No newline at end of file diff --git a/bitwarden_license/test/Sso.IntegrationTest/Sso.IntegrationTest.csproj b/bitwarden_license/test/Sso.IntegrationTest/Sso.IntegrationTest.csproj new file mode 100644 index 0000000000..42d0743d51 --- /dev/null +++ b/bitwarden_license/test/Sso.IntegrationTest/Sso.IntegrationTest.csproj @@ -0,0 +1,41 @@ + + + + net8.0 + enable + enable + + false + true + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + true + PreserveNewest + Never + + + + \ No newline at end of file diff --git a/bitwarden_license/test/Sso.IntegrationTest/Utilities/SsoApplicationFactory.cs b/bitwarden_license/test/Sso.IntegrationTest/Utilities/SsoApplicationFactory.cs new file mode 100644 index 0000000000..656c045284 --- /dev/null +++ b/bitwarden_license/test/Sso.IntegrationTest/Utilities/SsoApplicationFactory.cs @@ -0,0 +1,11 @@ +using Bit.IntegrationTestCommon.Factories; + +namespace Bit.Sso.IntegrationTest.Utilities; + +public class SsoApplicationFactory : WebApplicationFactoryBase +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + base.ConfigureWebHost(builder); + } +} diff --git a/bitwarden_license/test/Sso.IntegrationTest/Utilities/SsoTestDataBuilder.cs b/bitwarden_license/test/Sso.IntegrationTest/Utilities/SsoTestDataBuilder.cs new file mode 100644 index 0000000000..95f2387af2 --- /dev/null +++ b/bitwarden_license/test/Sso.IntegrationTest/Utilities/SsoTestDataBuilder.cs @@ -0,0 +1,327 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Models.Data; +using Bit.Core.Auth.Repositories; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Bit.Core.Settings; +using Bitwarden.License.Test.Sso.IntegrationTest.Utilities; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Services; +using Microsoft.AspNetCore.Authentication; +using NSubstitute; +using AuthenticationSchemes = Bit.Core.AuthenticationSchemes; + +namespace Bit.Sso.IntegrationTest.Utilities; + +/// +/// Contains the factory and all entities created by for use in integration tests. +/// +public record SsoTestData( + SsoApplicationFactory Factory, + Organization? Organization, + User? User, + OrganizationUser? OrganizationUser, + SsoConfig? SsoConfig, + SsoUser? SsoUser); + +/// +/// Builder for creating SSO test data with seeded database entities. +/// +public class SsoTestDataBuilder +{ + /// + /// This UserIdentifier is a mock for the UserIdentifier we get from the External Identity Provider. + /// + private string? _userIdentifier; + private Action? _organizationConfig; + private Action? _userConfig; + private Action? _orgUserConfig; + private Action? _ssoConfigConfig; + private Action? _ssoUserConfig; + private Action? _featureFlagConfig; + + private bool _includeUser = false; + private bool _includeSsoUser = false; + private bool _includeOrganizationUser = false; + private bool _includeSsoConfig = false; + private bool _successfulAuth = true; + private bool _withNullEmail = false; + private bool _isSelfHosted = false; + private bool _includeProviderUserId = true; + private bool _useNonExistentOrgInAuth = false; + private bool _isNativeClient = false; + + public SsoTestDataBuilder WithOrganization(Action configure) + { + _organizationConfig = configure; + return this; + } + + public SsoTestDataBuilder WithUser(Action? configure = null) + { + _includeUser = true; + _userConfig = configure; + return this; + } + + public SsoTestDataBuilder WithOrganizationUser(Action? configure = null) + { + _includeOrganizationUser = true; + _orgUserConfig = configure; + return this; + } + + public SsoTestDataBuilder WithSsoConfig(Action? configure = null) + { + _includeSsoConfig = true; + _ssoConfigConfig = configure; + return this; + } + + public SsoTestDataBuilder WithSsoUser(Action? configure = null) + { + _includeSsoUser = true; + _ssoUserConfig = configure; + return this; + } + + public SsoTestDataBuilder WithFeatureFlags(Action configure) + { + _featureFlagConfig = configure; + return this; + } + + public SsoTestDataBuilder WithFailedAuthentication() + { + _successfulAuth = false; + return this; + } + + public SsoTestDataBuilder WithNullEmail() + { + _withNullEmail = true; + return this; + } + + public SsoTestDataBuilder WithUserIdentifier(string userIdentifier) + { + _userIdentifier = userIdentifier; + return this; + } + + public SsoTestDataBuilder OmitProviderUserId() + { + _includeProviderUserId = false; + return this; + } + + public SsoTestDataBuilder AsSelfHosted() + { + _isSelfHosted = true; + return this; + } + + /// + /// Causes the auth result to use a different (non-existent) organization ID than what is seeded + /// in the database. This simulates the "organization not found" scenario. + /// + public SsoTestDataBuilder WithNonExistentOrganizationInAuth() + { + _useNonExistentOrgInAuth = true; + return this; + } + + /// + /// Configures the test to simulate a native client (non-browser) OIDC flow. + /// Native clients use custom URI schemes (e.g., "bitwarden://callback") instead of http/https. + /// This causes ExternalCallback to return a View with 200 status instead of a redirect. + /// + public SsoTestDataBuilder AsNativeClient() + { + _isNativeClient = true; + return this; + } + + public async Task BuildAsync() + { + // Create factory + var factory = new SsoApplicationFactory(); + + // Pre-generate IDs and values needed for auth mock (before accessing Services) + var organizationId = Guid.NewGuid(); + // Use a different org ID in auth if testing "organization not found" scenario + var authOrganizationId = _useNonExistentOrgInAuth ? Guid.NewGuid() : organizationId; + var providerUserId = _includeProviderUserId ? Guid.NewGuid().ToString() : ""; + var userEmail = _withNullEmail ? null : $"user_{Guid.NewGuid()}@test.com"; + var userName = "TestUser"; + + // 1. Configure mocked authentication service BEFORE accessing Services + factory.SubstituteService(authService => + { + if (_successfulAuth) + { + authService.AuthenticateAsync( + Arg.Any(), + AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme) + .Returns(MockSuccessfulAuthResult.Build( + authOrganizationId, + providerUserId, + userEmail, + userName, + acrValue: null, + _userIdentifier)); + } + else + { + authService.AuthenticateAsync( + Arg.Any(), + AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme) + .Returns(AuthenticateResult.Fail("External authentication error")); + } + }); + + // 1.a Configure GlobalSettings for Self-Hosted and seat limit + factory.SubstituteService(globalSettings => + { + globalSettings.SelfHosted.Returns(_isSelfHosted); + }); + + // 1.b configure setting feature flags + _featureFlagConfig?.Invoke(factory); + + // 1.c Configure IIdentityServerInteractionService for native client flow + if (_isNativeClient) + { + factory.SubstituteService(interaction => + { + // Native clients have redirect URIs that don't start with http/https + // e.g., "bitwarden://callback" or "com.bitwarden.app://callback" + var authorizationRequest = new AuthorizationRequest + { + RedirectUri = "bitwarden://sso-callback" + }; + interaction.GetAuthorizationContextAsync(Arg.Any()) + .Returns(authorizationRequest); + }); + } + + if (!_successfulAuth) + { + return new SsoTestData(factory, null!, null!, null!, null!, null!); + } + + // 2. Create Organization with defaults (using pre-generated ID) + var organization = new Organization + { + Id = organizationId, + Name = "Test Organization", + BillingEmail = "billing@test.com", + Plan = "Enterprise", + Enabled = true, + UseSso = true + }; + _organizationConfig?.Invoke(organization); + + var orgRepo = factory.Services.GetRequiredService(); + organization = await orgRepo.CreateAsync(organization); + + // 3. Create User with defaults (using pre-generated values) + User? user = null; + if (_includeUser) + { + user = new User + { + Email = userEmail ?? $"email_{Guid.NewGuid()}@test.dev", + Name = userName, + ApiKey = Guid.NewGuid().ToString(), + SecurityStamp = Guid.NewGuid().ToString() + }; + _userConfig?.Invoke(user); + + var userRepo = factory.Services.GetRequiredService(); + user = await userRepo.CreateAsync(user); + } + + // 4. Create OrganizationUser linking them + OrganizationUser? orgUser = null; + if (_includeOrganizationUser) + { + orgUser = new OrganizationUser + { + OrganizationId = organization.Id, + UserId = user!.Id, + Status = OrganizationUserStatusType.Confirmed, + Type = OrganizationUserType.User + }; + _orgUserConfig?.Invoke(orgUser); + + var orgUserRepo = factory.Services.GetRequiredService(); + orgUser = await orgUserRepo.CreateAsync(orgUser); + } + + // 4.a Create many OrganizationUser to test seat count logic + if (organization.Seats > 1) + { + var orgUserRepo = factory.Services.GetRequiredService(); + var userRepo = factory.Services.GetRequiredService(); + var additionalOrgUsers = new List(); + for (var i = 1; i <= organization.Seats; i++) + { + var additionalUser = new User + { + Email = $"additional_user_{i}_{Guid.NewGuid()}@test.dev", + Name = $"AdditionalUser{i}", + ApiKey = Guid.NewGuid().ToString(), + SecurityStamp = Guid.NewGuid().ToString() + }; + var createdAdditionalUser = await userRepo.CreateAsync(additionalUser); + + var additionalOrgUser = new OrganizationUser + { + OrganizationId = organization.Id, + UserId = createdAdditionalUser.Id, + Status = OrganizationUserStatusType.Confirmed, + Type = OrganizationUserType.User + }; + additionalOrgUsers.Add(additionalOrgUser); + } + await orgUserRepo.CreateManyAsync(additionalOrgUsers); + } + + // 5. Create SsoConfig, if ssoConfigConfig is not null + SsoConfig? ssoConfig = null; + if (_includeSsoConfig) + { + ssoConfig = new SsoConfig + { + OrganizationId = authOrganizationId, + Enabled = true + }; + ssoConfig.SetData(new SsoConfigurationData()); + _ssoConfigConfig?.Invoke(ssoConfig); + + var ssoConfigRepo = factory.Services.GetRequiredService(); + ssoConfig = await ssoConfigRepo.CreateAsync(ssoConfig); + } + + // 6. Optionally create SsoUser (using pre-generated providerUserId as ExternalId) + SsoUser? ssoUser = null; + if (_includeSsoUser) + { + ssoUser = new SsoUser + { + OrganizationId = organization.Id, + UserId = user!.Id, + ExternalId = providerUserId + }; + _ssoUserConfig?.Invoke(ssoUser); + + var ssoUserRepo = factory.Services.GetRequiredService(); + ssoUser = await ssoUserRepo.CreateAsync(ssoUser); + } + + return new SsoTestData(factory, organization, user, orgUser, ssoConfig, ssoUser); + } +} diff --git a/bitwarden_license/test/Sso.IntegrationTest/Utilities/SuccessfulAuthResult.cs b/bitwarden_license/test/Sso.IntegrationTest/Utilities/SuccessfulAuthResult.cs new file mode 100644 index 0000000000..72f5738ad9 --- /dev/null +++ b/bitwarden_license/test/Sso.IntegrationTest/Utilities/SuccessfulAuthResult.cs @@ -0,0 +1,88 @@ +using System.Security.Claims; +using Bit.Core; +using Duende.IdentityModel; +using Microsoft.AspNetCore.Authentication; + +namespace Bitwarden.License.Test.Sso.IntegrationTest.Utilities; + +/// +/// Creates a mock for use in tests requiring a valid external authentication result. +/// +internal static class MockSuccessfulAuthResult +{ + /// + /// Since this tests the external Authentication flow, only the OrganizationId is strictly required. + /// However, some tests may require additional claims to be present, so they can be optionally added. + /// + /// + /// + /// + /// + /// + /// + /// + public static AuthenticateResult Build( + Guid organizationId, + string? providerUserId, + string? email, + string? name = null, + string? acrValue = null, + string? userIdentifier = null) + { + return Build(organizationId.ToString(), providerUserId, email, name, acrValue, userIdentifier); + } + + /// + /// Overload that accepts a custom scheme string. Useful for testing invalid provider scenarios + /// where the scheme is not a valid GUID. + /// + public static AuthenticateResult Build( + string scheme, + string? providerUserId, + string? email, + string? name = null, + string? acrValue = null, + string? userIdentifier = null) + { + var claims = new List(); + + if (!string.IsNullOrEmpty(email)) + { + claims.Add(new Claim(JwtClaimTypes.Email, email)); + } + + if (!string.IsNullOrEmpty(providerUserId)) + { + claims.Add(new Claim(JwtClaimTypes.Subject, providerUserId)); + } + + if (!string.IsNullOrEmpty(name)) + { + claims.Add(new Claim(JwtClaimTypes.Name, name)); + } + + if (!string.IsNullOrEmpty(acrValue)) + { + claims.Add(new Claim(JwtClaimTypes.AuthenticationContextClassReference, acrValue)); + } + + var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "External")); + var properties = new AuthenticationProperties + { + Items = + { + ["scheme"] = scheme, + ["return_url"] = "~/", + ["state"] = "test-state", + ["user_identifier"] = userIdentifier ?? string.Empty + } + }; + + var ticket = new AuthenticationTicket( + principal, + properties, + AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme); + + return AuthenticateResult.Success(ticket); + } +} diff --git a/dev/docker-compose.yml b/dev/docker-compose.yml index 3554306ddb..c82da051b4 100644 --- a/dev/docker-compose.yml +++ b/dev/docker-compose.yml @@ -99,7 +99,7 @@ services: - idp rabbitmq: - image: rabbitmq:4.1.3-management + image: rabbitmq:4.2.0-management ports: - "5672:5672" - "15672:15672" diff --git a/dev/generate_openapi_files.ps1 b/dev/generate_openapi_files.ps1 index 9eca7dc734..011319b3a3 100644 --- a/dev/generate_openapi_files.ps1 +++ b/dev/generate_openapi_files.ps1 @@ -18,11 +18,11 @@ if ($LASTEXITCODE -ne 0) { # Api internal & public Set-Location "../../src/Api" dotnet build -dotnet swagger tofile --output "../../api.json" --host "https://api.bitwarden.com" "./bin/Debug/net8.0/Api.dll" "internal" +dotnet swagger tofile --output "../../api.json" "./bin/Debug/net8.0/Api.dll" "internal" if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } -dotnet swagger tofile --output "../../api.public.json" --host "https://api.bitwarden.com" "./bin/Debug/net8.0/Api.dll" "public" +dotnet swagger tofile --output "../../api.public.json" "./bin/Debug/net8.0/Api.dll" "public" if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/dev/secrets.json.example b/dev/secrets.json.example index c6a16846e9..7bf753e938 100644 --- a/dev/secrets.json.example +++ b/dev/secrets.json.example @@ -33,8 +33,20 @@ "id": "", "key": "" }, + "events": { + "connectionString": "", + "queueName": "event" + }, "licenseDirectory": "", "enableNewDeviceVerification": true, - "enableEmailVerification": true + "enableEmailVerification": true, + "communication": { + "bootstrap": "none", + "ssoCookieVendor": { + "idpLoginUrl": "", + "cookieName": "", + "cookieDomain": "" + } + } } } diff --git a/dev/setup_secrets.ps1 b/dev/setup_secrets.ps1 old mode 100644 new mode 100755 index 96dff04632..5013ca8bac --- a/dev/setup_secrets.ps1 +++ b/dev/setup_secrets.ps1 @@ -2,7 +2,7 @@ # Helper script for applying the same user secrets to each project param ( [switch]$clear, - [Parameter(ValueFromRemainingArguments = $true, Position=1)] + [Parameter(ValueFromRemainingArguments = $true, Position = 1)] $cmdArgs ) @@ -16,17 +16,18 @@ if ($clear -eq $true) { } $projects = @{ - Admin = "../src/Admin" - Api = "../src/Api" - Billing = "../src/Billing" - Events = "../src/Events" - EventsProcessor = "../src/EventsProcessor" - Icons = "../src/Icons" - Identity = "../src/Identity" - Notifications = "../src/Notifications" - Sso = "../bitwarden_license/src/Sso" - Scim = "../bitwarden_license/src/Scim" + Admin = "../src/Admin" + Api = "../src/Api" + Billing = "../src/Billing" + Events = "../src/Events" + EventsProcessor = "../src/EventsProcessor" + Icons = "../src/Icons" + Identity = "../src/Identity" + Notifications = "../src/Notifications" + Sso = "../bitwarden_license/src/Sso" + Scim = "../bitwarden_license/src/Scim" IntegrationTests = "../test/Infrastructure.IntegrationTest" + SeederApi = "../util/SeederApi" } foreach ($key in $projects.keys) { diff --git a/dev/verify_migrations.ps1 b/dev/verify_migrations.ps1 new file mode 100644 index 0000000000..ce1754e684 --- /dev/null +++ b/dev/verify_migrations.ps1 @@ -0,0 +1,352 @@ +#!/usr/bin/env pwsh + +<# +.SYNOPSIS + Validates that new database migration files follow naming conventions and chronological order. + +.DESCRIPTION + This script validates migration files to ensure: + + For SQL migrations in util/Migrator/DbScripts/: + 1. New migrations follow the naming format: YYYY-MM-DD_NN_Description.sql + 2. New migrations are chronologically ordered (filename sorts after existing migrations) + 3. Dates use leading zeros (e.g., 2025-01-05, not 2025-1-5) + 4. A 2-digit sequence number is included (e.g., _00, _01) + + For Entity Framework migrations in util/MySqlMigrations, util/PostgresMigrations, util/SqliteMigrations: + 1. New migrations follow the naming format: YYYYMMDDHHMMSS_Description.cs + 2. Each migration has both .cs and .Designer.cs files + 3. New migrations are chronologically ordered (timestamp sorts after existing migrations) + +.PARAMETER BaseRef + The base git reference to compare against (e.g., 'main', 'HEAD~1') + +.PARAMETER CurrentRef + The current git reference (defaults to 'HEAD') + +.EXAMPLE + # For pull requests - compare against main branch + .\verify_migrations.ps1 -BaseRef main + +.EXAMPLE + # For pushes - compare against previous commit + .\verify_migrations.ps1 -BaseRef HEAD~1 +#> + +param( + [Parameter(Mandatory = $true)] + [string]$BaseRef, + + [Parameter(Mandatory = $false)] + [string]$CurrentRef = "HEAD" +) + +# Use invariant culture for consistent string comparison +[System.Threading.Thread]::CurrentThread.CurrentCulture = [System.Globalization.CultureInfo]::InvariantCulture + +$migrationPath = "util/Migrator/DbScripts" + +# Get list of migrations from base reference +try { + $baseMigrations = git ls-tree -r --name-only $BaseRef -- "$migrationPath/" 2>$null | Where-Object { $_ -like "*.sql" } | Sort-Object + if ($LASTEXITCODE -ne 0) { + Write-Host "Warning: Could not retrieve migrations from base reference '$BaseRef'" + $baseMigrations = @() + } +} +catch { + Write-Host "Warning: Could not retrieve migrations from base reference '$BaseRef'" + $baseMigrations = @() +} + +# Get list of migrations from current reference +$currentMigrations = git ls-tree -r --name-only $CurrentRef -- "$migrationPath/" | Where-Object { $_ -like "*.sql" } | Sort-Object + +# Find added migrations +$addedMigrations = $currentMigrations | Where-Object { $_ -notin $baseMigrations } + +$sqlValidationFailed = $false + +if ($addedMigrations.Count -eq 0) { + Write-Host "No new SQL migration files added." + Write-Host "" +} +else { + Write-Host "New SQL migration files detected:" + $addedMigrations | ForEach-Object { Write-Host " $_" } + Write-Host "" + + # Get the last migration from base reference + if ($baseMigrations.Count -eq 0) { + Write-Host "No previous SQL migrations found (initial commit?). Skipping chronological validation." + Write-Host "" + } + else { + $lastBaseMigration = Split-Path -Leaf ($baseMigrations | Select-Object -Last 1) + Write-Host "Last SQL migration in base reference: $lastBaseMigration" + Write-Host "" + + # Required format regex: YYYY-MM-DD_NN_Description.sql + $formatRegex = '^[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{2}_.+\.sql$' + + foreach ($migration in $addedMigrations) { + $migrationName = Split-Path -Leaf $migration + + # Validate NEW migration filename format + if ($migrationName -notmatch $formatRegex) { + Write-Host "ERROR: Migration '$migrationName' does not match required format" + Write-Host "Required format: YYYY-MM-DD_NN_Description.sql" + Write-Host " - YYYY: 4-digit year" + Write-Host " - MM: 2-digit month with leading zero (01-12)" + Write-Host " - DD: 2-digit day with leading zero (01-31)" + Write-Host " - NN: 2-digit sequence number (00, 01, 02, etc.)" + Write-Host "Example: 2025-01-15_00_MyMigration.sql" + $sqlValidationFailed = $true + continue + } + + # Compare migration name with last base migration (using ordinal string comparison) + if ([string]::CompareOrdinal($migrationName, $lastBaseMigration) -lt 0) { + Write-Host "ERROR: New migration '$migrationName' is not chronologically after '$lastBaseMigration'" + $sqlValidationFailed = $true + } + else { + Write-Host "OK: '$migrationName' is chronologically after '$lastBaseMigration'" + } + } + + Write-Host "" + } + + if ($sqlValidationFailed) { + Write-Host "FAILED: One or more SQL migrations are incorrectly named or not in chronological order" + Write-Host "" + Write-Host "All new SQL migration files must:" + Write-Host " 1. Follow the naming format: YYYY-MM-DD_NN_Description.sql" + Write-Host " 2. Use leading zeros in dates (e.g., 2025-01-05, not 2025-1-5)" + Write-Host " 3. Include a 2-digit sequence number (e.g., _00, _01)" + Write-Host " 4. Have a filename that sorts after the last migration in base" + Write-Host "" + Write-Host "To fix this issue:" + Write-Host " 1. Locate your migration file(s) in util/Migrator/DbScripts/" + Write-Host " 2. Rename to follow format: YYYY-MM-DD_NN_Description.sql" + Write-Host " 3. Ensure the date is after $lastBaseMigration" + Write-Host "" + Write-Host "Example: 2025-01-15_00_AddNewFeature.sql" + } + else { + Write-Host "SUCCESS: All new SQL migrations are correctly named and in chronological order" + } + + Write-Host "" +} + +# =========================================================================================== +# Validate Entity Framework Migrations +# =========================================================================================== + +Write-Host "===================================================================" +Write-Host "Validating Entity Framework Migrations" +Write-Host "===================================================================" +Write-Host "" + +$efMigrationPaths = @( + @{ Path = "util/MySqlMigrations/Migrations"; Name = "MySQL" }, + @{ Path = "util/PostgresMigrations/Migrations"; Name = "Postgres" }, + @{ Path = "util/SqliteMigrations/Migrations"; Name = "SQLite" } +) + +$efValidationFailed = $false + +foreach ($migrationPathInfo in $efMigrationPaths) { + $efPath = $migrationPathInfo.Path + $dbName = $migrationPathInfo.Name + + Write-Host "-------------------------------------------------------------------" + Write-Host "Checking $dbName EF migrations in $efPath" + Write-Host "-------------------------------------------------------------------" + Write-Host "" + + # Get list of migrations from base reference + try { + $baseMigrations = git ls-tree -r --name-only $BaseRef -- "$efPath/" 2>$null | Where-Object { $_ -like "*.cs" -and $_ -notlike "*DatabaseContextModelSnapshot.cs" } | Sort-Object + if ($LASTEXITCODE -ne 0) { + Write-Host "Warning: Could not retrieve $dbName migrations from base reference '$BaseRef'" + $baseMigrations = @() + } + } + catch { + Write-Host "Warning: Could not retrieve $dbName migrations from base reference '$BaseRef'" + $baseMigrations = @() + } + + # Get list of migrations from current reference + $currentMigrations = git ls-tree -r --name-only $CurrentRef -- "$efPath/" | Where-Object { $_ -like "*.cs" -and $_ -notlike "*DatabaseContextModelSnapshot.cs" } | Sort-Object + + # Find added migrations + $addedMigrations = $currentMigrations | Where-Object { $_ -notin $baseMigrations } + + if ($addedMigrations.Count -eq 0) { + Write-Host "No new $dbName EF migration files added." + Write-Host "" + continue + } + + Write-Host "New $dbName EF migration files detected:" + $addedMigrations | ForEach-Object { Write-Host " $_" } + Write-Host "" + + # Get the last migration from base reference + if ($baseMigrations.Count -eq 0) { + Write-Host "No previous $dbName migrations found. Skipping chronological validation." + Write-Host "" + } + else { + $lastBaseMigration = Split-Path -Leaf ($baseMigrations | Select-Object -Last 1) + Write-Host "Last $dbName migration in base reference: $lastBaseMigration" + Write-Host "" + } + + # Required format regex: YYYYMMDDHHMMSS_Description.cs or YYYYMMDDHHMMSS_Description.Designer.cs + $efFormatRegex = '^[0-9]{14}_.+\.cs$' + + # Group migrations by base name (without .Designer.cs suffix) + $migrationGroups = @{} + $unmatchedFiles = @() + + foreach ($migration in $addedMigrations) { + $migrationName = Split-Path -Leaf $migration + + # Extract base name (remove .Designer.cs or .cs) + if ($migrationName -match '^([0-9]{14}_.+?)(?:\.Designer)?\.cs$') { + $baseName = $matches[1] + if (-not $migrationGroups.ContainsKey($baseName)) { + $migrationGroups[$baseName] = @() + } + $migrationGroups[$baseName] += $migrationName + } + else { + # Track files that don't match the expected pattern + $unmatchedFiles += $migrationName + } + } + + # Flag any files that don't match the expected pattern + if ($unmatchedFiles.Count -gt 0) { + Write-Host "ERROR: The following migration files do not match the required format:" + foreach ($unmatchedFile in $unmatchedFiles) { + Write-Host " - $unmatchedFile" + } + Write-Host "" + Write-Host "Required format: YYYYMMDDHHMMSS_Description.cs or YYYYMMDDHHMMSS_Description.Designer.cs" + Write-Host " - YYYYMMDDHHMMSS: 14-digit timestamp (Year, Month, Day, Hour, Minute, Second)" + Write-Host " - Description: Descriptive name using PascalCase" + Write-Host "Example: 20250115120000_AddNewFeature.cs and 20250115120000_AddNewFeature.Designer.cs" + Write-Host "" + $efValidationFailed = $true + } + + foreach ($baseName in $migrationGroups.Keys | Sort-Object) { + $files = $migrationGroups[$baseName] + + # Validate format + $mainFile = "$baseName.cs" + $designerFile = "$baseName.Designer.cs" + + if ($mainFile -notmatch $efFormatRegex) { + Write-Host "ERROR: Migration '$mainFile' does not match required format" + Write-Host "Required format: YYYYMMDDHHMMSS_Description.cs" + Write-Host " - YYYYMMDDHHMMSS: 14-digit timestamp (Year, Month, Day, Hour, Minute, Second)" + Write-Host "Example: 20250115120000_AddNewFeature.cs" + $efValidationFailed = $true + continue + } + + # Check that both .cs and .Designer.cs files exist + $hasCsFile = $files -contains $mainFile + $hasDesignerFile = $files -contains $designerFile + + if (-not $hasCsFile) { + Write-Host "ERROR: Missing main migration file: $mainFile" + $efValidationFailed = $true + } + + if (-not $hasDesignerFile) { + Write-Host "ERROR: Missing designer file: $designerFile" + Write-Host "Each EF migration must have both a .cs and .Designer.cs file" + $efValidationFailed = $true + } + + if (-not $hasCsFile -or -not $hasDesignerFile) { + continue + } + + # Compare migration timestamp with last base migration (using ordinal string comparison) + if ($baseMigrations.Count -gt 0) { + if ([string]::CompareOrdinal($mainFile, $lastBaseMigration) -lt 0) { + Write-Host "ERROR: New migration '$mainFile' is not chronologically after '$lastBaseMigration'" + $efValidationFailed = $true + } + else { + Write-Host "OK: '$mainFile' is chronologically after '$lastBaseMigration'" + } + } + else { + Write-Host "OK: '$mainFile' (no previous migrations to compare)" + } + } + + Write-Host "" +} + +if ($efValidationFailed) { + Write-Host "FAILED: One or more EF migrations are incorrectly named or not in chronological order" + Write-Host "" + Write-Host "All new EF migration files must:" + Write-Host " 1. Follow the naming format: YYYYMMDDHHMMSS_Description.cs" + Write-Host " 2. Include both .cs and .Designer.cs files" + Write-Host " 3. Have a timestamp that sorts after the last migration in base" + Write-Host "" + Write-Host "To fix this issue:" + Write-Host " 1. Locate your migration file(s) in the respective Migrations directory" + Write-Host " 2. Ensure both .cs and .Designer.cs files exist" + Write-Host " 3. Rename to follow format: YYYYMMDDHHMMSS_Description.cs" + Write-Host " 4. Ensure the timestamp is after the last migration" + Write-Host "" + Write-Host "Example: 20250115120000_AddNewFeature.cs and 20250115120000_AddNewFeature.Designer.cs" +} +else { + Write-Host "SUCCESS: All new EF migrations are correctly named and in chronological order" +} + +Write-Host "" +Write-Host "===================================================================" +Write-Host "Validation Summary" +Write-Host "===================================================================" + +if ($sqlValidationFailed -or $efValidationFailed) { + if ($sqlValidationFailed) { + Write-Host "❌ SQL migrations validation FAILED" + } + else { + Write-Host "✓ SQL migrations validation PASSED" + } + + if ($efValidationFailed) { + Write-Host "❌ EF migrations validation FAILED" + } + else { + Write-Host "✓ EF migrations validation PASSED" + } + + Write-Host "" + Write-Host "OVERALL RESULT: FAILED" + exit 1 +} +else { + Write-Host "✓ SQL migrations validation PASSED" + Write-Host "✓ EF migrations validation PASSED" + Write-Host "" + Write-Host "OVERALL RESULT: SUCCESS" + exit 0 +} diff --git a/global.json b/global.json index d25197db39..4cbe3f083a 100644 --- a/global.json +++ b/global.json @@ -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" } } diff --git a/src/Admin/Admin.csproj b/src/Admin/Admin.csproj index cd30e841b4..b815ddea82 100644 --- a/src/Admin/Admin.csproj +++ b/src/Admin/Admin.csproj @@ -2,6 +2,8 @@ bitwarden-Admin + + $(WarningsNotAsErrors);CA1304;CA1305 diff --git a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs index 0d992cb96a..1dbab08ca6 100644 --- a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs @@ -14,8 +14,10 @@ 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; using Bit.Core.Enums; using Bit.Core.Models.OrganizationConnectionConfigs; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; @@ -41,7 +43,7 @@ public class OrganizationsController : Controller private readonly ICollectionRepository _collectionRepository; private readonly IGroupRepository _groupRepository; private readonly IPolicyRepository _policyRepository; - private readonly IPaymentService _paymentService; + private readonly IStripePaymentService _paymentService; private readonly IApplicationCacheService _applicationCacheService; private readonly GlobalSettings _globalSettings; private readonly IProviderRepository _providerRepository; @@ -56,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, @@ -66,7 +69,7 @@ public class OrganizationsController : Controller ICollectionRepository collectionRepository, IGroupRepository groupRepository, IPolicyRepository policyRepository, - IPaymentService paymentService, + IStripePaymentService paymentService, IApplicationCacheService applicationCacheService, GlobalSettings globalSettings, IProviderRepository providerRepository, @@ -80,7 +83,8 @@ public class OrganizationsController : Controller IProviderBillingService providerBillingService, IOrganizationInitiateDeleteCommand organizationInitiateDeleteCommand, IPricingClient pricingClient, - IResendOrganizationInviteCommand resendOrganizationInviteCommand) + IResendOrganizationInviteCommand resendOrganizationInviteCommand, + IOrganizationBillingService organizationBillingService) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -105,6 +109,7 @@ public class OrganizationsController : Controller _organizationInitiateDeleteCommand = organizationInitiateDeleteCommand; _pricingClient = pricingClient; _resendOrganizationInviteCommand = resendOrganizationInviteCommand; + _organizationBillingService = organizationBillingService; } [RequirePermission(Permission.Org_List_View)] @@ -241,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 @@ -286,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 }); } @@ -473,6 +496,8 @@ public class OrganizationsController : Controller organization.UseOrganizationDomains = model.UseOrganizationDomains; organization.UseAdminSponsoredFamilies = model.UseAdminSponsoredFamilies; organization.UseAutomaticUserConfirmation = model.UseAutomaticUserConfirmation; + organization.UseDisableSmAdsForUsers = model.UseDisableSmAdsForUsers; + organization.UsePhishingBlocker = model.UsePhishingBlocker; //secrets organization.SmSeats = model.SmSeats; diff --git a/src/Admin/AdminConsole/Controllers/ProvidersController.cs b/src/Admin/AdminConsole/Controllers/ProvidersController.cs index 9344179a77..d9135e1d1c 100644 --- a/src/Admin/AdminConsole/Controllers/ProvidersController.cs +++ b/src/Admin/AdminConsole/Controllers/ProvidersController.cs @@ -56,6 +56,7 @@ public class ProvidersController : Controller private readonly IStripeAdapter _stripeAdapter; private readonly IAccessControlService _accessControlService; private readonly ISubscriberService _subscriberService; + private readonly ILogger _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 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 }); @@ -339,11 +361,11 @@ public class ProvidersController : Controller ]); await _providerBillingService.UpdateSeatMinimums(updateMspSeatMinimumsCommand); - var customer = await _stripeAdapter.CustomerGetAsync(provider.GatewayCustomerId); + var customer = await _stripeAdapter.GetCustomerAsync(provider.GatewayCustomerId); if (model.PayByInvoice != customer.ApprovedToPayByInvoice()) { var approvedToPayByInvoice = model.PayByInvoice ? "1" : "0"; - await _stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions + await _stripeAdapter.UpdateCustomerAsync(customer.Id, new CustomerUpdateOptions { Metadata = new Dictionary { diff --git a/src/Admin/AdminConsole/Models/OrganizationEditModel.cs b/src/Admin/AdminConsole/Models/OrganizationEditModel.cs index 6059a003b6..77a8496e5b 100644 --- a/src/Admin/AdminConsole/Models/OrganizationEditModel.cs +++ b/src/Admin/AdminConsole/Models/OrganizationEditModel.cs @@ -107,6 +107,8 @@ public class OrganizationEditModel : OrganizationViewModel MaxAutoscaleSmServiceAccounts = org.MaxAutoscaleSmServiceAccounts; UseOrganizationDomains = org.UseOrganizationDomains; UseAutomaticUserConfirmation = org.UseAutomaticUserConfirmation; + UseDisableSmAdsForUsers = org.UseDisableSmAdsForUsers; + UsePhishingBlocker = org.UsePhishingBlocker; _plans = plans; } @@ -160,6 +162,8 @@ public class OrganizationEditModel : OrganizationViewModel public new bool UseSecretsManager { get; set; } [Display(Name = "Risk Insights")] public new bool UseRiskInsights { get; set; } + [Display(Name = "Phishing Blocker")] + public new bool UsePhishingBlocker { get; set; } [Display(Name = "Admin Sponsored Families")] public bool UseAdminSponsoredFamilies { get; set; } [Display(Name = "Self Host")] @@ -193,6 +197,8 @@ public class OrganizationEditModel : OrganizationViewModel public int? MaxAutoscaleSmServiceAccounts { get; set; } [Display(Name = "Use Organization Domains")] public bool UseOrganizationDomains { get; set; } + [Display(Name = "Disable SM Ads For Users")] + public new bool UseDisableSmAdsForUsers { get; set; } [Display(Name = "Automatic User Confirmation")] public bool UseAutomaticUserConfirmation { get; set; } @@ -327,6 +333,8 @@ public class OrganizationEditModel : OrganizationViewModel existingOrganization.SmServiceAccounts = SmServiceAccounts; existingOrganization.MaxAutoscaleSmServiceAccounts = MaxAutoscaleSmServiceAccounts; existingOrganization.UseOrganizationDomains = UseOrganizationDomains; + existingOrganization.UseDisableSmAdsForUsers = UseDisableSmAdsForUsers; + existingOrganization.UsePhishingBlocker = UsePhishingBlocker; return existingOrganization; } } diff --git a/src/Admin/AdminConsole/Models/OrganizationViewModel.cs b/src/Admin/AdminConsole/Models/OrganizationViewModel.cs index 2c126ecd8e..652535b592 100644 --- a/src/Admin/AdminConsole/Models/OrganizationViewModel.cs +++ b/src/Admin/AdminConsole/Models/OrganizationViewModel.cs @@ -75,6 +75,8 @@ public class OrganizationViewModel public int OccupiedSmSeatsCount { get; set; } public bool UseSecretsManager => Organization.UseSecretsManager; public bool UseRiskInsights => Organization.UseRiskInsights; + public bool UsePhishingBlocker => Organization.UsePhishingBlocker; + public bool UseDisableSmAdsForUsers => Organization.UseDisableSmAdsForUsers; public IEnumerable OwnersDetails { get; set; } public IEnumerable AdminsDetails { get; set; } } diff --git a/src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml b/src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml index cb71c0fc78..0b60d99e3c 100644 --- a/src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml +++ b/src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml @@ -156,6 +156,10 @@ +
+ + +
@if(FeatureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)) {
@@ -181,6 +185,13 @@
+ @if (FeatureService.IsEnabled(FeatureFlagKeys.SM1719_RemoveSecretsManagerAds)) + { +
+ + +
+ }

Access Intelligence

diff --git a/src/Admin/Controllers/ToolsController.cs b/src/Admin/Controllers/ToolsController.cs index 46dafd65e7..2dd6de89a0 100644 --- a/src/Admin/Controllers/ToolsController.cs +++ b/src/Admin/Controllers/ToolsController.cs @@ -8,6 +8,7 @@ using Bit.Admin.Utilities; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Organizations.Queries; +using Bit.Core.Billing.Services; using Bit.Core.Entities; using Bit.Core.Platform.Installations; using Bit.Core.Repositories; diff --git a/src/Admin/Controllers/UsersController.cs b/src/Admin/Controllers/UsersController.cs index b85a91719c..f42b22b098 100644 --- a/src/Admin/Controllers/UsersController.cs +++ b/src/Admin/Controllers/UsersController.cs @@ -5,6 +5,7 @@ using Bit.Admin.Models; using Bit.Admin.Services; using Bit.Admin.Utilities; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; +using Bit.Core.Billing.Services; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; @@ -20,7 +21,7 @@ public class UsersController : Controller { private readonly IUserRepository _userRepository; private readonly ICipherRepository _cipherRepository; - private readonly IPaymentService _paymentService; + private readonly IStripePaymentService _paymentService; private readonly GlobalSettings _globalSettings; private readonly IAccessControlService _accessControlService; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; @@ -30,7 +31,7 @@ public class UsersController : Controller public UsersController( IUserRepository userRepository, ICipherRepository cipherRepository, - IPaymentService paymentService, + IStripePaymentService paymentService, GlobalSettings globalSettings, IAccessControlService accessControlService, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, diff --git a/src/Admin/Dockerfile b/src/Admin/Dockerfile index 648ff1be91..84248639cf 100644 --- a/src/Admin/Dockerfile +++ b/src/Admin/Dockerfile @@ -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 ./ diff --git a/src/Admin/HostedServices/DatabaseMigrationHostedService.cs b/src/Admin/HostedServices/DatabaseMigrationHostedService.cs index 434c265f26..51739ce655 100644 --- a/src/Admin/HostedServices/DatabaseMigrationHostedService.cs +++ b/src/Admin/HostedServices/DatabaseMigrationHostedService.cs @@ -1,5 +1,5 @@ -using Bit.Core.Utilities; -using Microsoft.Data.SqlClient; +using System.Data.Common; +using Bit.Core.Utilities; namespace Bit.Admin.HostedServices; @@ -19,7 +19,7 @@ public class DatabaseMigrationHostedService : IHostedService, IDisposable public virtual async Task StartAsync(CancellationToken cancellationToken) { // Wait 20 seconds to allow database to come online - await Task.Delay(20000); + await Task.Delay(20000, cancellationToken); var maxMigrationAttempts = 10; for (var i = 1; i <= maxMigrationAttempts; i++) @@ -30,7 +30,7 @@ public class DatabaseMigrationHostedService : IHostedService, IDisposable // TODO: Maybe flip a flag somewhere to indicate migration is complete?? break; } - catch (SqlException e) + catch (DbException e) { if (i >= maxMigrationAttempts) { @@ -40,8 +40,8 @@ public class DatabaseMigrationHostedService : IHostedService, IDisposable else { _logger.LogError(e, - "Database unavailable for migration. Trying again (attempt #{0})...", i + 1); - await Task.Delay(20000); + "Database unavailable for migration. Trying again (attempt #{AttemptNumber})...", i + 1); + await Task.Delay(20000, cancellationToken); } } } diff --git a/src/Admin/Program.cs b/src/Admin/Program.cs index 05bf35d41d..006a8223b2 100644 --- a/src/Admin/Program.cs +++ b/src/Admin/Program.cs @@ -16,19 +16,8 @@ public class Program o.Limits.MaxRequestLineSize = 20_000; }); webBuilder.UseStartup(); - webBuilder.ConfigureLogging((hostingContext, logging) => - logging.AddSerilog(hostingContext, (e, globalSettings) => - { - var context = e.Properties["SourceContext"].ToString(); - if (e.Properties.TryGetValue("RequestPath", out var requestPath) && - !string.IsNullOrWhiteSpace(requestPath?.ToString()) && - (context.Contains(".Server.Kestrel") || context.Contains(".Core.IISHttpServer"))) - { - return false; - } - return e.Level >= globalSettings.MinLogLevel.AdminSettings.Default; - })); }) + .AddSerilogFileLogging() .Build() .Run(); } diff --git a/src/Admin/Startup.cs b/src/Admin/Startup.cs index 5ecbdc899c..6c0a644ee6 100644 --- a/src/Admin/Startup.cs +++ b/src/Admin/Startup.cs @@ -65,6 +65,7 @@ public class Startup default: break; } + services.AddTestPlayIdTracking(globalSettings); // Context services.AddScoped(); @@ -132,11 +133,8 @@ public class Startup public void Configure( IApplicationBuilder app, IWebHostEnvironment env, - IHostApplicationLifetime appLifetime, GlobalSettings globalSettings) { - app.UseSerilog(env, appLifetime, globalSettings); - // Add general security headers app.UseMiddleware(); diff --git a/src/Admin/appsettings.Production.json b/src/Admin/appsettings.Production.json index 9f797f3111..1d852abfed 100644 --- a/src/Admin/appsettings.Production.json +++ b/src/Admin/appsettings.Production.json @@ -20,11 +20,9 @@ } }, "Logging": { - "IncludeScopes": false, "LogLevel": { - "Default": "Debug", - "System": "Information", - "Microsoft": "Information" + "Default": "Information", + "Microsoft.AspNetCore": "Warning" }, "Console": { "IncludeScopes": true, diff --git a/src/Admin/package-lock.json b/src/Admin/package-lock.json index 6e0f78e1e6..e851daac36 100644 --- a/src/Admin/package-lock.json +++ b/src/Admin/package-lock.json @@ -18,9 +18,9 @@ "css-loader": "7.1.2", "expose-loader": "5.0.1", "mini-css-extract-plugin": "2.9.2", - "sass": "1.93.2", + "sass": "1.97.2", "sass-loader": "16.0.5", - "webpack": "5.102.1", + "webpack": "5.104.1", "webpack-cli": "5.1.4" } }, @@ -750,9 +750,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.8.18", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.18.tgz", - "integrity": "sha512-UYmTpOBwgPScZpS4A+YbapwWuBwasxvO/2IOHArSsAhL/+ZdmATBXTex3t+l2hXwLVYK382ibr/nKoY9GKe86w==", + "version": "2.9.13", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.13.tgz", + "integrity": "sha512-WhtvB2NG2wjr04+h77sg3klAIwrgOqnjS49GGudnUPGFFgg7G17y7Qecqp+2Dr5kUDxNRBca0SK7cG8JwzkWDQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -793,9 +793,9 @@ } }, "node_modules/browserslist": { - "version": "4.26.3", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz", - "integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==", + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "dev": true, "funding": [ { @@ -814,11 +814,11 @@ "license": "MIT", "peer": true, "dependencies": { - "baseline-browser-mapping": "^2.8.9", - "caniuse-lite": "^1.0.30001746", - "electron-to-chromium": "^1.5.227", - "node-releases": "^2.0.21", - "update-browserslist-db": "^1.1.3" + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" @@ -835,9 +835,9 @@ "license": "MIT" }, "node_modules/caniuse-lite": { - "version": "1.0.30001751", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz", - "integrity": "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==", + "version": "1.0.30001763", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001763.tgz", + "integrity": "sha512-mh/dGtq56uN98LlNX9qdbKnzINhX0QzhiWBFEkFfsFO4QyCvL8YegrJAazCwXIeqkIob8BlZPGM3xdnY+sgmvQ==", "dev": true, "funding": [ { @@ -989,9 +989,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.237", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.237.tgz", - "integrity": "sha512-icUt1NvfhGLar5lSWH3tHNzablaA5js3HVHacQimfP8ViEBOQv+L7DKEuHdbTZ0SKCO1ogTJTIL1Gwk9S6Qvcg==", + "version": "1.5.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", "dev": true, "license": "ISC" }, @@ -1023,9 +1023,9 @@ } }, "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", "dev": true, "license": "MIT" }, @@ -1419,13 +1419,17 @@ } }, "node_modules/loader-runner": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", - "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz", + "integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==", "dev": true, "license": "MIT", "engines": { "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, "node_modules/locate-path": { @@ -1542,9 +1546,9 @@ "optional": true }, "node_modules/node-releases": { - "version": "2.0.26", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.26.tgz", - "integrity": "sha512-S2M9YimhSjBSvYnlr5/+umAnPHE++ODwt5e2Ij6FoX45HA/s4vHdkDx1eax2pAPeAOqu4s9b7ppahsyEFdVqQA==", + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", "dev": true, "license": "MIT" }, @@ -1875,9 +1879,9 @@ "license": "MIT" }, "node_modules/sass": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.93.2.tgz", - "integrity": "sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==", + "version": "1.97.2", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.97.2.tgz", + "integrity": "sha512-y5LWb0IlbO4e97Zr7c3mlpabcbBtS+ieiZ9iwDooShpFKWXf62zz5pEPdwrLYm+Bxn1fnbwFGzHuCLSA9tBmrw==", "dev": true, "license": "MIT", "peer": true, @@ -2110,9 +2114,9 @@ } }, "node_modules/terser-webpack-plugin": { - "version": "5.3.14", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz", - "integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==", + "version": "5.3.16", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz", + "integrity": "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==", "dev": true, "license": "MIT", "dependencies": { @@ -2174,9 +2178,9 @@ "license": "MIT" }, "node_modules/update-browserslist-db": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", - "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "dev": true, "funding": [ { @@ -2226,9 +2230,9 @@ } }, "node_modules/webpack": { - "version": "5.102.1", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.102.1.tgz", - "integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==", + "version": "5.104.1", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.104.1.tgz", + "integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==", "dev": true, "license": "MIT", "peer": true, @@ -2241,21 +2245,21 @@ "@webassemblyjs/wasm-parser": "^1.14.1", "acorn": "^8.15.0", "acorn-import-phases": "^1.0.3", - "browserslist": "^4.26.3", + "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.17.3", - "es-module-lexer": "^1.2.1", + "enhanced-resolve": "^5.17.4", + "es-module-lexer": "^2.0.0", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", "json-parse-even-better-errors": "^2.3.1", - "loader-runner": "^4.2.0", + "loader-runner": "^4.3.1", "mime-types": "^2.1.27", "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", - "terser-webpack-plugin": "^5.3.11", + "terser-webpack-plugin": "^5.3.16", "watchpack": "^2.4.4", "webpack-sources": "^3.3.3" }, diff --git a/src/Admin/package.json b/src/Admin/package.json index f6f21e2cf9..3a3926d6ee 100644 --- a/src/Admin/package.json +++ b/src/Admin/package.json @@ -17,9 +17,9 @@ "css-loader": "7.1.2", "expose-loader": "5.0.1", "mini-css-extract-plugin": "2.9.2", - "sass": "1.93.2", + "sass": "1.97.2", "sass-loader": "16.0.5", - "webpack": "5.102.1", + "webpack": "5.104.1", "webpack-cli": "5.1.4" } } diff --git a/src/Api/AdminConsole/Authorization/Requirements/MemberRequirement.cs b/src/Api/AdminConsole/Authorization/Requirements/MemberRequirement.cs new file mode 100644 index 0000000000..ed205524d1 --- /dev/null +++ b/src/Api/AdminConsole/Authorization/Requirements/MemberRequirement.cs @@ -0,0 +1,14 @@ +using Bit.Core.Context; + +namespace Bit.Api.AdminConsole.Authorization.Requirements; + +/// +/// Requires that the user is a member of the organization. +/// +public class MemberRequirement : IOrganizationRequirement +{ + public Task AuthorizeAsync( + CurrentContextOrganization? organizationClaims, + Func> isProviderUserForOrg) + => Task.FromResult(organizationClaims is not null); +} diff --git a/src/Api/AdminConsole/Controllers/OrganizationIntegrationConfigurationController.cs b/src/Api/AdminConsole/Controllers/OrganizationIntegrationConfigurationController.cs deleted file mode 100644 index 0b7fe8dffe..0000000000 --- a/src/Api/AdminConsole/Controllers/OrganizationIntegrationConfigurationController.cs +++ /dev/null @@ -1,130 +0,0 @@ -using Bit.Api.AdminConsole.Models.Request.Organizations; -using Bit.Api.AdminConsole.Models.Response.Organizations; -using Bit.Core.Context; -using Bit.Core.Exceptions; -using Bit.Core.Repositories; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; - -namespace Bit.Api.AdminConsole.Controllers; - -[Route("organizations/{organizationId:guid}/integrations/{integrationId:guid}/configurations")] -[Authorize("Application")] -public class OrganizationIntegrationConfigurationController( - ICurrentContext currentContext, - IOrganizationIntegrationRepository integrationRepository, - IOrganizationIntegrationConfigurationRepository integrationConfigurationRepository) : Controller -{ - [HttpGet("")] - public async Task> GetAsync( - Guid organizationId, - Guid integrationId) - { - if (!await HasPermission(organizationId)) - { - throw new NotFoundException(); - } - var integration = await integrationRepository.GetByIdAsync(integrationId); - if (integration == null || integration.OrganizationId != organizationId) - { - throw new NotFoundException(); - } - - var configurations = await integrationConfigurationRepository.GetManyByIntegrationAsync(integrationId); - return configurations - .Select(configuration => new OrganizationIntegrationConfigurationResponseModel(configuration)) - .ToList(); - } - - [HttpPost("")] - public async Task CreateAsync( - Guid organizationId, - Guid integrationId, - [FromBody] OrganizationIntegrationConfigurationRequestModel model) - { - if (!await HasPermission(organizationId)) - { - throw new NotFoundException(); - } - var integration = await integrationRepository.GetByIdAsync(integrationId); - if (integration == null || integration.OrganizationId != organizationId) - { - throw new NotFoundException(); - } - if (!model.IsValidForType(integration.Type)) - { - throw new BadRequestException($"Invalid Configuration and/or Template for integration type {integration.Type}"); - } - - var organizationIntegrationConfiguration = model.ToOrganizationIntegrationConfiguration(integrationId); - var configuration = await integrationConfigurationRepository.CreateAsync(organizationIntegrationConfiguration); - return new OrganizationIntegrationConfigurationResponseModel(configuration); - } - - [HttpPut("{configurationId:guid}")] - public async Task UpdateAsync( - Guid organizationId, - Guid integrationId, - Guid configurationId, - [FromBody] OrganizationIntegrationConfigurationRequestModel model) - { - if (!await HasPermission(organizationId)) - { - throw new NotFoundException(); - } - var integration = await integrationRepository.GetByIdAsync(integrationId); - if (integration == null || integration.OrganizationId != organizationId) - { - throw new NotFoundException(); - } - if (!model.IsValidForType(integration.Type)) - { - throw new BadRequestException($"Invalid Configuration and/or Template for integration type {integration.Type}"); - } - - var configuration = await integrationConfigurationRepository.GetByIdAsync(configurationId); - if (configuration is null || configuration.OrganizationIntegrationId != integrationId) - { - throw new NotFoundException(); - } - - var newConfiguration = model.ToOrganizationIntegrationConfiguration(configuration); - await integrationConfigurationRepository.ReplaceAsync(newConfiguration); - - return new OrganizationIntegrationConfigurationResponseModel(newConfiguration); - } - - [HttpDelete("{configurationId:guid}")] - public async Task DeleteAsync(Guid organizationId, Guid integrationId, Guid configurationId) - { - if (!await HasPermission(organizationId)) - { - throw new NotFoundException(); - } - var integration = await integrationRepository.GetByIdAsync(integrationId); - if (integration == null || integration.OrganizationId != organizationId) - { - throw new NotFoundException(); - } - - var configuration = await integrationConfigurationRepository.GetByIdAsync(configurationId); - if (configuration is null || configuration.OrganizationIntegrationId != integrationId) - { - throw new NotFoundException(); - } - - await integrationConfigurationRepository.DeleteAsync(configuration); - } - - [HttpPost("{configurationId:guid}/delete")] - [Obsolete("This endpoint is deprecated. Use DELETE method instead")] - public async Task PostDeleteAsync(Guid organizationId, Guid integrationId, Guid configurationId) - { - await DeleteAsync(organizationId, integrationId, configurationId); - } - - private async Task HasPermission(Guid organizationId) - { - return await currentContext.OrganizationOwner(organizationId); - } -} diff --git a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs index 155b60ce5b..90d02a46a1 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs @@ -19,6 +19,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimed using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.SelfRevokeUser; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Repositories; @@ -41,6 +42,8 @@ using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using V1_RevokeOrganizationUserCommand = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v1.IRevokeOrganizationUserCommand; +using V2_RevokeOrganizationUserCommand = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2; namespace Bit.Api.AdminConsole.Controllers; @@ -71,12 +74,15 @@ public class OrganizationUsersController : BaseAdminConsoleController private readonly IFeatureService _featureService; private readonly IPricingClient _pricingClient; private readonly IResendOrganizationInviteCommand _resendOrganizationInviteCommand; + private readonly IBulkResendOrganizationInvitesCommand _bulkResendOrganizationInvitesCommand; private readonly IAutomaticallyConfirmOrganizationUserCommand _automaticallyConfirmOrganizationUserCommand; + private readonly V2_RevokeOrganizationUserCommand.IRevokeOrganizationUserCommand _revokeOrganizationUserCommandVNext; private readonly IConfirmOrganizationUserCommand _confirmOrganizationUserCommand; private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand; private readonly IInitPendingOrganizationCommand _initPendingOrganizationCommand; - private readonly IRevokeOrganizationUserCommand _revokeOrganizationUserCommand; + private readonly V1_RevokeOrganizationUserCommand _revokeOrganizationUserCommand; private readonly IAdminRecoverAccountCommand _adminRecoverAccountCommand; + private readonly ISelfRevokeOrganizationUserCommand _selfRevokeOrganizationUserCommand; public OrganizationUsersController(IOrganizationRepository organizationRepository, IOrganizationUserRepository organizationUserRepository, @@ -103,10 +109,13 @@ public class OrganizationUsersController : BaseAdminConsoleController IConfirmOrganizationUserCommand confirmOrganizationUserCommand, IRestoreOrganizationUserCommand restoreOrganizationUserCommand, IInitPendingOrganizationCommand initPendingOrganizationCommand, - IRevokeOrganizationUserCommand revokeOrganizationUserCommand, + V1_RevokeOrganizationUserCommand revokeOrganizationUserCommand, IResendOrganizationInviteCommand resendOrganizationInviteCommand, + IBulkResendOrganizationInvitesCommand bulkResendOrganizationInvitesCommand, IAdminRecoverAccountCommand adminRecoverAccountCommand, - IAutomaticallyConfirmOrganizationUserCommand automaticallyConfirmOrganizationUserCommand) + IAutomaticallyConfirmOrganizationUserCommand automaticallyConfirmOrganizationUserCommand, + V2_RevokeOrganizationUserCommand.IRevokeOrganizationUserCommand revokeOrganizationUserCommandVNext, + ISelfRevokeOrganizationUserCommand selfRevokeOrganizationUserCommand) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -131,12 +140,15 @@ public class OrganizationUsersController : BaseAdminConsoleController _featureService = featureService; _pricingClient = pricingClient; _resendOrganizationInviteCommand = resendOrganizationInviteCommand; + _bulkResendOrganizationInvitesCommand = bulkResendOrganizationInvitesCommand; _automaticallyConfirmOrganizationUserCommand = automaticallyConfirmOrganizationUserCommand; + _revokeOrganizationUserCommandVNext = revokeOrganizationUserCommandVNext; _confirmOrganizationUserCommand = confirmOrganizationUserCommand; _restoreOrganizationUserCommand = restoreOrganizationUserCommand; _initPendingOrganizationCommand = initPendingOrganizationCommand; _revokeOrganizationUserCommand = revokeOrganizationUserCommand; _adminRecoverAccountCommand = adminRecoverAccountCommand; + _selfRevokeOrganizationUserCommand = selfRevokeOrganizationUserCommand; } [HttpGet("{id}")] @@ -273,7 +285,17 @@ public class OrganizationUsersController : BaseAdminConsoleController public async Task> BulkReinvite(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) { var userId = _userService.GetProperUserId(User); - var result = await _organizationService.ResendInvitesAsync(orgId, userId.Value, model.Ids); + + IEnumerable> result; + if (_featureService.IsEnabled(FeatureFlagKeys.IncreaseBulkReinviteLimitForCloud)) + { + result = await _bulkResendOrganizationInvitesCommand.BulkResendInvitesAsync(orgId, userId.Value, model.Ids); + } + else + { + result = await _organizationService.ResendInvitesAsync(orgId, userId.Value, model.Ids); + } + return new ListResponseModel( result.Select(t => new OrganizationUserBulkResponseModel(t.Item1.Id, t.Item2))); } @@ -483,43 +505,10 @@ public class OrganizationUsersController : BaseAdminConsoleController } } +#nullable enable [HttpPut("{id}/reset-password")] [Authorize] public async Task PutResetPassword(Guid orgId, Guid id, [FromBody] OrganizationUserResetPasswordRequestModel model) - { - if (_featureService.IsEnabled(FeatureFlagKeys.AccountRecoveryCommand)) - { - // TODO: remove legacy implementation after feature flag is enabled. - return await PutResetPasswordNew(orgId, id, model); - } - - // Get the users role, since provider users aren't a member of the organization we use the owner check - var orgUserType = await _currentContext.OrganizationOwner(orgId) - ? OrganizationUserType.Owner - : _currentContext.Organizations?.FirstOrDefault(o => o.Id == orgId)?.Type; - if (orgUserType == null) - { - return TypedResults.NotFound(); - } - - var result = await _userService.AdminResetPasswordAsync(orgUserType.Value, orgId, id, model.NewMasterPasswordHash, model.Key); - if (result.Succeeded) - { - return TypedResults.Ok(); - } - - foreach (var error in result.Errors) - { - ModelState.AddModelError(string.Empty, error.Description); - } - - await Task.Delay(2000); - return TypedResults.BadRequest(ModelState); - } - -#nullable enable - // TODO: make sure the route and authorize attributes are maintained when the legacy implementation is removed. - private async Task PutResetPasswordNew(Guid orgId, Guid id, [FromBody] OrganizationUserResetPasswordRequestModel model) { var targetOrganizationUser = await _organizationUserRepository.GetByIdAsync(id); if (targetOrganizationUser == null || targetOrganizationUser.OrganizationId != orgId) @@ -650,6 +639,20 @@ public class OrganizationUsersController : BaseAdminConsoleController await RestoreOrRevokeUserAsync(orgId, id, _revokeOrganizationUserCommand.RevokeUserAsync); } + [HttpPut("revoke-self")] + [Authorize] + public async Task RevokeSelfAsync(Guid orgId) + { + var userId = _userService.GetProperUserId(User); + if (!userId.HasValue) + { + throw new UnauthorizedAccessException(); + } + + var result = await _selfRevokeOrganizationUserCommand.SelfRevokeUserAsync(orgId, userId.Value); + return Handle(result); + } + [HttpPatch("{id}/revoke")] [Obsolete("This endpoint is deprecated. Use PUT method instead")] [Authorize] @@ -662,7 +665,24 @@ public class OrganizationUsersController : BaseAdminConsoleController [Authorize] public async Task> BulkRevokeAsync(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) { - return await RestoreOrRevokeUsersAsync(orgId, model, _revokeOrganizationUserCommand.RevokeUsersAsync); + var currentUserId = _userService.GetProperUserId(User); + if (currentUserId == null) + { + throw new UnauthorizedAccessException(); + } + + var results = await _revokeOrganizationUserCommandVNext.RevokeUsersAsync( + new V2_RevokeOrganizationUserCommand.RevokeOrganizationUsersRequest( + orgId, + model.Ids.ToArray(), + new StandardUser(currentUserId.Value, await _currentContext.OrganizationOwner(orgId)))); + + return new ListResponseModel(results + .Select(result => new OrganizationUserBulkResponseModel(result.Id, + result.Result.Match( + error => error.Message, + _ => string.Empty + )))); } [HttpPatch("revoke")] diff --git a/src/Api/AdminConsole/Controllers/OrganizationsController.cs b/src/Api/AdminConsole/Controllers/OrganizationsController.cs index 590895665d..100cd7caf6 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationsController.cs @@ -12,7 +12,6 @@ using Bit.Api.Models.Request.Accounts; using Bit.Api.Models.Request.Organizations; using Bit.Api.Models.Response; using Bit.Core; -using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Business.Tokenables; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; @@ -70,6 +69,7 @@ public class OrganizationsController : Controller private readonly IPolicyRequirementQuery _policyRequirementQuery; private readonly IPricingClient _pricingClient; private readonly IOrganizationUpdateKeysCommand _organizationUpdateKeysCommand; + private readonly IOrganizationUpdateCommand _organizationUpdateCommand; public OrganizationsController( IOrganizationRepository organizationRepository, @@ -94,7 +94,8 @@ public class OrganizationsController : Controller IOrganizationDeleteCommand organizationDeleteCommand, IPolicyRequirementQuery policyRequirementQuery, IPricingClient pricingClient, - IOrganizationUpdateKeysCommand organizationUpdateKeysCommand) + IOrganizationUpdateKeysCommand organizationUpdateKeysCommand, + IOrganizationUpdateCommand organizationUpdateCommand) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -119,6 +120,7 @@ public class OrganizationsController : Controller _policyRequirementQuery = policyRequirementQuery; _pricingClient = pricingClient; _organizationUpdateKeysCommand = organizationUpdateKeysCommand; + _organizationUpdateCommand = organizationUpdateCommand; } [HttpGet("{id}")] @@ -224,36 +226,31 @@ public class OrganizationsController : Controller return new OrganizationResponseModel(result.Organization, plan); } - [HttpPut("{id}")] - public async Task Put(string id, [FromBody] OrganizationUpdateRequestModel model) + [HttpPut("{organizationId:guid}")] + public async Task Put(Guid organizationId, [FromBody] OrganizationUpdateRequestModel model) { - var orgIdGuid = new Guid(id); + // If billing email is being changed, require subscription editing permissions. + // Otherwise, organization owner permissions are sufficient. + var requiresBillingPermission = model.BillingEmail is not null; + var authorized = requiresBillingPermission + ? await _currentContext.EditSubscription(organizationId) + : await _currentContext.OrganizationOwner(organizationId); - var organization = await _organizationRepository.GetByIdAsync(orgIdGuid); - if (organization == null) + if (!authorized) { - throw new NotFoundException(); + return TypedResults.Unauthorized(); } - var updateBilling = ShouldUpdateBilling(model, organization); + var commandRequest = model.ToCommandRequest(organizationId); + var updatedOrganization = await _organizationUpdateCommand.UpdateAsync(commandRequest); - var hasRequiredPermissions = updateBilling - ? await _currentContext.EditSubscription(orgIdGuid) - : await _currentContext.OrganizationOwner(orgIdGuid); - - if (!hasRequiredPermissions) - { - throw new NotFoundException(); - } - - await _organizationService.UpdateAsync(model.ToOrganization(organization, _globalSettings), updateBilling); - var plan = await _pricingClient.GetPlan(organization.PlanType); - return new OrganizationResponseModel(organization, plan); + var plan = await _pricingClient.GetPlan(updatedOrganization.PlanType); + return TypedResults.Ok(new OrganizationResponseModel(updatedOrganization, plan)); } [HttpPost("{id}")] [Obsolete("This endpoint is deprecated. Use PUT method instead")] - public async Task PostPut(string id, [FromBody] OrganizationUpdateRequestModel model) + public async Task PostPut(Guid id, [FromBody] OrganizationUpdateRequestModel model) { return await Put(id, model); } @@ -588,11 +585,4 @@ public class OrganizationsController : Controller return organization.PlanType; } - - private bool ShouldUpdateBilling(OrganizationUpdateRequestModel model, Organization organization) - { - var organizationNameChanged = model.Name != organization.Name; - var billingEmailChanged = model.BillingEmail != organization.BillingEmail; - return !_globalSettings.SelfHosted && (organizationNameChanged || billingEmailChanged); - } } diff --git a/src/Api/AdminConsole/Controllers/PoliciesController.cs b/src/Api/AdminConsole/Controllers/PoliciesController.cs index a5272413e2..bce0332d67 100644 --- a/src/Api/AdminConsole/Controllers/PoliciesController.cs +++ b/src/Api/AdminConsole/Controllers/PoliciesController.cs @@ -7,7 +7,6 @@ using Bit.Api.AdminConsole.Models.Request; using Bit.Api.AdminConsole.Models.Response.Helpers; using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Api.Models.Response; -using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces; @@ -42,7 +41,6 @@ public class PoliciesController : Controller private readonly IDataProtectorTokenFactory _orgUserInviteTokenDataFactory; private readonly IPolicyRepository _policyRepository; private readonly IUserService _userService; - private readonly IFeatureService _featureService; private readonly ISavePolicyCommand _savePolicyCommand; private readonly IVNextSavePolicyCommand _vNextSavePolicyCommand; @@ -55,7 +53,6 @@ public class PoliciesController : Controller IDataProtectorTokenFactory orgUserInviteTokenDataFactory, IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery, IOrganizationRepository organizationRepository, - IFeatureService featureService, ISavePolicyCommand savePolicyCommand, IVNextSavePolicyCommand vNextSavePolicyCommand) { @@ -69,7 +66,6 @@ public class PoliciesController : Controller _organizationRepository = organizationRepository; _orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory; _organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery; - _featureService = featureService; _savePolicyCommand = savePolicyCommand; _vNextSavePolicyCommand = vNextSavePolicyCommand; } @@ -215,15 +211,12 @@ public class PoliciesController : Controller } [HttpPut("{type}/vnext")] - [RequireFeatureAttribute(FeatureFlagKeys.CreateDefaultLocation)] [Authorize] public async Task PutVNext(Guid orgId, PolicyType type, [FromBody] SavePolicyRequest model) { var savePolicyRequest = await model.ToSavePolicyModelAsync(orgId, type, _currentContext); - var policy = _featureService.IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor) ? - await _vNextSavePolicyCommand.SaveAsync(savePolicyRequest) : - await _savePolicyCommand.VNextSaveAsync(savePolicyRequest); + var policy = await _vNextSavePolicyCommand.SaveAsync(savePolicyRequest); return new PolicyResponseModel(policy); } diff --git a/src/Api/AdminConsole/Controllers/ProviderClientsController.cs b/src/Api/AdminConsole/Controllers/ProviderClientsController.cs index caf2651e16..dfa6984826 100644 --- a/src/Api/AdminConsole/Controllers/ProviderClientsController.cs +++ b/src/Api/AdminConsole/Controllers/ProviderClientsController.cs @@ -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 }; diff --git a/src/Api/AdminConsole/Controllers/ProvidersController.cs b/src/Api/AdminConsole/Controllers/ProvidersController.cs index aa87bf9c74..515404e8a9 100644 --- a/src/Api/AdminConsole/Controllers/ProvidersController.cs +++ b/src/Api/AdminConsole/Controllers/ProvidersController.cs @@ -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 _logger; public ProvidersController(IUserService userService, IProviderRepository providerRepository, - IProviderService providerService, ICurrentContext currentContext, GlobalSettings globalSettings) + IProviderService providerService, ICurrentContext currentContext, GlobalSettings globalSettings, + IProviderBillingService providerBillingService, ILogger 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); } diff --git a/src/Api/AdminConsole/Models/Request/OrganizationDomainRequestModel.cs b/src/Api/AdminConsole/Models/Request/OrganizationDomainRequestModel.cs index 46b253da31..3a2ada719f 100644 --- a/src/Api/AdminConsole/Models/Request/OrganizationDomainRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/OrganizationDomainRequestModel.cs @@ -2,11 +2,13 @@ #nullable disable using System.ComponentModel.DataAnnotations; +using Bit.Core.Utilities; namespace Bit.Api.AdminConsole.Models.Request; public class OrganizationDomainRequestModel { [Required] + [DomainNameValidator] public string DomainName { get; set; } } diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationCreateRequestModel.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationCreateRequestModel.cs index 7754c44c8c..464ba0c2fd 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationCreateRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationCreateRequestModel.cs @@ -113,11 +113,10 @@ public class OrganizationCreateRequestModel : IValidatableObject BillingAddressCountry = BillingAddressCountry, }, InitiationPath = InitiationPath, - SkipTrial = SkipTrial + SkipTrial = SkipTrial, + Keys = Keys?.ToPublicKeyEncryptionKeyPairData() }; - Keys?.ToOrganizationSignup(orgSignup); - return orgSignup; } diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModel.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModel.cs deleted file mode 100644 index 8581c4ae1f..0000000000 --- a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModel.cs +++ /dev/null @@ -1,108 +0,0 @@ -using System.Text.Json; -using Bit.Core.AdminConsole.Entities; -using Bit.Core.AdminConsole.Models.Data.EventIntegrations; -using Bit.Core.Enums; - - -namespace Bit.Api.AdminConsole.Models.Request.Organizations; - -public class OrganizationIntegrationConfigurationRequestModel -{ - public string? Configuration { get; set; } - - public EventType? EventType { get; set; } - - public string? Filters { get; set; } - - public string? Template { get; set; } - - public bool IsValidForType(IntegrationType integrationType) - { - switch (integrationType) - { - case IntegrationType.CloudBillingSync or IntegrationType.Scim: - return false; - case IntegrationType.Slack: - return !string.IsNullOrWhiteSpace(Template) && - IsConfigurationValid() && - IsFiltersValid(); - case IntegrationType.Webhook: - return !string.IsNullOrWhiteSpace(Template) && - IsConfigurationValid() && - IsFiltersValid(); - case IntegrationType.Hec: - return !string.IsNullOrWhiteSpace(Template) && - Configuration is null && - IsFiltersValid(); - case IntegrationType.Datadog: - return !string.IsNullOrWhiteSpace(Template) && - Configuration is null && - IsFiltersValid(); - case IntegrationType.Teams: - return !string.IsNullOrWhiteSpace(Template) && - Configuration is null && - IsFiltersValid(); - default: - return false; - - } - } - - public OrganizationIntegrationConfiguration ToOrganizationIntegrationConfiguration(Guid organizationIntegrationId) - { - return new OrganizationIntegrationConfiguration() - { - OrganizationIntegrationId = organizationIntegrationId, - Configuration = Configuration, - Filters = Filters, - EventType = EventType, - Template = Template - }; - } - - public OrganizationIntegrationConfiguration ToOrganizationIntegrationConfiguration(OrganizationIntegrationConfiguration currentConfiguration) - { - currentConfiguration.Configuration = Configuration; - currentConfiguration.EventType = EventType; - currentConfiguration.Filters = Filters; - currentConfiguration.Template = Template; - - return currentConfiguration; - } - - private bool IsConfigurationValid() - { - if (string.IsNullOrWhiteSpace(Configuration)) - { - return false; - } - - try - { - var config = JsonSerializer.Deserialize(Configuration); - return config is not null; - } - catch - { - return false; - } - } - - private bool IsFiltersValid() - { - if (Filters is null) - { - return true; - } - - try - { - var filters = JsonSerializer.Deserialize(Filters); - return filters is not null; - } - catch - { - return false; - } - } -} diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationKeysRequestModel.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationKeysRequestModel.cs index 22b225a689..ef2fb0f07b 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationKeysRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationKeysRequestModel.cs @@ -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); } } diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationNoPaymentCreateRequest.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationNoPaymentCreateRequest.cs index 0c62b23518..81d7c413eb 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationNoPaymentCreateRequest.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationNoPaymentCreateRequest.cs @@ -110,10 +110,9 @@ public class OrganizationNoPaymentCreateRequest BillingAddressCountry = BillingAddressCountry, }, InitiationPath = InitiationPath, + Keys = Keys?.ToPublicKeyEncryptionKeyPairData() }; - Keys?.ToOrganizationSignup(orgSignup); - return orgSignup; } } diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUpdateRequestModel.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUpdateRequestModel.cs index 5a3192c121..a0b1247ae1 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUpdateRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUpdateRequestModel.cs @@ -1,41 +1,27 @@ -// 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 System.Text.Json.Serialization; -using Bit.Core.AdminConsole.Entities; -using Bit.Core.Models.Data; -using Bit.Core.Settings; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Update; using Bit.Core.Utilities; namespace Bit.Api.AdminConsole.Models.Request.Organizations; public class OrganizationUpdateRequestModel { - [Required] [StringLength(50, ErrorMessage = "The field Name exceeds the maximum length.")] [JsonConverter(typeof(HtmlEncodingStringConverter))] - public string Name { get; set; } - [StringLength(50, ErrorMessage = "The field Business Name exceeds the maximum length.")] - [JsonConverter(typeof(HtmlEncodingStringConverter))] - public string BusinessName { get; set; } - [EmailAddress] - [Required] - [StringLength(256)] - public string BillingEmail { get; set; } - public Permissions Permissions { get; set; } - public OrganizationKeysRequestModel Keys { get; set; } + public string? Name { get; set; } - public virtual Organization ToOrganization(Organization existingOrganization, GlobalSettings globalSettings) + [EmailAddress] + [StringLength(256)] + public string? BillingEmail { get; set; } + + public OrganizationKeysRequestModel? Keys { get; set; } + + public OrganizationUpdateRequest ToCommandRequest(Guid organizationId) => new() { - if (!globalSettings.SelfHosted) - { - // These items come from the license file - existingOrganization.Name = Name; - existingOrganization.BusinessName = BusinessName; - existingOrganization.BillingEmail = BillingEmail?.ToLowerInvariant()?.Trim(); - } - Keys?.ToOrganization(existingOrganization); - return existingOrganization; - } + OrganizationId = organizationId, + Name = Name, + BillingEmail = BillingEmail, + Keys = Keys?.ToPublicKeyEncryptionKeyPairData() + }; } diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUpgradeRequestModel.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUpgradeRequestModel.cs index a5dec192b9..7d5a9e56c7 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUpgradeRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUpgradeRequestModel.cs @@ -43,11 +43,10 @@ public class OrganizationUpgradeRequestModel { BillingAddressCountry = BillingAddressCountry, BillingAddressPostalCode = BillingAddressPostalCode - } + }, + Keys = Keys?.ToPublicKeyEncryptionKeyPairData() }; - Keys?.ToOrganizationUpgrade(orgUpgrade); - return orgUpgrade; } } diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUserRequestModels.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUserRequestModels.cs index 4e0accb9e8..b7a4db3acd 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUserRequestModels.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUserRequestModels.cs @@ -119,7 +119,7 @@ public class OrganizationUserResetPasswordEnrollmentRequestModel public class OrganizationUserBulkRequestModel { - [Required] + [Required, MinLength(1)] public IEnumerable Ids { get; set; } } diff --git a/src/Api/AdminConsole/Models/Response/BaseProfileOrganizationResponseModel.cs b/src/Api/AdminConsole/Models/Response/BaseProfileOrganizationResponseModel.cs index c172c45e94..c3378cd11d 100644 --- a/src/Api/AdminConsole/Models/Response/BaseProfileOrganizationResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/BaseProfileOrganizationResponseModel.cs @@ -47,6 +47,8 @@ public abstract class BaseProfileOrganizationResponseModel : ResponseModel UseAdminSponsoredFamilies = organizationDetails.UseAdminSponsoredFamilies; UseAutomaticUserConfirmation = organizationDetails.UseAutomaticUserConfirmation; UseSecretsManager = organizationDetails.UseSecretsManager; + UsePhishingBlocker = organizationDetails.UsePhishingBlocker; + UseDisableSMAdsForUsers = organizationDetails.UseDisableSMAdsForUsers; UsePasswordManager = organizationDetails.UsePasswordManager; SelfHost = organizationDetails.SelfHost; Seats = organizationDetails.Seats; @@ -99,6 +101,8 @@ public abstract class BaseProfileOrganizationResponseModel : ResponseModel public bool UseOrganizationDomains { get; set; } public bool UseAdminSponsoredFamilies { get; set; } public bool UseAutomaticUserConfirmation { get; set; } + public bool UseDisableSMAdsForUsers { get; set; } + public bool UsePhishingBlocker { get; set; } public bool SelfHost { get; set; } public int? Seats { get; set; } public short? MaxCollections { get; set; } diff --git a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs index 8006a85734..46c5a50b6c 100644 --- a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs @@ -1,10 +1,13 @@ // FIXME: Update this file to be null safe and then delete the line below #nullable disable +using System.Security.Claims; using System.Text.Json.Serialization; using Bit.Api.Models.Response; using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Licenses; +using Bit.Core.Billing.Licenses.Extensions; using Bit.Core.Billing.Organizations.Models; using Bit.Core.Models.Api; using Bit.Core.Models.Business; @@ -71,6 +74,8 @@ public class OrganizationResponseModel : ResponseModel UseOrganizationDomains = organization.UseOrganizationDomains; UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies; UseAutomaticUserConfirmation = organization.UseAutomaticUserConfirmation; + UseDisableSmAdsForUsers = organization.UseDisableSmAdsForUsers; + UsePhishingBlocker = organization.UsePhishingBlocker; } public Guid Id { get; set; } @@ -120,6 +125,8 @@ public class OrganizationResponseModel : ResponseModel public bool UseOrganizationDomains { get; set; } public bool UseAdminSponsoredFamilies { get; set; } public bool UseAutomaticUserConfirmation { get; set; } + public bool UseDisableSmAdsForUsers { get; set; } + public bool UsePhishingBlocker { get; set; } } public class OrganizationSubscriptionResponseModel : OrganizationResponseModel @@ -175,6 +182,30 @@ public class OrganizationSubscriptionResponseModel : OrganizationResponseModel } } + public OrganizationSubscriptionResponseModel(Organization organization, OrganizationLicense license, ClaimsPrincipal claimsPrincipal) : + this(organization, (Plan)null) + { + if (license != null) + { + // CRITICAL: When a license has a Token (JWT), ALWAYS use the expiration from the token claim + // The token's expiration is cryptographically secured and cannot be tampered with + // The file's Expires property can be manually edited and should NOT be trusted for display + if (claimsPrincipal != null) + { + Expiration = claimsPrincipal.GetValue(OrganizationLicenseConstants.Expires); + ExpirationWithoutGracePeriod = claimsPrincipal.GetValue(OrganizationLicenseConstants.ExpirationWithoutGracePeriod); + } + else + { + // No token - use the license file expiration (for older licenses without tokens) + Expiration = license.Expires; + ExpirationWithoutGracePeriod = license.ExpirationWithoutGracePeriod ?? (license.Trial + ? license.Expires + : license.Expires?.AddDays(-Constants.OrganizationSelfHostSubscriptionGracePeriodDays)); + } + } + } + public string StorageName { get; set; } public double? StorageGb { get; set; } public BillingCustomerDiscount CustomerDiscount { get; set; } diff --git a/src/Api/AdminConsole/Models/Response/Organizations/PolicyResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/PolicyResponseModel.cs index 81ca801308..0507de7a55 100644 --- a/src/Api/AdminConsole/Models/Response/Organizations/PolicyResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/Organizations/PolicyResponseModel.cs @@ -30,6 +30,7 @@ public class PolicyResponseModel : ResponseModel { Data = JsonSerializer.Deserialize>(policy.Data); } + RevisionDate = policy.RevisionDate; } public Guid Id { get; set; } @@ -37,4 +38,5 @@ public class PolicyResponseModel : ResponseModel public PolicyType Type { get; set; } public Dictionary Data { get; set; } public bool Enabled { get; set; } + public DateTime RevisionDate { get; set; } } diff --git a/src/Api/AdminConsole/Public/Controllers/MembersController.cs b/src/Api/AdminConsole/Public/Controllers/MembersController.cs index 3b2e82121d..58e5db18c2 100644 --- a/src/Api/AdminConsole/Public/Controllers/MembersController.cs +++ b/src/Api/AdminConsole/Public/Controllers/MembersController.cs @@ -6,6 +6,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; +using Bit.Core.Billing.Services; using Bit.Core.Context; using Bit.Core.Repositories; using Bit.Core.Services; @@ -24,7 +25,7 @@ public class MembersController : Controller private readonly ICurrentContext _currentContext; private readonly IUpdateOrganizationUserCommand _updateOrganizationUserCommand; private readonly IUpdateOrganizationUserGroupsCommand _updateOrganizationUserGroupsCommand; - private readonly IPaymentService _paymentService; + private readonly IStripePaymentService _paymentService; private readonly IOrganizationRepository _organizationRepository; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; @@ -37,7 +38,7 @@ public class MembersController : Controller ICurrentContext currentContext, IUpdateOrganizationUserCommand updateOrganizationUserCommand, IUpdateOrganizationUserGroupsCommand updateOrganizationUserGroupsCommand, - IPaymentService paymentService, + IStripePaymentService paymentService, IOrganizationRepository organizationRepository, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, IRemoveOrganizationUserCommand removeOrganizationUserCommand, diff --git a/src/Api/AdminConsole/Public/Controllers/PoliciesController.cs b/src/Api/AdminConsole/Public/Controllers/PoliciesController.cs index be0997f271..cf8da813be 100644 --- a/src/Api/AdminConsole/Public/Controllers/PoliciesController.cs +++ b/src/Api/AdminConsole/Public/Controllers/PoliciesController.cs @@ -5,15 +5,10 @@ using System.Net; using Bit.Api.AdminConsole.Public.Models.Request; using Bit.Api.AdminConsole.Public.Models.Response; using Bit.Api.Models.Public.Response; -using Bit.Core; -using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; -using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; using Bit.Core.AdminConsole.Repositories; -using Bit.Core.AdminConsole.Services; using Bit.Core.Context; -using Bit.Core.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -24,25 +19,16 @@ namespace Bit.Api.AdminConsole.Public.Controllers; public class PoliciesController : Controller { private readonly IPolicyRepository _policyRepository; - private readonly IPolicyService _policyService; private readonly ICurrentContext _currentContext; - private readonly IFeatureService _featureService; - private readonly ISavePolicyCommand _savePolicyCommand; private readonly IVNextSavePolicyCommand _vNextSavePolicyCommand; public PoliciesController( IPolicyRepository policyRepository, - IPolicyService policyService, ICurrentContext currentContext, - IFeatureService featureService, - ISavePolicyCommand savePolicyCommand, IVNextSavePolicyCommand vNextSavePolicyCommand) { _policyRepository = policyRepository; - _policyService = policyService; _currentContext = currentContext; - _featureService = featureService; - _savePolicyCommand = savePolicyCommand; _vNextSavePolicyCommand = vNextSavePolicyCommand; } @@ -97,17 +83,8 @@ public class PoliciesController : Controller [ProducesResponseType((int)HttpStatusCode.NotFound)] public async Task Put(PolicyType type, [FromBody] PolicyUpdateRequestModel model) { - Policy policy; - if (_featureService.IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor)) - { - var savePolicyModel = model.ToSavePolicyModel(_currentContext.OrganizationId!.Value, type); - policy = await _vNextSavePolicyCommand.SaveAsync(savePolicyModel); - } - else - { - var policyUpdate = model.ToPolicyUpdate(_currentContext.OrganizationId!.Value, type); - policy = await _savePolicyCommand.SaveAsync(policyUpdate); - } + var savePolicyModel = model.ToSavePolicyModel(_currentContext.OrganizationId!.Value, type); + var policy = await _vNextSavePolicyCommand.SaveAsync(savePolicyModel); var response = new PolicyResponseModel(policy); return new JsonResult(response); diff --git a/src/Api/AdminConsole/Public/Models/Response/GroupResponseModel.cs b/src/Api/AdminConsole/Public/Models/Response/GroupResponseModel.cs index c12616b4cc..e164f3c4ea 100644 --- a/src/Api/AdminConsole/Public/Models/Response/GroupResponseModel.cs +++ b/src/Api/AdminConsole/Public/Models/Response/GroupResponseModel.cs @@ -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; /// public class GroupResponseModel : GroupBaseModel, IResponseModel { + [JsonConstructor] + public GroupResponseModel() + { + + } + public GroupResponseModel(Group group, IEnumerable collections) { if (group == null) diff --git a/src/Api/Api.csproj b/src/Api/Api.csproj index 138549e92d..d25b989d11 100644 --- a/src/Api/Api.csproj +++ b/src/Api/Api.csproj @@ -4,6 +4,8 @@ false bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml true + + $(WarningsNotAsErrors);CA1304;CA1305 @@ -33,7 +35,7 @@ - + diff --git a/src/Api/Auth/Controllers/AccountsController.cs b/src/Api/Auth/Controllers/AccountsController.cs index ecf49c18c8..1223e2f05f 100644 --- a/src/Api/Auth/Controllers/AccountsController.cs +++ b/src/Api/Auth/Controllers/AccountsController.cs @@ -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; @@ -37,13 +38,16 @@ public class AccountsController : Controller private readonly IProviderUserRepository _providerUserRepository; private readonly IUserService _userService; private readonly IPolicyService _policyService; + private readonly ISetInitialMasterPasswordCommandV1 _setInitialMasterPasswordCommandV1; private readonly ISetInitialMasterPasswordCommand _setInitialMasterPasswordCommand; + private readonly ITdeSetPasswordCommand _tdeSetPasswordCommand; private readonly ITdeOffboardingPasswordCommand _tdeOffboardingPasswordCommand; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly IFeatureService _featureService; private readonly IUserAccountKeysQuery _userAccountKeysQuery; private readonly ITwoFactorEmailService _twoFactorEmailService; private readonly IChangeKdfCommand _changeKdfCommand; + private readonly IUserRepository _userRepository; public AccountsController( IOrganizationService organizationService, @@ -52,12 +56,15 @@ public class AccountsController : Controller IUserService userService, IPolicyService policyService, ISetInitialMasterPasswordCommand setInitialMasterPasswordCommand, + ISetInitialMasterPasswordCommandV1 setInitialMasterPasswordCommandV1, + ITdeSetPasswordCommand tdeSetPasswordCommand, ITdeOffboardingPasswordCommand tdeOffboardingPasswordCommand, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, IFeatureService featureService, IUserAccountKeysQuery userAccountKeysQuery, ITwoFactorEmailService twoFactorEmailService, - IChangeKdfCommand changeKdfCommand + IChangeKdfCommand changeKdfCommand, + IUserRepository userRepository ) { _organizationService = organizationService; @@ -66,12 +73,15 @@ public class AccountsController : Controller _userService = userService; _policyService = policyService; _setInitialMasterPasswordCommand = setInitialMasterPasswordCommand; + _setInitialMasterPasswordCommandV1 = setInitialMasterPasswordCommandV1; + _tdeSetPasswordCommand = tdeSetPasswordCommand; _tdeOffboardingPasswordCommand = tdeOffboardingPasswordCommand; _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; _featureService = featureService; _userAccountKeysQuery = userAccountKeysQuery; _twoFactorEmailService = twoFactorEmailService; _changeKdfCommand = changeKdfCommand; + _userRepository = userRepository; } @@ -204,7 +214,7 @@ public class AccountsController : Controller } [HttpPost("set-password")] - public async Task PostSetPasswordAsync([FromBody] SetPasswordRequestModel model) + public async Task PostSetPasswordAsync([FromBody] SetInitialPasswordRequestModel model) { var user = await _userService.GetUserByPrincipalAsync(User); if (user == null) @@ -212,33 +222,48 @@ public class AccountsController : Controller throw new UnauthorizedAccessException(); } - try + if (model.IsV2Request()) { - user = model.ToUser(user); + if (model.IsTdeSetPasswordRequest()) + { + await _tdeSetPasswordCommand.SetMasterPasswordAsync(user, model.ToData()); + } + else + { + await _setInitialMasterPasswordCommand.SetInitialMasterPasswordAsync(user, model.ToData()); + } } - catch (Exception e) + else { - ModelState.AddModelError(string.Empty, e.Message); + // TODO removed with https://bitwarden.atlassian.net/browse/PM-27327 + try + { + user = model.ToUser(user); + } + catch (Exception e) + { + ModelState.AddModelError(string.Empty, e.Message); + throw new BadRequestException(ModelState); + } + + var result = await _setInitialMasterPasswordCommandV1.SetInitialMasterPasswordAsync( + user, + model.MasterPasswordHash, + model.Key, + model.OrgIdentifier); + + if (result.Succeeded) + { + return; + } + + foreach (var error in result.Errors) + { + ModelState.AddModelError(string.Empty, error.Description); + } + throw new BadRequestException(ModelState); } - - var result = await _setInitialMasterPasswordCommand.SetInitialMasterPasswordAsync( - user, - model.MasterPasswordHash, - model.Key, - model.OrgIdentifier); - - if (result.Succeeded) - { - return; - } - - foreach (var error in result.Errors) - { - ModelState.AddModelError(string.Empty, error.Description); - } - - throw new BadRequestException(ModelState); } [HttpPost("verify-password")] @@ -432,16 +457,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 +498,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] diff --git a/src/Api/Auth/Controllers/EmergencyAccessController.cs b/src/Api/Auth/Controllers/EmergencyAccessController.cs index 016cd82fe2..bd87e82c8a 100644 --- a/src/Api/Auth/Controllers/EmergencyAccessController.cs +++ b/src/Api/Auth/Controllers/EmergencyAccessController.cs @@ -7,7 +7,7 @@ using Bit.Api.Auth.Models.Request; using Bit.Api.Auth.Models.Response; using Bit.Api.Models.Response; using Bit.Api.Vault.Models.Response; -using Bit.Core.Auth.Services; +using Bit.Core.Auth.UserFeatures.EmergencyAccess; using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.Services; diff --git a/src/Api/Auth/Controllers/TwoFactorController.cs b/src/Api/Auth/Controllers/TwoFactorController.cs index 0af46fb57c..ba6cf66859 100644 --- a/src/Api/Auth/Controllers/TwoFactorController.cs +++ b/src/Api/Auth/Controllers/TwoFactorController.cs @@ -9,7 +9,6 @@ using Bit.Api.Models.Response; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Identity; using Bit.Core.Auth.Identity.TokenProviders; -using Bit.Core.Auth.LoginFeatures.PasswordlessLogin.Interfaces; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Services; using Bit.Core.Context; @@ -35,7 +34,7 @@ public class TwoFactorController : Controller private readonly IOrganizationService _organizationService; private readonly UserManager _userManager; private readonly ICurrentContext _currentContext; - private readonly IVerifyAuthRequestCommand _verifyAuthRequestCommand; + private readonly IAuthRequestRepository _authRequestRepository; private readonly IDuoUniversalTokenService _duoUniversalTokenService; private readonly IDataProtectorTokenFactory _twoFactorAuthenticatorDataProtector; private readonly IDataProtectorTokenFactory _ssoEmailTwoFactorSessionDataProtector; @@ -47,7 +46,7 @@ public class TwoFactorController : Controller IOrganizationService organizationService, UserManager userManager, ICurrentContext currentContext, - IVerifyAuthRequestCommand verifyAuthRequestCommand, + IAuthRequestRepository authRequestRepository, IDuoUniversalTokenService duoUniversalConfigService, IDataProtectorTokenFactory twoFactorAuthenticatorDataProtector, IDataProtectorTokenFactory ssoEmailTwoFactorSessionDataProtector, @@ -58,7 +57,7 @@ public class TwoFactorController : Controller _organizationService = organizationService; _userManager = userManager; _currentContext = currentContext; - _verifyAuthRequestCommand = verifyAuthRequestCommand; + _authRequestRepository = authRequestRepository; _duoUniversalTokenService = duoUniversalConfigService; _twoFactorAuthenticatorDataProtector = twoFactorAuthenticatorDataProtector; _ssoEmailTwoFactorSessionDataProtector = ssoEmailTwoFactorSessionDataProtector; @@ -350,14 +349,15 @@ public class TwoFactorController : Controller if (user != null) { - // Check if 2FA email is from Passwordless. + // Check if 2FA email is from a device approval ("Log in with device") scenario. if (!string.IsNullOrEmpty(requestModel.AuthRequestAccessCode)) { - if (await _verifyAuthRequestCommand - .VerifyAuthRequestAsync(new Guid(requestModel.AuthRequestId), - requestModel.AuthRequestAccessCode)) + var authRequest = await _authRequestRepository.GetByIdAsync(new Guid(requestModel.AuthRequestId)); + if (authRequest != null && + authRequest.IsValidForAuthentication(user.Id, requestModel.AuthRequestAccessCode)) { await _twoFactorEmailService.SendTwoFactorEmailAsync(user); + return; } } else if (!string.IsNullOrEmpty(requestModel.SsoEmail2FaSessionToken)) diff --git a/src/Api/Auth/Controllers/WebAuthnController.cs b/src/Api/Auth/Controllers/WebAuthnController.cs index 60b8621c5e..833087e99c 100644 --- a/src/Api/Auth/Controllers/WebAuthnController.cs +++ b/src/Api/Auth/Controllers/WebAuthnController.cs @@ -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> Get() { @@ -71,6 +71,7 @@ public class WebAuthnController : Controller return new ListResponseModel(credentials.Select(c => new WebAuthnCredentialResponseModel(c))); } + [Authorize(Policies.Application)] [HttpPost("attestation-options")] public async Task AttestationOptions([FromBody] SecretVerificationRequestModel model) { @@ -88,6 +89,7 @@ public class WebAuthnController : Controller }; } + [Authorize(Policies.Web)] [HttpPost("assertion-options")] public async Task 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) { diff --git a/src/Api/Auth/Jobs/EmergencyAccessNotificationJob.cs b/src/Api/Auth/Jobs/EmergencyAccessNotificationJob.cs index c67cb9db3f..f58eaafaab 100644 --- a/src/Api/Auth/Jobs/EmergencyAccessNotificationJob.cs +++ b/src/Api/Auth/Jobs/EmergencyAccessNotificationJob.cs @@ -1,7 +1,7 @@ // FIXME: Update this file to be null safe and then delete the line below #nullable disable -using Bit.Core.Auth.Services; +using Bit.Core.Auth.UserFeatures.EmergencyAccess; using Bit.Core.Jobs; using Quartz; diff --git a/src/Api/Auth/Jobs/EmergencyAccessTimeoutJob.cs b/src/Api/Auth/Jobs/EmergencyAccessTimeoutJob.cs index f23774f060..63b861d920 100644 --- a/src/Api/Auth/Jobs/EmergencyAccessTimeoutJob.cs +++ b/src/Api/Auth/Jobs/EmergencyAccessTimeoutJob.cs @@ -1,7 +1,7 @@ // FIXME: Update this file to be null safe and then delete the line below #nullable disable -using Bit.Core.Auth.Services; +using Bit.Core.Auth.UserFeatures.EmergencyAccess; using Bit.Core.Jobs; using Quartz; diff --git a/src/Api/Auth/Models/Request/Accounts/SetInitialPasswordRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/SetInitialPasswordRequestModel.cs new file mode 100644 index 0000000000..55ffdca94b --- /dev/null +++ b/src/Api/Auth/Models/Request/Accounts/SetInitialPasswordRequestModel.cs @@ -0,0 +1,160 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Api.KeyManagement.Models.Requests; +using Bit.Core.Auth.Models.Api.Request.Accounts; +using Bit.Core.Auth.Models.Data; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.KeyManagement.Models.Api.Request; +using Bit.Core.Utilities; + +namespace Bit.Api.Auth.Models.Request.Accounts; + +public class SetInitialPasswordRequestModel : IValidatableObject +{ + // TODO will be removed with https://bitwarden.atlassian.net/browse/PM-27327 + [Obsolete("Use MasterPasswordAuthentication instead")] + [StringLength(300)] + public string? MasterPasswordHash { get; set; } + + [Obsolete("Use MasterPasswordUnlock instead")] + public string? Key { get; set; } + + [Obsolete("Use AccountKeys instead")] + public KeysRequestModel? Keys { get; set; } + + [Obsolete("Use MasterPasswordAuthentication instead")] + public KdfType? Kdf { get; set; } + + [Obsolete("Use MasterPasswordAuthentication instead")] + public int? KdfIterations { get; set; } + + [Obsolete("Use MasterPasswordAuthentication instead")] + public int? KdfMemory { get; set; } + + [Obsolete("Use MasterPasswordAuthentication instead")] + public int? KdfParallelism { get; set; } + + public MasterPasswordAuthenticationDataRequestModel? MasterPasswordAuthentication { get; set; } + public MasterPasswordUnlockDataRequestModel? MasterPasswordUnlock { get; set; } + public AccountKeysRequestModel? AccountKeys { get; set; } + + [StringLength(50)] + public string? MasterPasswordHint { get; set; } + + [Required] + public required string OrgIdentifier { get; set; } + + // TODO removed with https://bitwarden.atlassian.net/browse/PM-27327 + public User ToUser(User existingUser) + { + existingUser.MasterPasswordHint = MasterPasswordHint; + existingUser.Kdf = Kdf!.Value; + existingUser.KdfIterations = KdfIterations!.Value; + existingUser.KdfMemory = KdfMemory; + existingUser.KdfParallelism = KdfParallelism; + existingUser.Key = Key; + Keys?.ToUser(existingUser); + return existingUser; + } + + public IEnumerable Validate(ValidationContext validationContext) + { + if (IsV2Request()) + { + // V2 registration + + // Validate Kdf + var authenticationKdf = MasterPasswordAuthentication!.Kdf.ToData(); + var unlockKdf = MasterPasswordUnlock!.Kdf.ToData(); + + // Currently, KDF settings are not saved separately for authentication and unlock and must therefore be equal + if (!authenticationKdf.Equals(unlockKdf)) + { + yield return new ValidationResult("KDF settings must be equal for authentication and unlock.", + [$"{nameof(MasterPasswordAuthentication)}.{nameof(MasterPasswordAuthenticationDataRequestModel.Kdf)}", + $"{nameof(MasterPasswordUnlock)}.{nameof(MasterPasswordUnlockDataRequestModel.Kdf)}"]); + } + + var authenticationValidationErrors = KdfSettingsValidator.Validate(authenticationKdf).ToList(); + if (authenticationValidationErrors.Count != 0) + { + yield return authenticationValidationErrors.First(); + } + + var unlockValidationErrors = KdfSettingsValidator.Validate(unlockKdf).ToList(); + if (unlockValidationErrors.Count != 0) + { + yield return unlockValidationErrors.First(); + } + + yield break; + } + + // V1 registration + // TODO removed with https://bitwarden.atlassian.net/browse/PM-27327 + if (string.IsNullOrEmpty(MasterPasswordHash)) + { + yield return new ValidationResult("MasterPasswordHash must be supplied."); + } + + if (string.IsNullOrEmpty(Key)) + { + yield return new ValidationResult("Key must be supplied."); + } + + if (Kdf == null) + { + yield return new ValidationResult("Kdf must be supplied."); + yield break; + } + + if (KdfIterations == null) + { + yield return new ValidationResult("KdfIterations must be supplied."); + yield break; + } + + 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."); + } + } + + var validationErrors = KdfSettingsValidator + .Validate(Kdf!.Value, KdfIterations!.Value, KdfMemory, KdfParallelism).ToList(); + if (validationErrors.Count != 0) + { + yield return validationErrors.First(); + } + } + + public bool IsV2Request() + { + // AccountKeys can be null for TDE users, so we don't check that here + return MasterPasswordAuthentication != null && MasterPasswordUnlock != null; + } + + public bool IsTdeSetPasswordRequest() + { + return AccountKeys == null; + } + + public SetInitialMasterPasswordDataModel ToData() + { + return new SetInitialMasterPasswordDataModel + { + MasterPasswordAuthentication = MasterPasswordAuthentication!.ToData(), + MasterPasswordUnlock = MasterPasswordUnlock!.ToData(), + OrgSsoIdentifier = OrgIdentifier, + AccountKeys = AccountKeys?.ToAccountKeysData(), + MasterPasswordHint = MasterPasswordHint + }; + } +} diff --git a/src/Api/Auth/Models/Request/Accounts/SetPasswordRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/SetPasswordRequestModel.cs deleted file mode 100644 index 0d809c6c11..0000000000 --- a/src/Api/Auth/Models/Request/Accounts/SetPasswordRequestModel.cs +++ /dev/null @@ -1,40 +0,0 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using System.ComponentModel.DataAnnotations; -using Bit.Core.Auth.Models.Api.Request.Accounts; -using Bit.Core.Entities; -using Bit.Core.Enums; - -namespace Bit.Api.Auth.Models.Request.Accounts; - -public class SetPasswordRequestModel -{ - [Required] - [StringLength(300)] - public string MasterPasswordHash { get; set; } - [Required] - public string Key { get; set; } - [StringLength(50)] - public string MasterPasswordHint { get; set; } - 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; } - public string OrgIdentifier { get; set; } - - public User ToUser(User existingUser) - { - existingUser.MasterPasswordHint = MasterPasswordHint; - existingUser.Kdf = Kdf; - existingUser.KdfIterations = KdfIterations; - existingUser.KdfMemory = KdfMemory; - existingUser.KdfParallelism = KdfParallelism; - existingUser.Key = Key; - Keys?.ToUser(existingUser); - return existingUser; - } -} diff --git a/src/Api/Auth/Models/Request/EmergencyAccessRequestModels.cs b/src/Api/Auth/Models/Request/EmergencyAccessRequestModels.cs index 33a7e52791..75e96ebc66 100644 --- a/src/Api/Auth/Models/Request/EmergencyAccessRequestModels.cs +++ b/src/Api/Auth/Models/Request/EmergencyAccessRequestModels.cs @@ -36,7 +36,7 @@ public class EmergencyAccessUpdateRequestModel existingEmergencyAccess.KeyEncrypted = KeyEncrypted; } existingEmergencyAccess.Type = Type; - existingEmergencyAccess.WaitTimeDays = WaitTimeDays; + existingEmergencyAccess.WaitTimeDays = (short)WaitTimeDays; return existingEmergencyAccess; } } diff --git a/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs b/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs index 79df29c928..6173de81d9 100644 --- a/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs +++ b/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs @@ -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) }); } diff --git a/src/Api/Billing/Controllers/AccountsBillingController.cs b/src/Api/Billing/Controllers/AccountsBillingController.cs index 7abcf8c357..243f4d3c53 100644 --- a/src/Api/Billing/Controllers/AccountsBillingController.cs +++ b/src/Api/Billing/Controllers/AccountsBillingController.cs @@ -1,7 +1,5 @@ -#nullable enable -using Bit.Api.Billing.Models.Responses; +using Bit.Api.Billing.Models.Responses; using Bit.Core.Billing.Services; -using Bit.Core.Billing.Tax.Requests; using Bit.Core.Services; using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; @@ -12,10 +10,11 @@ namespace Bit.Api.Billing.Controllers; [Route("accounts/billing")] [Authorize("Application")] public class AccountsBillingController( - IPaymentService paymentService, + IStripePaymentService paymentService, IUserService userService, IPaymentHistoryService paymentHistoryService) : Controller { + // TODO: Migrate to Query / AccountBillingVNextController [HttpGet("history")] [SelfHosted(NotSelfHostedOnly = true)] public async Task GetBillingHistoryAsync() @@ -30,20 +29,7 @@ public class AccountsBillingController( return new BillingHistoryResponseModel(billingInfo); } - [HttpGet("payment-method")] - [SelfHosted(NotSelfHostedOnly = true)] - public async Task GetPaymentMethodAsync() - { - var user = await userService.GetUserByPrincipalAsync(User); - if (user == null) - { - throw new UnauthorizedAccessException(); - } - - var billingInfo = await paymentService.GetBillingAsync(user); - return new BillingPaymentResponseModel(billingInfo); - } - + // TODO: Migrate to Query / AccountBillingVNextController [HttpGet("invoices")] public async Task GetInvoicesAsync([FromQuery] string? status = null, [FromQuery] string? startAfter = null) { @@ -62,6 +48,7 @@ public class AccountsBillingController( return TypedResults.Ok(invoices); } + // TODO: Migrate to Query / AccountBillingVNextController [HttpGet("transactions")] public async Task GetTransactionsAsync([FromQuery] DateTime? startAfter = null) { @@ -78,18 +65,4 @@ public class AccountsBillingController( return TypedResults.Ok(transactions); } - - [HttpPost("preview-invoice")] - public async Task PreviewInvoiceAsync([FromBody] PreviewIndividualInvoiceRequestBody model) - { - var user = await userService.GetUserByPrincipalAsync(User); - if (user == null) - { - throw new UnauthorizedAccessException(); - } - - var invoice = await paymentService.PreviewInvoiceAsync(model, user.GatewayCustomerId, user.GatewaySubscriptionId); - - return TypedResults.Ok(invoice); - } } diff --git a/src/Api/Billing/Controllers/AccountsController.cs b/src/Api/Billing/Controllers/AccountsController.cs index 075218dd74..c90b927bee 100644 --- a/src/Api/Billing/Controllers/AccountsController.cs +++ b/src/Api/Billing/Controllers/AccountsController.cs @@ -1,17 +1,12 @@ -#nullable enable - -using Bit.Api.Models.Request; +using Bit.Api.Models.Request; using Bit.Api.Models.Request.Accounts; using Bit.Api.Models.Response; using Bit.Api.Utilities; using Bit.Core; -using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Billing.Models; using Bit.Core.Billing.Models.Business; using Bit.Core.Billing.Services; using Bit.Core.Exceptions; -using Bit.Core.KeyManagement.Queries.Interfaces; -using Bit.Core.Models.Business; using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Utilities; @@ -24,61 +19,14 @@ namespace Bit.Api.Billing.Controllers; [Authorize("Application")] public class AccountsController( IUserService userService, - ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, - IUserAccountKeysQuery userAccountKeysQuery, - IFeatureService featureService) : Controller + IFeatureService featureService, + ILicensingService licensingService) : Controller { - [HttpPost("premium")] - public async Task PostPremiumAsync( - PremiumRequestModel model, - [FromServices] GlobalSettings globalSettings) - { - var user = await userService.GetUserByPrincipalAsync(User); - if (user == null) - { - throw new UnauthorizedAccessException(); - } - - var valid = model.Validate(globalSettings); - UserLicense? license = null; - if (valid && globalSettings.SelfHosted) - { - license = await ApiHelpers.ReadJsonFileFromBody(HttpContext, model.License); - } - - if (!valid && !globalSettings.SelfHosted && string.IsNullOrWhiteSpace(model.Country)) - { - throw new BadRequestException("Country is required."); - } - - if (!valid || (globalSettings.SelfHosted && license == null)) - { - throw new BadRequestException("Invalid license."); - } - - var result = await userService.SignUpPremiumAsync(user, model.PaymentToken, - model.PaymentMethodType!.Value, model.AdditionalStorageGb.GetValueOrDefault(0), license, - new TaxInfo { BillingAddressCountry = model.Country, BillingAddressPostalCode = model.PostalCode }); - - var userTwoFactorEnabled = await twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user); - var userHasPremiumFromOrganization = await userService.HasPremiumFromOrganization(user); - var organizationIdsClaimingActiveUser = await GetOrganizationIdsClaimingUserAsync(user.Id); - var accountKeys = await userAccountKeysQuery.Run(user); - - var profile = new ProfileResponseModel(user, accountKeys, null, null, null, userTwoFactorEnabled, - userHasPremiumFromOrganization, organizationIdsClaimingActiveUser); - return new PaymentResponseModel - { - UserProfile = profile, - PaymentIntentClientSecret = result.Item2, - Success = result.Item1 - }; - } - + // TODO: Remove with deletion of pm-29594-update-individual-subscription-page [HttpGet("subscription")] public async Task GetSubscriptionAsync( [FromServices] GlobalSettings globalSettings, - [FromServices] IPaymentService paymentService) + [FromServices] IStripePaymentService paymentService) { var user = await userService.GetUserByPrincipalAsync(User); if (user == null) @@ -97,12 +45,14 @@ public class AccountsController( var includeMilestone2Discount = featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2); var subscriptionInfo = await paymentService.GetSubscriptionAsync(user); var license = await userService.GenerateLicenseAsync(user, subscriptionInfo); - return new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount); + var claimsPrincipal = licensingService.GetClaimsPrincipalFromLicense(license); + return new SubscriptionResponseModel(user, subscriptionInfo, license, claimsPrincipal, includeMilestone2Discount); } else { var license = await userService.GenerateLicenseAsync(user); - return new SubscriptionResponseModel(user, license); + var claimsPrincipal = licensingService.GetClaimsPrincipalFromLicense(license); + return new SubscriptionResponseModel(user, null, license, claimsPrincipal); } } else @@ -111,29 +61,7 @@ public class AccountsController( } } - [HttpPost("payment")] - [SelfHosted(NotSelfHostedOnly = true)] - public async Task PostPaymentAsync([FromBody] PaymentRequestModel model) - { - var user = await userService.GetUserByPrincipalAsync(User); - if (user == null) - { - throw new UnauthorizedAccessException(); - } - - await userService.ReplacePaymentMethodAsync(user, model.PaymentToken, model.PaymentMethodType!.Value, - new TaxInfo - { - BillingAddressLine1 = model.Line1, - BillingAddressLine2 = model.Line2, - BillingAddressCity = model.City, - BillingAddressState = model.State, - BillingAddressCountry = model.Country, - BillingAddressPostalCode = model.PostalCode, - TaxIdNumber = model.TaxId - }); - } - + // TODO: Remove with deletion of pm-29594-update-individual-subscription-page [HttpPost("storage")] [SelfHosted(NotSelfHostedOnly = true)] public async Task PostStorageAsync([FromBody] StorageRequestModel model) @@ -148,8 +76,11 @@ public class AccountsController( return new PaymentResponseModel { Success = true, PaymentIntentClientSecret = result }; } - - + /* + * TODO: A new version of this exists in the AccountBillingVNextController. + * The individual-self-hosting-license-uploader.component needs to be updated to use it. + * Then, this can be removed. + */ [HttpPost("license")] [SelfHosted(SelfHostedOnly = true)] public async Task PostLicenseAsync(LicenseRequestModel model) @@ -169,6 +100,7 @@ public class AccountsController( await userService.UpdateLicenseAsync(user, license); } + // TODO: Migrate to Command / AccountBillingVNextController as DELETE /account/billing/vnext/subscription [HttpPost("cancel")] public async Task PostCancelAsync( [FromBody] SubscriptionCancellationRequestModel request, @@ -186,6 +118,7 @@ public class AccountsController( user.IsExpired()); } + // TODO: Remove with deletion of pm-29594-update-individual-subscription-page [HttpPost("reinstate-premium")] [SelfHosted(NotSelfHostedOnly = true)] public async Task PostReinstateAsync() @@ -198,45 +131,4 @@ public class AccountsController( await userService.ReinstatePremiumAsync(user); } - - [HttpGet("tax")] - [SelfHosted(NotSelfHostedOnly = true)] - public async Task GetTaxInfoAsync( - [FromServices] IPaymentService paymentService) - { - var user = await userService.GetUserByPrincipalAsync(User); - if (user == null) - { - throw new UnauthorizedAccessException(); - } - - var taxInfo = await paymentService.GetTaxInfoAsync(user); - return new TaxInfoResponseModel(taxInfo); - } - - [HttpPut("tax")] - [SelfHosted(NotSelfHostedOnly = true)] - public async Task PutTaxInfoAsync( - [FromBody] TaxInfoUpdateRequestModel model, - [FromServices] IPaymentService paymentService) - { - var user = await userService.GetUserByPrincipalAsync(User); - if (user == null) - { - throw new UnauthorizedAccessException(); - } - - var taxInfo = new TaxInfo - { - BillingAddressPostalCode = model.PostalCode, - BillingAddressCountry = model.Country, - }; - await paymentService.SaveTaxInfoAsync(user, taxInfo); - } - - private async Task> GetOrganizationIdsClaimingUserAsync(Guid userId) - { - var organizationsClaimingUser = await userService.GetOrganizationsClaimingUserAsync(userId); - return organizationsClaimingUser.Select(o => o.Id); - } } diff --git a/src/Api/Billing/Controllers/InvoicesController.cs b/src/Api/Billing/Controllers/InvoicesController.cs deleted file mode 100644 index 30ea975e09..0000000000 --- a/src/Api/Billing/Controllers/InvoicesController.cs +++ /dev/null @@ -1,45 +0,0 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using Bit.Core.AdminConsole.Entities; -using Bit.Core.Billing.Tax.Requests; -using Bit.Core.Context; -using Bit.Core.Repositories; -using Bit.Core.Services; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; - -namespace Bit.Api.Billing.Controllers; - -[Route("invoices")] -[Authorize("Application")] -public class InvoicesController : BaseBillingController -{ - [HttpPost("preview-organization")] - public async Task PreviewInvoiceAsync( - [FromBody] PreviewOrganizationInvoiceRequestBody model, - [FromServices] ICurrentContext currentContext, - [FromServices] IOrganizationRepository organizationRepository, - [FromServices] IPaymentService paymentService) - { - Organization organization = null; - if (model.OrganizationId != default) - { - if (!await currentContext.EditPaymentMethods(model.OrganizationId)) - { - return Error.Unauthorized(); - } - - organization = await organizationRepository.GetByIdAsync(model.OrganizationId); - if (organization == null) - { - return Error.NotFound(); - } - } - - var invoice = await paymentService.PreviewInvoiceAsync(model, organization?.GatewayCustomerId, - organization?.GatewaySubscriptionId); - - return TypedResults.Ok(invoice); - } -} diff --git a/src/Api/Billing/Controllers/OrganizationBillingController.cs b/src/Api/Billing/Controllers/OrganizationBillingController.cs index 6e4cacc155..e06d946ea0 100644 --- a/src/Api/Billing/Controllers/OrganizationBillingController.cs +++ b/src/Api/Billing/Controllers/OrganizationBillingController.cs @@ -5,7 +5,6 @@ using Bit.Core.Billing.Providers.Services; using Bit.Core.Billing.Services; using Bit.Core.Context; using Bit.Core.Repositories; -using Bit.Core.Services; using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -19,10 +18,10 @@ public class OrganizationBillingController( ICurrentContext currentContext, IOrganizationBillingService organizationBillingService, IOrganizationRepository organizationRepository, - IPaymentService paymentService, - ISubscriberService subscriberService, + IStripePaymentService paymentService, IPaymentHistoryService paymentHistoryService) : BaseBillingController { + // TODO: Remove when pm-25379-use-new-organization-metadata-structure is removed. [HttpGet("metadata")] public async Task GetMetadataAsync([FromRoute] Guid organizationId) { @@ -41,6 +40,7 @@ public class OrganizationBillingController( return TypedResults.Ok(metadata); } + // TODO: Migrate to Query / OrganizationBillingVNextController [HttpGet("history")] public async Task GetHistoryAsync([FromRoute] Guid organizationId) { @@ -61,6 +61,7 @@ public class OrganizationBillingController( return TypedResults.Ok(billingInfo); } + // TODO: Migrate to Query / OrganizationBillingVNextController [HttpGet("invoices")] public async Task GetInvoicesAsync([FromRoute] Guid organizationId, [FromQuery] string? status = null, [FromQuery] string? startAfter = null) { @@ -85,6 +86,7 @@ public class OrganizationBillingController( return TypedResults.Ok(invoices); } + // TODO: Migrate to Query / OrganizationBillingVNextController [HttpGet("transactions")] public async Task GetTransactionsAsync([FromRoute] Guid organizationId, [FromQuery] DateTime? startAfter = null) { @@ -108,6 +110,7 @@ public class OrganizationBillingController( return TypedResults.Ok(transactions); } + // TODO: Can be removed once we do away with the organization-plans.component. [HttpGet] [SelfHosted(NotSelfHostedOnly = true)] public async Task GetBillingAsync(Guid organizationId) @@ -131,127 +134,7 @@ public class OrganizationBillingController( return TypedResults.Ok(response); } - [HttpGet("payment-method")] - public async Task GetPaymentMethodAsync([FromRoute] Guid organizationId) - { - if (!await currentContext.EditPaymentMethods(organizationId)) - { - return Error.Unauthorized(); - } - - var organization = await organizationRepository.GetByIdAsync(organizationId); - - if (organization == null) - { - return Error.NotFound(); - } - - var paymentMethod = await subscriberService.GetPaymentMethod(organization); - - var response = PaymentMethodResponse.From(paymentMethod); - - return TypedResults.Ok(response); - } - - [HttpPut("payment-method")] - public async Task UpdatePaymentMethodAsync( - [FromRoute] Guid organizationId, - [FromBody] UpdatePaymentMethodRequestBody requestBody) - { - if (!await currentContext.EditPaymentMethods(organizationId)) - { - return Error.Unauthorized(); - } - - var organization = await organizationRepository.GetByIdAsync(organizationId); - - if (organization == null) - { - return Error.NotFound(); - } - - var tokenizedPaymentSource = requestBody.PaymentSource.ToDomain(); - - var taxInformation = requestBody.TaxInformation.ToDomain(); - - await organizationBillingService.UpdatePaymentMethod(organization, tokenizedPaymentSource, taxInformation); - - return TypedResults.Ok(); - } - - [HttpPost("payment-method/verify-bank-account")] - public async Task VerifyBankAccountAsync( - [FromRoute] Guid organizationId, - [FromBody] VerifyBankAccountRequestBody requestBody) - { - if (!await currentContext.EditPaymentMethods(organizationId)) - { - return Error.Unauthorized(); - } - - if (requestBody.DescriptorCode.Length != 6 || !requestBody.DescriptorCode.StartsWith("SM")) - { - return Error.BadRequest("Statement descriptor should be a 6-character value that starts with 'SM'"); - } - - var organization = await organizationRepository.GetByIdAsync(organizationId); - - if (organization == null) - { - return Error.NotFound(); - } - - await subscriberService.VerifyBankAccount(organization, requestBody.DescriptorCode); - - return TypedResults.Ok(); - } - - [HttpGet("tax-information")] - public async Task GetTaxInformationAsync([FromRoute] Guid organizationId) - { - if (!await currentContext.EditPaymentMethods(organizationId)) - { - return Error.Unauthorized(); - } - - var organization = await organizationRepository.GetByIdAsync(organizationId); - - if (organization == null) - { - return Error.NotFound(); - } - - var taxInformation = await subscriberService.GetTaxInformation(organization); - - var response = TaxInformationResponse.From(taxInformation); - - return TypedResults.Ok(response); - } - - [HttpPut("tax-information")] - public async Task UpdateTaxInformationAsync( - [FromRoute] Guid organizationId, - [FromBody] TaxInformationRequestBody requestBody) - { - if (!await currentContext.EditPaymentMethods(organizationId)) - { - return Error.Unauthorized(); - } - - var organization = await organizationRepository.GetByIdAsync(organizationId); - - if (organization == null) - { - return Error.NotFound(); - } - - var taxInformation = requestBody.ToDomain(); - - await subscriberService.UpdateTaxInformation(organization, taxInformation); - - return TypedResults.Ok(); - } - + // TODO: Migrate to Command / OrganizationBillingVNextController [HttpPost("setup-business-unit")] [SelfHosted(NotSelfHostedOnly = true)] public async Task SetupBusinessUnitAsync( @@ -280,6 +163,7 @@ public class OrganizationBillingController( return TypedResults.Ok(providerId); } + // TODO: Migrate to Command / OrganizationBillingVNextController [HttpPost("change-frequency")] [SelfHosted(NotSelfHostedOnly = true)] public async Task ChangePlanSubscriptionFrequencyAsync( diff --git a/src/Api/Billing/Controllers/OrganizationsController.cs b/src/Api/Billing/Controllers/OrganizationsController.cs index 5494c5a90e..bca5605a8c 100644 --- a/src/Api/Billing/Controllers/OrganizationsController.cs +++ b/src/Api/Billing/Controllers/OrganizationsController.cs @@ -19,7 +19,6 @@ using Bit.Core.Billing.Services; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Exceptions; -using Bit.Core.Models.Business; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.Repositories; using Bit.Core.Services; @@ -37,7 +36,7 @@ public class OrganizationsController( IOrganizationUserRepository organizationUserRepository, IOrganizationService organizationService, IUserService userService, - IPaymentService paymentService, + IStripePaymentService paymentService, ICurrentContext currentContext, IGetCloudOrganizationLicenseQuery getCloudOrganizationLicenseQuery, GlobalSettings globalSettings, @@ -67,7 +66,8 @@ public class OrganizationsController( if (globalSettings.SelfHosted) { var orgLicense = await licensingService.ReadOrganizationLicenseAsync(organization); - return new OrganizationSubscriptionResponseModel(organization, orgLicense); + var claimsPrincipal = licensingService.GetClaimsPrincipalFromLicense(orgLicense); + return new OrganizationSubscriptionResponseModel(organization, orgLicense, claimsPrincipal); } var plan = await pricingClient.GetPlanOrThrow(organization.PlanType); @@ -248,53 +248,6 @@ public class OrganizationsController( await organizationService.ReinstateSubscriptionAsync(id); } - [HttpGet("{id:guid}/tax")] - [SelfHosted(NotSelfHostedOnly = true)] - public async Task GetTaxInfo(Guid id) - { - if (!await currentContext.OrganizationOwner(id)) - { - throw new NotFoundException(); - } - - var organization = await organizationRepository.GetByIdAsync(id); - if (organization == null) - { - throw new NotFoundException(); - } - - var taxInfo = await paymentService.GetTaxInfoAsync(organization); - return new TaxInfoResponseModel(taxInfo); - } - - [HttpPut("{id:guid}/tax")] - [SelfHosted(NotSelfHostedOnly = true)] - public async Task PutTaxInfo(Guid id, [FromBody] ExpandedTaxInfoUpdateRequestModel model) - { - if (!await currentContext.OrganizationOwner(id)) - { - throw new NotFoundException(); - } - - var organization = await organizationRepository.GetByIdAsync(id); - if (organization == null) - { - throw new NotFoundException(); - } - - var taxInfo = new TaxInfo - { - TaxIdNumber = model.TaxId, - BillingAddressLine1 = model.Line1, - BillingAddressLine2 = model.Line2, - BillingAddressCity = model.City, - BillingAddressState = model.State, - BillingAddressPostalCode = model.PostalCode, - BillingAddressCountry = model.Country, - }; - await paymentService.SaveTaxInfoAsync(organization, taxInfo); - } - /// /// Tries to grant owner access to the Secrets Manager for the organization /// diff --git a/src/Api/Billing/Controllers/ProviderBillingController.cs b/src/Api/Billing/Controllers/ProviderBillingController.cs index 006a7ce068..dfa705a329 100644 --- a/src/Api/Billing/Controllers/ProviderBillingController.cs +++ b/src/Api/Billing/Controllers/ProviderBillingController.cs @@ -1,7 +1,6 @@ // FIXME: Update this file to be null safe and then delete the line below #nullable disable -using Bit.Api.Billing.Models.Requests; using Bit.Api.Billing.Models.Responses; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Pricing; @@ -9,7 +8,6 @@ using Bit.Core.Billing.Providers.Models; using Bit.Core.Billing.Providers.Repositories; using Bit.Core.Billing.Providers.Services; using Bit.Core.Billing.Services; -using Bit.Core.Billing.Tax.Models; using Bit.Core.Context; using Bit.Core.Models.BitStripe; using Bit.Core.Services; @@ -34,6 +32,7 @@ public class ProviderBillingController( IStripeAdapter stripeAdapter, IUserService userService) : BaseProviderController(currentContext, logger, providerRepository, userService) { + // TODO: Migrate to Query / ProviderBillingVNextController [HttpGet("invoices")] public async Task GetInvoicesAsync([FromRoute] Guid providerId) { @@ -44,7 +43,7 @@ public class ProviderBillingController( return result; } - var invoices = await stripeAdapter.InvoiceListAsync(new StripeInvoiceListOptions + var invoices = await stripeAdapter.ListInvoicesAsync(new StripeInvoiceListOptions { Customer = provider.GatewayCustomerId }); @@ -54,6 +53,7 @@ public class ProviderBillingController( return TypedResults.Ok(response); } + // TODO: Migrate to Query / ProviderBillingVNextController [HttpGet("invoices/{invoiceId}")] public async Task GenerateClientInvoiceReportAsync([FromRoute] Guid providerId, string invoiceId) { @@ -76,51 +76,7 @@ public class ProviderBillingController( "text/csv"); } - [HttpPut("payment-method")] - public async Task UpdatePaymentMethodAsync( - [FromRoute] Guid providerId, - [FromBody] UpdatePaymentMethodRequestBody requestBody) - { - var (provider, result) = await TryGetBillableProviderForAdminOperation(providerId); - - if (provider == null) - { - return result; - } - - var tokenizedPaymentSource = requestBody.PaymentSource.ToDomain(); - var taxInformation = requestBody.TaxInformation.ToDomain(); - - await providerBillingService.UpdatePaymentMethod( - provider, - tokenizedPaymentSource, - taxInformation); - - return TypedResults.Ok(); - } - - [HttpPost("payment-method/verify-bank-account")] - public async Task VerifyBankAccountAsync( - [FromRoute] Guid providerId, - [FromBody] VerifyBankAccountRequestBody requestBody) - { - var (provider, result) = await TryGetBillableProviderForAdminOperation(providerId); - - if (provider == null) - { - return result; - } - - if (requestBody.DescriptorCode.Length != 6 || !requestBody.DescriptorCode.StartsWith("SM")) - { - return Error.BadRequest("Statement descriptor should be a 6-character value that starts with 'SM'"); - } - - await subscriberService.VerifyBankAccount(provider, requestBody.DescriptorCode); - - return TypedResults.Ok(); - } - + // TODO: Migrate to Query / ProviderBillingVNextController [HttpGet("subscription")] public async Task GetSubscriptionAsync([FromRoute] Guid providerId) { @@ -131,7 +87,7 @@ public class ProviderBillingController( return result; } - var subscription = await stripeAdapter.SubscriptionGetAsync(provider.GatewaySubscriptionId, + var subscription = await stripeAdapter.GetSubscriptionAsync(provider.GatewaySubscriptionId, new SubscriptionGetOptions { Expand = ["customer.tax_ids", "discounts", "test_clock"] }); var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id); @@ -140,7 +96,7 @@ public class ProviderBillingController( { var plan = await pricingClient.GetPlanOrThrow(providerPlan.PlanType); var priceId = ProviderPriceAdapter.GetPriceId(provider, subscription, plan.Type); - var price = await stripeAdapter.PriceGetAsync(priceId); + var price = await stripeAdapter.GetPriceAsync(priceId); var unitAmount = price.UnitAmountDecimal.HasValue ? price.UnitAmountDecimal.Value / 100M @@ -172,53 +128,4 @@ public class ProviderBillingController( return TypedResults.Ok(response); } - - [HttpGet("tax-information")] - public async Task GetTaxInformationAsync([FromRoute] Guid providerId) - { - var (provider, result) = await TryGetBillableProviderForAdminOperation(providerId); - - if (provider == null) - { - return result; - } - - var taxInformation = await subscriberService.GetTaxInformation(provider); - - var response = TaxInformationResponse.From(taxInformation); - - return TypedResults.Ok(response); - } - - [HttpPut("tax-information")] - public async Task UpdateTaxInformationAsync( - [FromRoute] Guid providerId, - [FromBody] TaxInformationRequestBody requestBody) - { - var (provider, result) = await TryGetBillableProviderForAdminOperation(providerId); - - if (provider == null) - { - return result; - } - - if (requestBody is not { Country: not null, PostalCode: not null }) - { - return Error.BadRequest("Country and postal code are required to update your tax information."); - } - - var taxInformation = new TaxInformation( - requestBody.Country, - requestBody.PostalCode, - requestBody.TaxId, - requestBody.TaxIdType, - requestBody.Line1, - requestBody.Line2, - requestBody.City, - requestBody.State); - - await subscriberService.UpdateTaxInformation(provider, taxInformation); - - return TypedResults.Ok(); - } } diff --git a/src/Api/Billing/Controllers/StripeController.cs b/src/Api/Billing/Controllers/StripeController.cs index 15fccd16f4..6cb10e3165 100644 --- a/src/Api/Billing/Controllers/StripeController.cs +++ b/src/Api/Billing/Controllers/StripeController.cs @@ -1,5 +1,5 @@ -using Bit.Core.Billing.Tax.Services; -using Bit.Core.Services; +using Bit.Core.Billing.Services; +using Bit.Core.Billing.Tax.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; @@ -28,7 +28,7 @@ public class StripeController( Usage = "off_session" }; - var setupIntent = await stripeAdapter.SetupIntentCreate(options); + var setupIntent = await stripeAdapter.CreateSetupIntentAsync(options); return TypedResults.Ok(setupIntent.ClientSecret); } @@ -43,7 +43,7 @@ public class StripeController( Usage = "off_session" }; - var setupIntent = await stripeAdapter.SetupIntentCreate(options); + var setupIntent = await stripeAdapter.CreateSetupIntentAsync(options); return TypedResults.Ok(setupIntent.ClientSecret); } diff --git a/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs b/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs index b01b629e4f..6c56d6db3a 100644 --- a/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs +++ b/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs @@ -1,10 +1,14 @@ using Bit.Api.Billing.Attributes; using Bit.Api.Billing.Models.Requests.Payment; using Bit.Api.Billing.Models.Requests.Premium; +using Bit.Api.Billing.Models.Requests.Storage; using Bit.Core; +using Bit.Core.Billing.Licenses.Queries; using Bit.Core.Billing.Payment.Commands; using Bit.Core.Billing.Payment.Queries; using Bit.Core.Billing.Premium.Commands; +using Bit.Core.Billing.Subscriptions.Commands; +using Bit.Core.Billing.Subscriptions.Queries; using Bit.Core.Entities; using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; @@ -19,9 +23,14 @@ namespace Bit.Api.Billing.Controllers.VNext; public class AccountBillingVNextController( ICreateBitPayInvoiceForCreditCommand createBitPayInvoiceForCreditCommand, ICreatePremiumCloudHostedSubscriptionCommand createPremiumCloudHostedSubscriptionCommand, + IGetBitwardenSubscriptionQuery getBitwardenSubscriptionQuery, IGetCreditQuery getCreditQuery, IGetPaymentMethodQuery getPaymentMethodQuery, - IUpdatePaymentMethodCommand updatePaymentMethodCommand) : BaseBillingController + IGetUserLicenseQuery getUserLicenseQuery, + IReinstateSubscriptionCommand reinstateSubscriptionCommand, + IUpdatePaymentMethodCommand updatePaymentMethodCommand, + IUpdatePremiumStorageCommand updatePremiumStorageCommand, + IUpgradePremiumToOrganizationCommand upgradePremiumToOrganizationCommand) : BaseBillingController { [HttpGet("credit")] [InjectUser] @@ -66,7 +75,6 @@ public class AccountBillingVNextController( } [HttpPost("subscription")] - [RequireFeature(FeatureFlagKeys.PM24996ImplementUpgradeFromFreeDialog)] [InjectUser] public async Task CreateSubscriptionAsync( [BindNever] User user, @@ -77,4 +85,55 @@ public class AccountBillingVNextController( user, paymentMethod, billingAddress, additionalStorageGb); return Handle(result); } + + [HttpGet("license")] + [InjectUser] + public async Task GetLicenseAsync( + [BindNever] User user) + { + var response = await getUserLicenseQuery.Run(user); + return TypedResults.Ok(response); + } + + [HttpGet("subscription")] + [RequireFeature(FeatureFlagKeys.PM29594_UpdateIndividualSubscriptionPage)] + [InjectUser] + public async Task GetSubscriptionAsync( + [BindNever] User user) + { + var subscription = await getBitwardenSubscriptionQuery.Run(user); + return TypedResults.Ok(subscription); + } + + [HttpPost("subscription/reinstate")] + [RequireFeature(FeatureFlagKeys.PM29594_UpdateIndividualSubscriptionPage)] + [InjectUser] + public async Task ReinstateSubscriptionAsync( + [BindNever] User user) + { + var result = await reinstateSubscriptionCommand.Run(user); + return Handle(result); + } + + [HttpPut("subscription/storage")] + [RequireFeature(FeatureFlagKeys.PM29594_UpdateIndividualSubscriptionPage)] + [InjectUser] + public async Task UpdateSubscriptionStorageAsync( + [BindNever] User user, + [FromBody] StorageUpdateRequest request) + { + var result = await updatePremiumStorageCommand.Run(user, request.AdditionalStorageGb); + return Handle(result); + } + + [HttpPost("upgrade")] + [InjectUser] + public async Task UpgradePremiumToOrganizationAsync( + [BindNever] User user, + [FromBody] UpgradePremiumToOrganizationRequest request) + { + var (organizationName, key, planType) = request.ToDomain(); + var result = await upgradePremiumToOrganizationCommand.Run(user, organizationName, key, planType); + return Handle(result); + } } diff --git a/src/Api/Billing/Controllers/VNext/SelfHostedAccountBillingController.cs b/src/Api/Billing/Controllers/VNext/SelfHostedAccountBillingVNextController.cs similarity index 85% rename from src/Api/Billing/Controllers/VNext/SelfHostedAccountBillingController.cs rename to src/Api/Billing/Controllers/VNext/SelfHostedAccountBillingVNextController.cs index 973a7d99a1..ccc1dd9734 100644 --- a/src/Api/Billing/Controllers/VNext/SelfHostedAccountBillingController.cs +++ b/src/Api/Billing/Controllers/VNext/SelfHostedAccountBillingVNextController.cs @@ -1,8 +1,6 @@ -#nullable enable -using Bit.Api.Billing.Attributes; +using Bit.Api.Billing.Attributes; using Bit.Api.Billing.Models.Requests.Premium; using Bit.Api.Utilities; -using Bit.Core; using Bit.Core.Billing.Models.Business; using Bit.Core.Billing.Premium.Commands; using Bit.Core.Entities; @@ -17,11 +15,10 @@ namespace Bit.Api.Billing.Controllers.VNext; [Authorize("Application")] [Route("account/billing/vnext/self-host")] [SelfHosted(SelfHostedOnly = true)] -public class SelfHostedAccountBillingController( +public class SelfHostedAccountBillingVNextController( ICreatePremiumSelfHostedSubscriptionCommand createPremiumSelfHostedSubscriptionCommand) : BaseBillingController { [HttpPost("license")] - [RequireFeature(FeatureFlagKeys.PM24996ImplementUpgradeFromFreeDialog)] [InjectUser] public async Task UploadLicenseAsync( [BindNever] User user, diff --git a/src/Api/Billing/Controllers/VNext/SelfHostedBillingController.cs b/src/Api/Billing/Controllers/VNext/SelfHostedOrganizationBillingVNextController.cs similarity index 95% rename from src/Api/Billing/Controllers/VNext/SelfHostedBillingController.cs rename to src/Api/Billing/Controllers/VNext/SelfHostedOrganizationBillingVNextController.cs index bd40c777dc..625a97c998 100644 --- a/src/Api/Billing/Controllers/VNext/SelfHostedBillingController.cs +++ b/src/Api/Billing/Controllers/VNext/SelfHostedOrganizationBillingVNextController.cs @@ -14,7 +14,7 @@ namespace Bit.Api.Billing.Controllers.VNext; [Authorize("Application")] [Route("organizations/{organizationId:guid}/billing/vnext/self-host")] [SelfHosted(SelfHostedOnly = true)] -public class SelfHostedBillingController( +public class SelfHostedOrganizationBillingVNextController( IGetOrganizationMetadataQuery getOrganizationMetadataQuery) : BaseBillingController { [Authorize] diff --git a/src/Api/Billing/Models/Requests/KeyPairRequestBody.cs b/src/Api/Billing/Models/Requests/KeyPairRequestBody.cs index 2fec3bd61d..9979141b6d 100644 --- a/src/Api/Billing/Models/Requests/KeyPairRequestBody.cs +++ b/src/Api/Billing/Models/Requests/KeyPairRequestBody.cs @@ -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); + } } diff --git a/src/Api/Billing/Models/Requests/Premium/UpgradePremiumToOrganizationRequest.cs b/src/Api/Billing/Models/Requests/Premium/UpgradePremiumToOrganizationRequest.cs new file mode 100644 index 0000000000..14375efc78 --- /dev/null +++ b/src/Api/Billing/Models/Requests/Premium/UpgradePremiumToOrganizationRequest.cs @@ -0,0 +1,37 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using Bit.Core.Billing.Enums; + +namespace Bit.Api.Billing.Models.Requests.Premium; + +public class UpgradePremiumToOrganizationRequest +{ + [Required] + public string OrganizationName { get; set; } = null!; + + [Required] + public string Key { get; set; } = null!; + + [Required] + [JsonConverter(typeof(JsonStringEnumConverter))] + public ProductTierType Tier { get; set; } + + [Required] + [JsonConverter(typeof(JsonStringEnumConverter))] + public PlanCadenceType Cadence { get; set; } + + private PlanType PlanType => + Tier switch + { + ProductTierType.Families => PlanType.FamiliesAnnually, + ProductTierType.Teams => Cadence == PlanCadenceType.Monthly + ? PlanType.TeamsMonthly + : PlanType.TeamsAnnually, + ProductTierType.Enterprise => Cadence == PlanCadenceType.Monthly + ? PlanType.EnterpriseMonthly + : PlanType.EnterpriseAnnually, + _ => throw new InvalidOperationException("Cannot upgrade to an Organization subscription that isn't Families, Teams or Enterprise.") + }; + + public (string OrganizationName, string Key, PlanType PlanType) ToDomain() => (OrganizationName, Key, PlanType); +} diff --git a/src/Api/Billing/Models/Requests/Storage/StorageUpdateRequest.cs b/src/Api/Billing/Models/Requests/Storage/StorageUpdateRequest.cs new file mode 100644 index 0000000000..fe0c8e9e17 --- /dev/null +++ b/src/Api/Billing/Models/Requests/Storage/StorageUpdateRequest.cs @@ -0,0 +1,34 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Api.Billing.Models.Requests.Storage; + +/// +/// Request model for updating storage allocation on a user's premium subscription. +/// Allows for both increasing and decreasing storage in an idempotent manner. +/// +public class StorageUpdateRequest : IValidatableObject +{ + /// + /// The additional storage in GB beyond the base storage. + /// Must be between 0 and the maximum allowed (minus base storage). + /// + [Required] + public short AdditionalStorageGb { get; set; } + + public IEnumerable Validate(ValidationContext validationContext) + { + if (AdditionalStorageGb < 0) + { + yield return new ValidationResult( + "Additional storage cannot be negative.", + [nameof(AdditionalStorageGb)]); + } + + if (AdditionalStorageGb > 99) + { + yield return new ValidationResult( + "Maximum additional storage is 99 GB.", + [nameof(AdditionalStorageGb)]); + } + } +} diff --git a/src/Api/Billing/Models/Requests/TaxInformationRequestBody.cs b/src/Api/Billing/Models/Requests/TaxInformationRequestBody.cs deleted file mode 100644 index a1b754a9dc..0000000000 --- a/src/Api/Billing/Models/Requests/TaxInformationRequestBody.cs +++ /dev/null @@ -1,31 +0,0 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using System.ComponentModel.DataAnnotations; -using Bit.Core.Billing.Tax.Models; - -namespace Bit.Api.Billing.Models.Requests; - -public class TaxInformationRequestBody -{ - [Required] - public string Country { get; set; } - [Required] - public string PostalCode { get; set; } - public string TaxId { get; set; } - public string TaxIdType { get; set; } - public string Line1 { get; set; } - public string Line2 { get; set; } - public string City { get; set; } - public string State { get; set; } - - public TaxInformation ToDomain() => new( - Country, - PostalCode, - TaxId, - TaxIdType, - Line1, - Line2, - City, - State); -} diff --git a/src/Api/Billing/Models/Requests/TokenizedPaymentSourceRequestBody.cs b/src/Api/Billing/Models/Requests/TokenizedPaymentSourceRequestBody.cs deleted file mode 100644 index b469ce2576..0000000000 --- a/src/Api/Billing/Models/Requests/TokenizedPaymentSourceRequestBody.cs +++ /dev/null @@ -1,25 +0,0 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using System.ComponentModel.DataAnnotations; -using Bit.Api.Utilities; -using Bit.Core.Billing.Models; -using Bit.Core.Enums; - -namespace Bit.Api.Billing.Models.Requests; - -public class TokenizedPaymentSourceRequestBody -{ - [Required] - [EnumMatches( - PaymentMethodType.BankAccount, - PaymentMethodType.Card, - PaymentMethodType.PayPal, - ErrorMessage = "'type' must be BankAccount, Card or PayPal")] - public PaymentMethodType Type { get; set; } - - [Required] - public string Token { get; set; } - - public TokenizedPaymentSource ToDomain() => new(Type, Token); -} diff --git a/src/Api/Billing/Models/Requests/UpdatePaymentMethodRequestBody.cs b/src/Api/Billing/Models/Requests/UpdatePaymentMethodRequestBody.cs deleted file mode 100644 index 05ab1e34c9..0000000000 --- a/src/Api/Billing/Models/Requests/UpdatePaymentMethodRequestBody.cs +++ /dev/null @@ -1,15 +0,0 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using System.ComponentModel.DataAnnotations; - -namespace Bit.Api.Billing.Models.Requests; - -public class UpdatePaymentMethodRequestBody -{ - [Required] - public TokenizedPaymentSourceRequestBody PaymentSource { get; set; } - - [Required] - public TaxInformationRequestBody TaxInformation { get; set; } -} diff --git a/src/Api/Billing/Models/Requests/VerifyBankAccountRequestBody.cs b/src/Api/Billing/Models/Requests/VerifyBankAccountRequestBody.cs deleted file mode 100644 index e248d55dde..0000000000 --- a/src/Api/Billing/Models/Requests/VerifyBankAccountRequestBody.cs +++ /dev/null @@ -1,12 +0,0 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using System.ComponentModel.DataAnnotations; - -namespace Bit.Api.Billing.Models.Requests; - -public class VerifyBankAccountRequestBody -{ - [Required] - public string DescriptorCode { get; set; } -} diff --git a/src/Api/Billing/Models/Responses/BillingPaymentResponseModel.cs b/src/Api/Billing/Models/Responses/BillingPaymentResponseModel.cs deleted file mode 100644 index f305e41c4f..0000000000 --- a/src/Api/Billing/Models/Responses/BillingPaymentResponseModel.cs +++ /dev/null @@ -1,20 +0,0 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using Bit.Core.Billing.Models; -using Bit.Core.Models.Api; - -namespace Bit.Api.Billing.Models.Responses; - -public class BillingPaymentResponseModel : ResponseModel -{ - public BillingPaymentResponseModel(BillingInfo billing) - : base("billingPayment") - { - Balance = billing.Balance; - PaymentSource = billing.PaymentSource != null ? new BillingSource(billing.PaymentSource) : null; - } - - public decimal Balance { get; set; } - public BillingSource PaymentSource { get; set; } -} diff --git a/src/Api/Billing/Models/Responses/PaymentMethodResponse.cs b/src/Api/Billing/Models/Responses/PaymentMethodResponse.cs deleted file mode 100644 index a54ac0a876..0000000000 --- a/src/Api/Billing/Models/Responses/PaymentMethodResponse.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Bit.Core.Billing.Models; -using Bit.Core.Billing.Tax.Models; - -namespace Bit.Api.Billing.Models.Responses; - -public record PaymentMethodResponse( - decimal AccountCredit, - PaymentSource PaymentSource, - string SubscriptionStatus, - TaxInformation TaxInformation) -{ - public static PaymentMethodResponse From(PaymentMethod paymentMethod) => - new( - paymentMethod.AccountCredit, - paymentMethod.PaymentSource, - paymentMethod.SubscriptionStatus, - paymentMethod.TaxInformation); -} diff --git a/src/Api/Billing/Models/Responses/PaymentSourceResponse.cs b/src/Api/Billing/Models/Responses/PaymentSourceResponse.cs deleted file mode 100644 index 2c9a63b1d0..0000000000 --- a/src/Api/Billing/Models/Responses/PaymentSourceResponse.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Bit.Core.Billing.Models; -using Bit.Core.Enums; - -namespace Bit.Api.Billing.Models.Responses; - -public record PaymentSourceResponse( - PaymentMethodType Type, - string Description, - bool NeedsVerification) -{ - public static PaymentSourceResponse From(PaymentSource paymentMethod) - => new( - paymentMethod.Type, - paymentMethod.Description, - paymentMethod.NeedsVerification); -} diff --git a/src/Api/Billing/Models/Responses/TaxInformationResponse.cs b/src/Api/Billing/Models/Responses/TaxInformationResponse.cs deleted file mode 100644 index 59e4934751..0000000000 --- a/src/Api/Billing/Models/Responses/TaxInformationResponse.cs +++ /dev/null @@ -1,23 +0,0 @@ -using Bit.Core.Billing.Tax.Models; - -namespace Bit.Api.Billing.Models.Responses; - -public record TaxInformationResponse( - string Country, - string PostalCode, - string TaxId, - string Line1, - string Line2, - string City, - string State) -{ - public static TaxInformationResponse From(TaxInformation taxInformation) - => new( - taxInformation.Country, - taxInformation.PostalCode, - taxInformation.TaxId, - taxInformation.Line1, - taxInformation.Line2, - taxInformation.City, - taxInformation.State); -} diff --git a/src/Api/Controllers/PhishingDomainsController.cs b/src/Api/Controllers/PhishingDomainsController.cs deleted file mode 100644 index f0c1a65648..0000000000 --- a/src/Api/Controllers/PhishingDomainsController.cs +++ /dev/null @@ -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>> GetPhishingDomainsAsync() - { - if (!featureService.IsEnabled(FeatureFlagKeys.PhishingDetection)) - { - return NotFound(); - } - - var domains = await phishingDomainRepository.GetActivePhishingDomainsAsync(); - return Ok(domains); - } - - [HttpGet("checksum")] - public async Task> GetChecksumAsync() - { - if (!featureService.IsEnabled(FeatureFlagKeys.PhishingDetection)) - { - return NotFound(); - } - - var checksum = await phishingDomainRepository.GetCurrentChecksumAsync(); - return Ok(checksum); - } -} diff --git a/src/Api/AdminConsole/Controllers/EventsController.cs b/src/Api/Dirt/Controllers/EventsController.cs similarity index 99% rename from src/Api/AdminConsole/Controllers/EventsController.cs rename to src/Api/Dirt/Controllers/EventsController.cs index 7e058a7870..1ac83c1316 100644 --- a/src/Api/AdminConsole/Controllers/EventsController.cs +++ b/src/Api/Dirt/Controllers/EventsController.cs @@ -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")] diff --git a/src/Api/Dirt/Controllers/HibpController.cs b/src/Api/Dirt/Controllers/HibpController.cs index d108fdbd4f..8060384502 100644 --- a/src/Api/Dirt/Controllers/HibpController.cs +++ b/src/Api/Dirt/Controllers/HibpController.cs @@ -66,7 +66,10 @@ public class HibpController : Controller } else if (response.StatusCode == HttpStatusCode.NotFound) { - return new NotFoundResult(); + /* 12/1/2025 - Per the HIBP API, If the domain does not have any email addresses in any breaches, + an HTTP 404 response will be returned. API also specifies that "404 Not found is the account could + not be found and has therefore not been pwned". Per REST semantics we will return 200 OK with empty array. */ + return Content("[]", "application/json"); } else if (response.StatusCode == HttpStatusCode.TooManyRequests && retry) { diff --git a/src/Api/Dirt/Controllers/OrganizationIntegrationConfigurationController.cs b/src/Api/Dirt/Controllers/OrganizationIntegrationConfigurationController.cs new file mode 100644 index 0000000000..4296aa3edd --- /dev/null +++ b/src/Api/Dirt/Controllers/OrganizationIntegrationConfigurationController.cs @@ -0,0 +1,93 @@ +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.Dirt.Controllers; + +[Route("organizations/{organizationId:guid}/integrations/{integrationId:guid}/configurations")] +[Authorize("Application")] +public class OrganizationIntegrationConfigurationController( + ICurrentContext currentContext, + ICreateOrganizationIntegrationConfigurationCommand createCommand, + IUpdateOrganizationIntegrationConfigurationCommand updateCommand, + IDeleteOrganizationIntegrationConfigurationCommand deleteCommand, + IGetOrganizationIntegrationConfigurationsQuery getQuery) : Controller +{ + [HttpGet("")] + public async Task> GetAsync( + Guid organizationId, + Guid integrationId) + { + if (!await HasPermission(organizationId)) + { + throw new NotFoundException(); + } + + var configurations = await getQuery.GetManyByIntegrationAsync(organizationId, integrationId); + return configurations + .Select(configuration => new OrganizationIntegrationConfigurationResponseModel(configuration)) + .ToList(); + } + + [HttpPost("")] + public async Task CreateAsync( + Guid organizationId, + Guid integrationId, + [FromBody] OrganizationIntegrationConfigurationRequestModel model) + { + if (!await HasPermission(organizationId)) + { + throw new NotFoundException(); + } + + var configuration = model.ToOrganizationIntegrationConfiguration(integrationId); + var created = await createCommand.CreateAsync(organizationId, integrationId, configuration); + + return new OrganizationIntegrationConfigurationResponseModel(created); + } + + [HttpPut("{configurationId:guid}")] + public async Task UpdateAsync( + Guid organizationId, + Guid integrationId, + Guid configurationId, + [FromBody] OrganizationIntegrationConfigurationRequestModel model) + { + if (!await HasPermission(organizationId)) + { + throw new NotFoundException(); + } + + var configuration = model.ToOrganizationIntegrationConfiguration(integrationId); + var updated = await updateCommand.UpdateAsync(organizationId, integrationId, configurationId, configuration); + + return new OrganizationIntegrationConfigurationResponseModel(updated); + } + + [HttpDelete("{configurationId:guid}")] + public async Task DeleteAsync(Guid organizationId, Guid integrationId, Guid configurationId) + { + if (!await HasPermission(organizationId)) + { + throw new NotFoundException(); + } + + await deleteCommand.DeleteAsync(organizationId, integrationId, configurationId); + } + + [HttpPost("{configurationId:guid}/delete")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead")] + public async Task PostDeleteAsync(Guid organizationId, Guid integrationId, Guid configurationId) + { + await DeleteAsync(organizationId, integrationId, configurationId); + } + + private async Task HasPermission(Guid organizationId) + { + return await currentContext.OrganizationOwner(organizationId); + } +} diff --git a/src/Api/AdminConsole/Controllers/OrganizationIntegrationController.cs b/src/Api/Dirt/Controllers/OrganizationIntegrationController.cs similarity index 61% rename from src/Api/AdminConsole/Controllers/OrganizationIntegrationController.cs rename to src/Api/Dirt/Controllers/OrganizationIntegrationController.cs index 181811e892..960db648c2 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationIntegrationController.cs +++ b/src/Api/Dirt/Controllers/OrganizationIntegrationController.cs @@ -1,18 +1,21 @@ -using Bit.Api.AdminConsole.Models.Request.Organizations; -using Bit.Api.AdminConsole.Models.Response.Organizations; +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 Bit.Core.Repositories; 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")] public class OrganizationIntegrationController( ICurrentContext currentContext, - IOrganizationIntegrationRepository integrationRepository) : Controller + ICreateOrganizationIntegrationCommand createCommand, + IUpdateOrganizationIntegrationCommand updateCommand, + IDeleteOrganizationIntegrationCommand deleteCommand, + IGetOrganizationIntegrationsQuery getQuery) : Controller { [HttpGet("")] public async Task> GetAsync(Guid organizationId) @@ -22,7 +25,7 @@ public class OrganizationIntegrationController( throw new NotFoundException(); } - var integrations = await integrationRepository.GetManyByOrganizationAsync(organizationId); + var integrations = await getQuery.GetManyByOrganizationAsync(organizationId); return integrations .Select(integration => new OrganizationIntegrationResponseModel(integration)) .ToList(); @@ -36,8 +39,10 @@ public class OrganizationIntegrationController( throw new NotFoundException(); } - var integration = await integrationRepository.CreateAsync(model.ToOrganizationIntegration(organizationId)); - return new OrganizationIntegrationResponseModel(integration); + var integration = model.ToOrganizationIntegration(organizationId); + var created = await createCommand.CreateAsync(integration); + + return new OrganizationIntegrationResponseModel(created); } [HttpPut("{integrationId:guid}")] @@ -48,14 +53,10 @@ public class OrganizationIntegrationController( throw new NotFoundException(); } - var integration = await integrationRepository.GetByIdAsync(integrationId); - if (integration is null || integration.OrganizationId != organizationId) - { - throw new NotFoundException(); - } + var integration = model.ToOrganizationIntegration(organizationId); + var updated = await updateCommand.UpdateAsync(organizationId, integrationId, integration); - await integrationRepository.ReplaceAsync(model.ToOrganizationIntegration(integration)); - return new OrganizationIntegrationResponseModel(integration); + return new OrganizationIntegrationResponseModel(updated); } [HttpDelete("{integrationId:guid}")] @@ -66,13 +67,7 @@ public class OrganizationIntegrationController( throw new NotFoundException(); } - var integration = await integrationRepository.GetByIdAsync(integrationId); - if (integration is null || integration.OrganizationId != organizationId) - { - throw new NotFoundException(); - } - - await integrationRepository.DeleteAsync(integration); + await deleteCommand.DeleteAsync(organizationId, integrationId); } [HttpPost("{integrationId:guid}/delete")] diff --git a/src/Api/AdminConsole/Controllers/SlackIntegrationController.cs b/src/Api/Dirt/Controllers/SlackIntegrationController.cs similarity index 94% rename from src/Api/AdminConsole/Controllers/SlackIntegrationController.cs rename to src/Api/Dirt/Controllers/SlackIntegrationController.cs index 7b53f73f81..e98ed0d3fa 100644 --- a/src/Api/AdminConsole/Controllers/SlackIntegrationController.cs +++ b/src/Api/Dirt/Controllers/SlackIntegrationController.cs @@ -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")] diff --git a/src/Api/AdminConsole/Controllers/TeamsIntegrationController.cs b/src/Api/Dirt/Controllers/TeamsIntegrationController.cs similarity index 94% rename from src/Api/AdminConsole/Controllers/TeamsIntegrationController.cs rename to src/Api/Dirt/Controllers/TeamsIntegrationController.cs index 36d107bbcc..b2bd55017c 100644 --- a/src/Api/AdminConsole/Controllers/TeamsIntegrationController.cs +++ b/src/Api/Dirt/Controllers/TeamsIntegrationController.cs @@ -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")] diff --git a/src/Api/Dirt/Models/Request/OrganizationIntegrationConfigurationRequestModel.cs b/src/Api/Dirt/Models/Request/OrganizationIntegrationConfigurationRequestModel.cs new file mode 100644 index 0000000000..e918bea2d6 --- /dev/null +++ b/src/Api/Dirt/Models/Request/OrganizationIntegrationConfigurationRequestModel.cs @@ -0,0 +1,27 @@ +using Bit.Core.Dirt.Entities; +using Bit.Core.Enums; + +namespace Bit.Api.Dirt.Models.Request; + +public class OrganizationIntegrationConfigurationRequestModel +{ + public string? Configuration { get; set; } + + public EventType? EventType { get; set; } + + public string? Filters { get; set; } + + public string? Template { get; set; } + + public OrganizationIntegrationConfiguration ToOrganizationIntegrationConfiguration(Guid organizationIntegrationId) + { + return new OrganizationIntegrationConfiguration() + { + OrganizationIntegrationId = organizationIntegrationId, + Configuration = Configuration, + Filters = Filters, + EventType = EventType, + Template = Template + }; + } +} diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrgnizationIntegrationRequestModel.cs b/src/Api/Dirt/Models/Request/OrganizationIntegrationRequestModel.cs similarity index 94% rename from src/Api/AdminConsole/Models/Request/Organizations/OrgnizationIntegrationRequestModel.cs rename to src/Api/Dirt/Models/Request/OrganizationIntegrationRequestModel.cs index 668afe70bf..259671bd66 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/OrgnizationIntegrationRequestModel.cs +++ b/src/Api/Dirt/Models/Request/OrganizationIntegrationRequestModel.cs @@ -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 { diff --git a/src/Api/AdminConsole/Models/Response/EventResponseModel.cs b/src/Api/Dirt/Models/Response/EventResponseModel.cs similarity index 98% rename from src/Api/AdminConsole/Models/Response/EventResponseModel.cs rename to src/Api/Dirt/Models/Response/EventResponseModel.cs index c259bc3bc4..bfcc50c84e 100644 --- a/src/Api/AdminConsole/Models/Response/EventResponseModel.cs +++ b/src/Api/Dirt/Models/Response/EventResponseModel.cs @@ -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 { diff --git a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationIntegrationConfigurationResponseModel.cs b/src/Api/Dirt/Models/Response/OrganizationIntegrationConfigurationResponseModel.cs similarity index 90% rename from src/Api/AdminConsole/Models/Response/Organizations/OrganizationIntegrationConfigurationResponseModel.cs rename to src/Api/Dirt/Models/Response/OrganizationIntegrationConfigurationResponseModel.cs index d070375d88..62a3aea405 100644 --- a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationIntegrationConfigurationResponseModel.cs +++ b/src/Api/Dirt/Models/Response/OrganizationIntegrationConfigurationResponseModel.cs @@ -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 { diff --git a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationIntegrationResponseModel.cs b/src/Api/Dirt/Models/Response/OrganizationIntegrationResponseModel.cs similarity index 93% rename from src/Api/AdminConsole/Models/Response/Organizations/OrganizationIntegrationResponseModel.cs rename to src/Api/Dirt/Models/Response/OrganizationIntegrationResponseModel.cs index 0c31e07bef..60e885fe82 100644 --- a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationIntegrationResponseModel.cs +++ b/src/Api/Dirt/Models/Response/OrganizationIntegrationResponseModel.cs @@ -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 { diff --git a/src/Api/AdminConsole/Public/Controllers/EventsController.cs b/src/Api/Dirt/Public/Controllers/EventsController.cs similarity index 98% rename from src/Api/AdminConsole/Public/Controllers/EventsController.cs rename to src/Api/Dirt/Public/Controllers/EventsController.cs index b92e576ef9..8c76137489 100644 --- a/src/Api/AdminConsole/Public/Controllers/EventsController.cs +++ b/src/Api/Dirt/Public/Controllers/EventsController.cs @@ -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")] diff --git a/src/Api/AdminConsole/Public/Models/Request/EventFilterRequestModel.cs b/src/Api/Dirt/Public/Models/EventFilterRequestModel.cs similarity index 97% rename from src/Api/AdminConsole/Public/Models/Request/EventFilterRequestModel.cs rename to src/Api/Dirt/Public/Models/EventFilterRequestModel.cs index a007349f26..20984c2cb0 100644 --- a/src/Api/AdminConsole/Public/Models/Request/EventFilterRequestModel.cs +++ b/src/Api/Dirt/Public/Models/EventFilterRequestModel.cs @@ -3,7 +3,7 @@ using Bit.Core.Exceptions; -namespace Bit.Api.Models.Public.Request; +namespace Bit.Api.Dirt.Public.Models; public class EventFilterRequestModel { diff --git a/src/Api/AdminConsole/Public/Models/Response/EventResponseModel.cs b/src/Api/Dirt/Public/Models/EventResponseModel.cs similarity index 98% rename from src/Api/AdminConsole/Public/Models/Response/EventResponseModel.cs rename to src/Api/Dirt/Public/Models/EventResponseModel.cs index 3e1de2747a..77c0b5a275 100644 --- a/src/Api/AdminConsole/Public/Models/Response/EventResponseModel.cs +++ b/src/Api/Dirt/Public/Models/EventResponseModel.cs @@ -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; /// /// An event log. diff --git a/src/Api/Jobs/JobsHostedService.cs b/src/Api/Jobs/JobsHostedService.cs index 0178f6d68b..a9626dc90e 100644 --- a/src/Api/Jobs/JobsHostedService.cs +++ b/src/Api/Jobs/JobsHostedService.cs @@ -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(typeof(ValidateUsersJob), everyTopOfTheSixthHourTrigger), new Tuple(typeof(ValidateOrganizationsJob), everyTwelfthHourAndThirtyMinutesTrigger), new Tuple(typeof(ValidateOrganizationDomainJob), validateOrganizationDomainTrigger), - new Tuple(typeof(UpdatePhishingDomainsJob), updatePhishingDomainsTrigger), new (typeof(OrganizationSubscriptionUpdateJob), updateOrgSubscriptionsTrigger), }; @@ -111,7 +103,6 @@ public class JobsHostedService : BaseJobsHostedService services.AddTransient(); services.AddTransient(); services.AddTransient(); - services.AddTransient(); services.AddTransient(); } diff --git a/src/Api/Jobs/UpdatePhishingDomainsJob.cs b/src/Api/Jobs/UpdatePhishingDomainsJob.cs deleted file mode 100644 index 355f2af69b..0000000000 --- a/src/Api/Jobs/UpdatePhishingDomainsJob.cs +++ /dev/null @@ -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 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."); - } - } -} diff --git a/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs b/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs index 7968970048..a124616e30 100644 --- a/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs +++ b/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs @@ -1,8 +1,8 @@ -#nullable enable -using Bit.Api.AdminConsole.Models.Request.Organizations; +using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.Auth.Models.Request; using Bit.Api.Auth.Models.Request.WebAuthn; using Bit.Api.KeyManagement.Models.Requests; +using Bit.Api.KeyManagement.Models.Responses; using Bit.Api.KeyManagement.Validators; using Bit.Api.Tools.Models.Request; using Bit.Api.Vault.Models.Request; @@ -14,6 +14,7 @@ using Bit.Core.Entities; using Bit.Core.Exceptions; using Bit.Core.KeyManagement.Commands.Interfaces; using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.KeyManagement.Queries.Interfaces; using Bit.Core.KeyManagement.UserKey; using Bit.Core.Repositories; using Bit.Core.Services; @@ -45,11 +46,14 @@ public class AccountsKeyManagementController : Controller private readonly IRotationValidator, IEnumerable> _webauthnKeyValidator; private readonly IRotationValidator, IEnumerable> _deviceValidator; + private readonly IKeyConnectorConfirmationDetailsQuery _keyConnectorConfirmationDetailsQuery; + private readonly ISetKeyConnectorKeyCommand _setKeyConnectorKeyCommand; public AccountsKeyManagementController(IUserService userService, IFeatureService featureService, IOrganizationUserRepository organizationUserRepository, IEmergencyAccessRepository emergencyAccessRepository, + IKeyConnectorConfirmationDetailsQuery keyConnectorConfirmationDetailsQuery, IRegenerateUserAsymmetricKeysCommand regenerateUserAsymmetricKeysCommand, IRotateUserAccountKeysCommand rotateUserKeyCommandV2, IRotationValidator, IEnumerable> cipherValidator, @@ -59,8 +63,10 @@ public class AccountsKeyManagementController : Controller emergencyAccessValidator, IRotationValidator, IReadOnlyList> organizationUserValidator, - IRotationValidator, IEnumerable> webAuthnKeyValidator, - IRotationValidator, IEnumerable> deviceValidator) + IRotationValidator, IEnumerable> + webAuthnKeyValidator, + IRotationValidator, IEnumerable> deviceValidator, + ISetKeyConnectorKeyCommand setKeyConnectorKeyCommand) { _userService = userService; _featureService = featureService; @@ -75,12 +81,14 @@ public class AccountsKeyManagementController : Controller _organizationUserValidator = organizationUserValidator; _webauthnKeyValidator = webAuthnKeyValidator; _deviceValidator = deviceValidator; + _keyConnectorConfirmationDetailsQuery = keyConnectorConfirmationDetailsQuery; + _setKeyConnectorKeyCommand = setKeyConnectorKeyCommand; } [HttpPost("key-management/regenerate-keys")] public async Task RegenerateKeysAsync([FromBody] KeyRegenerationRequestModel request) { - if (!_featureService.IsEnabled(FeatureFlagKeys.PrivateKeyRegeneration)) + if (!_featureService.IsEnabled(FeatureFlagKeys.PrivateKeyRegeneration) && !_featureService.IsEnabled(FeatureFlagKeys.DataRecoveryTool)) { throw new NotFoundException(); } @@ -142,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")] @@ -178,4 +196,17 @@ public class AccountsKeyManagementController : Controller throw new BadRequestException(ModelState); } + + [HttpGet("key-connector/confirmation-details/{orgSsoIdentifier}")] + public async Task GetKeyConnectorConfirmationDetailsAsync(string orgSsoIdentifier) + { + var user = await _userService.GetUserByPrincipalAsync(User); + if (user == null) + { + throw new UnauthorizedAccessException(); + } + + var details = await _keyConnectorConfirmationDetailsQuery.Run(orgSsoIdentifier, user.Id); + return new KeyConnectorConfirmationDetailsResponseModel(details); + } } diff --git a/src/Api/KeyManagement/Models/Requests/MasterPasswordAuthenticationDataRequestModel.cs b/src/Api/KeyManagement/Models/Requests/MasterPasswordAuthenticationDataRequestModel.cs index d65dc8fcb7..4f70a1135f 100644 --- a/src/Api/KeyManagement/Models/Requests/MasterPasswordAuthenticationDataRequestModel.cs +++ b/src/Api/KeyManagement/Models/Requests/MasterPasswordAuthenticationDataRequestModel.cs @@ -6,8 +6,11 @@ namespace Bit.Api.KeyManagement.Models.Requests; public class MasterPasswordAuthenticationDataRequestModel { public required KdfRequestModel Kdf { get; init; } + [Required] public required string MasterPasswordAuthenticationHash { get; init; } - [StringLength(256)] public required string Salt { get; init; } + [Required] + [StringLength(256)] + public required string Salt { get; init; } public MasterPasswordAuthenticationData ToData() { diff --git a/src/Api/KeyManagement/Models/Requests/MasterPasswordUnlockDataRequestModel.cs b/src/Api/KeyManagement/Models/Requests/MasterPasswordUnlockDataRequestModel.cs index ce7a2b343f..e1d7863cae 100644 --- a/src/Api/KeyManagement/Models/Requests/MasterPasswordUnlockDataRequestModel.cs +++ b/src/Api/KeyManagement/Models/Requests/MasterPasswordUnlockDataRequestModel.cs @@ -7,8 +7,12 @@ namespace Bit.Api.KeyManagement.Models.Requests; public class MasterPasswordUnlockDataRequestModel { public required KdfRequestModel Kdf { get; init; } - [EncryptedString] public required string MasterKeyWrappedUserKey { get; init; } - [StringLength(256)] public required string Salt { get; init; } + [Required] + [EncryptedString] + public required string MasterKeyWrappedUserKey { get; init; } + [Required] + [StringLength(256)] + public required string Salt { get; init; } public MasterPasswordUnlockData ToData() { diff --git a/src/Api/KeyManagement/Models/Requests/RotateAccountKeysAndDataRequestModel.cs b/src/Api/KeyManagement/Models/Requests/RotateAccountKeysAndDataRequestModel.cs index 02780b015a..3510be9546 100644 --- a/src/Api/KeyManagement/Models/Requests/RotateAccountKeysAndDataRequestModel.cs +++ b/src/Api/KeyManagement/Models/Requests/RotateAccountKeysAndDataRequestModel.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using Bit.Core.KeyManagement.Models.Api.Request; namespace Bit.Api.KeyManagement.Models.Requests; diff --git a/src/Api/KeyManagement/Models/Requests/SetKeyConnectorKeyRequestModel.cs b/src/Api/KeyManagement/Models/Requests/SetKeyConnectorKeyRequestModel.cs index 9f52a97383..6cd13fdf83 100644 --- a/src/Api/KeyManagement/Models/Requests/SetKeyConnectorKeyRequestModel.cs +++ b/src/Api/KeyManagement/Models/Requests/SetKeyConnectorKeyRequestModel.cs @@ -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 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 + }; + } } diff --git a/src/Api/KeyManagement/Models/Responses/KeyConnectorConfirmationDetailsResponseModel.cs b/src/Api/KeyManagement/Models/Responses/KeyConnectorConfirmationDetailsResponseModel.cs new file mode 100644 index 0000000000..68d2c689df --- /dev/null +++ b/src/Api/KeyManagement/Models/Responses/KeyConnectorConfirmationDetailsResponseModel.cs @@ -0,0 +1,24 @@ +using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.Models.Api; + +namespace Bit.Api.KeyManagement.Models.Responses; + +public class KeyConnectorConfirmationDetailsResponseModel : ResponseModel +{ + private const string _objectName = "keyConnectorConfirmationDetails"; + + public KeyConnectorConfirmationDetailsResponseModel(KeyConnectorConfirmationDetails details, + string obj = _objectName) : base(obj) + { + ArgumentNullException.ThrowIfNull(details); + + OrganizationName = details.OrganizationName; + } + + public KeyConnectorConfirmationDetailsResponseModel() : base(_objectName) + { + OrganizationName = string.Empty; + } + + public string OrganizationName { get; set; } +} diff --git a/src/Api/KeyManagement/Validators/SendRotationValidator.cs b/src/Api/KeyManagement/Validators/SendRotationValidator.cs index 10a5d996b7..a781ab99ee 100644 --- a/src/Api/KeyManagement/Validators/SendRotationValidator.cs +++ b/src/Api/KeyManagement/Validators/SendRotationValidator.cs @@ -44,7 +44,7 @@ public class SendRotationValidator : IRotationValidator public class CollectionResponseModel : CollectionBaseModel, IResponseModel { + [JsonConstructor] + public CollectionResponseModel() + { + + } + public CollectionResponseModel(Collection collection, IEnumerable groups) { if (collection == null) diff --git a/src/Api/Models/Response/ConfigResponseModel.cs b/src/Api/Models/Response/ConfigResponseModel.cs index 20bc3f9e10..d748254206 100644 --- a/src/Api/Models/Response/ConfigResponseModel.cs +++ b/src/Api/Models/Response/ConfigResponseModel.cs @@ -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() { diff --git a/src/Api/Models/Response/KeysResponseModel.cs b/src/Api/Models/Response/KeysResponseModel.cs index cfc1a6a0a1..4c877e0bfc 100644 --- a/src/Api/Models/Response/KeysResponseModel.cs +++ b/src/Api/Models/Response/KeysResponseModel.cs @@ -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; } + /// + /// The master key wrapped user key. The master key can either be a master-password master key or a + /// key-connector master key. + /// + 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; } } diff --git a/src/Api/Models/Response/SubscriptionResponseModel.cs b/src/Api/Models/Response/SubscriptionResponseModel.cs index 29a47e160c..a357264081 100644 --- a/src/Api/Models/Response/SubscriptionResponseModel.cs +++ b/src/Api/Models/Response/SubscriptionResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Billing.Constants; +using System.Security.Claims; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Licenses; +using Bit.Core.Billing.Licenses.Extensions; using Bit.Core.Billing.Models.Business; using Bit.Core.Entities; using Bit.Core.Models.Api; @@ -7,6 +10,7 @@ using Bit.Core.Utilities; namespace Bit.Api.Models.Response; +// TODO: Remove with deletion of pm-29594-update-individual-subscription-page public class SubscriptionResponseModel : ResponseModel { @@ -37,6 +41,46 @@ public class SubscriptionResponseModel : ResponseModel : null; } + /// The user entity containing storage and premium subscription information + /// Subscription information retrieved from the payment provider (Stripe/Braintree) + /// The user's license containing expiration and feature entitlements + /// The claims principal containing cryptographically secure token claims + /// + /// Whether to include discount information in the response. + /// Set to true when the PM23341_Milestone_2 feature flag is enabled AND + /// you want to expose Milestone 2 discount information to the client. + /// The discount will only be included if it matches the specific Milestone 2 coupon ID. + /// + public SubscriptionResponseModel(User user, SubscriptionInfo? subscription, UserLicense license, ClaimsPrincipal? claimsPrincipal, bool includeMilestone2Discount = false) + : base("subscription") + { + Subscription = subscription?.Subscription != null ? new BillingSubscription(subscription.Subscription) : null; + UpcomingInvoice = subscription?.UpcomingInvoice != null ? + new BillingSubscriptionUpcomingInvoice(subscription.UpcomingInvoice) : null; + StorageName = user.Storage.HasValue ? CoreHelpers.ReadableBytesSize(user.Storage.Value) : null; + StorageGb = user.Storage.HasValue ? Math.Round(user.Storage.Value / 1073741824D, 2) : 0; // 1 GB + MaxStorageGb = user.MaxStorageGb; + License = license; + + // CRITICAL: When a license has a Token (JWT), ALWAYS use the expiration from the token claim + // The token's expiration is cryptographically secured and cannot be tampered with + // The file's Expires property can be manually edited and should NOT be trusted for display + if (claimsPrincipal != null) + { + Expiration = claimsPrincipal.GetValue(UserLicenseConstants.Expires); + } + else + { + // No token - use the license file expiration (for older licenses without tokens) + Expiration = License.Expires; + } + + // Only display the Milestone 2 subscription discount on the subscription page. + CustomerDiscount = ShouldIncludeMilestone2Discount(includeMilestone2Discount, subscription?.CustomerDiscount) + ? new BillingCustomerDiscount(subscription!.CustomerDiscount!) + : null; + } + public SubscriptionResponseModel(User user, UserLicense? license = null) : base("subscription") { diff --git a/src/Api/Program.cs b/src/Api/Program.cs index 6023f51c6d..bf924af47f 100644 --- a/src/Api/Program.cs +++ b/src/Api/Program.cs @@ -1,9 +1,4 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using AspNetCoreRateLimit; -using Bit.Core.Utilities; -using Microsoft.IdentityModel.Tokens; +using Bit.Core.Utilities; namespace Bit.Api; @@ -17,32 +12,8 @@ public class Program .ConfigureWebHostDefaults(webBuilder => { webBuilder.UseStartup(); - webBuilder.ConfigureLogging((hostingContext, logging) => - logging.AddSerilog(hostingContext, (e, globalSettings) => - { - var context = e.Properties["SourceContext"].ToString(); - if (e.Exception != null && - (e.Exception.GetType() == typeof(SecurityTokenValidationException) || - e.Exception.Message == "Bad security stamp.")) - { - return false; - } - - if ( - context.Contains(typeof(IpRateLimitMiddleware).FullName)) - { - return e.Level >= globalSettings.MinLogLevel.ApiSettings.IpRateLimit; - } - - if (context.Contains("Duende.IdentityServer.Validation.TokenValidator") || - context.Contains("Duende.IdentityServer.Validation.TokenRequestValidator")) - { - return e.Level >= globalSettings.MinLogLevel.ApiSettings.IdentityToken; - } - - return e.Level >= globalSettings.MinLogLevel.ApiSettings.Default; - })); }) + .AddSerilogFileLogging() .Build() .Run(); } diff --git a/src/Api/Public/Controllers/CollectionsController.cs b/src/Api/Public/Controllers/CollectionsController.cs index 8615113906..28de4dc16d 100644 --- a/src/Api/Public/Controllers/CollectionsController.cs +++ b/src/Api/Public/Controllers/CollectionsController.cs @@ -65,10 +65,12 @@ public class CollectionsController : Controller [ProducesResponseType(typeof(ListResponseModel), (int)HttpStatusCode.OK)] public async Task 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 + .Where(c => c.Item1.Type != CollectionType.DefaultUserCollection) + .Select(c => new CollectionResponseModel(c.Item1, c.Item2.Groups)); + var response = new ListResponseModel(collectionResponses); return new JsonResult(response); } diff --git a/src/Api/SecretsManager/Controllers/SecretVersionsController.cs b/src/Api/SecretsManager/Controllers/SecretVersionsController.cs new file mode 100644 index 0000000000..86e2d1f7e9 --- /dev/null +++ b/src/Api/SecretsManager/Controllers/SecretVersionsController.cs @@ -0,0 +1,337 @@ +using Bit.Api.Models.Response; +using Bit.Api.SecretsManager.Models.Request; +using Bit.Api.SecretsManager.Models.Response; +using Bit.Core.Auth.Identity; +using Bit.Core.Context; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.SecretsManager.Repositories; +using Bit.Core.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Bit.Api.SecretsManager.Controllers; + +[Authorize("secrets")] +public class SecretVersionsController : Controller +{ + private readonly ICurrentContext _currentContext; + private readonly ISecretVersionRepository _secretVersionRepository; + private readonly ISecretRepository _secretRepository; + private readonly IUserService _userService; + private readonly IOrganizationUserRepository _organizationUserRepository; + + public SecretVersionsController( + ICurrentContext currentContext, + ISecretVersionRepository secretVersionRepository, + ISecretRepository secretRepository, + IUserService userService, + IOrganizationUserRepository organizationUserRepository) + { + _currentContext = currentContext; + _secretVersionRepository = secretVersionRepository; + _secretRepository = secretRepository; + _userService = userService; + _organizationUserRepository = organizationUserRepository; + } + + [HttpGet("secrets/{secretId}/versions")] + public async Task> GetVersionsBySecretIdAsync([FromRoute] Guid secretId) + { + var secret = await _secretRepository.GetByIdAsync(secretId); + if (secret == null || !_currentContext.AccessSecretsManager(secret.OrganizationId)) + { + throw new NotFoundException(); + } + + // For service accounts and organization API, skip user-level access checks + if (_currentContext.IdentityClientType == IdentityClientType.ServiceAccount || + _currentContext.IdentityClientType == IdentityClientType.Organization) + { + // Already verified Secrets Manager access above + var versionList = await _secretVersionRepository.GetManyBySecretIdAsync(secretId); + var responseList = versionList.Select(v => new SecretVersionResponseModel(v)); + return new ListResponseModel(responseList); + } + + var userId = _userService.GetProperUserId(User); + if (!userId.HasValue) + { + throw new NotFoundException(); + } + + var orgAdmin = await _currentContext.OrganizationAdmin(secret.OrganizationId); + var accessClient = AccessClientHelper.ToAccessClient(_currentContext.IdentityClientType, orgAdmin); + + var access = await _secretRepository.AccessToSecretAsync(secretId, userId.Value, accessClient); + if (!access.Read) + { + throw new NotFoundException(); + } + + var versions = await _secretVersionRepository.GetManyBySecretIdAsync(secretId); + var responses = versions.Select(v => new SecretVersionResponseModel(v)); + + return new ListResponseModel(responses); + } + + [HttpGet("secret-versions/{id}")] + public async Task GetByIdAsync([FromRoute] Guid id) + { + var secretVersion = await _secretVersionRepository.GetByIdAsync(id); + if (secretVersion == null) + { + throw new NotFoundException(); + } + + var secret = await _secretRepository.GetByIdAsync(secretVersion.SecretId); + if (secret == null || !_currentContext.AccessSecretsManager(secret.OrganizationId)) + { + throw new NotFoundException(); + } + + // For service accounts and organization API, skip user-level access checks + if (_currentContext.IdentityClientType == IdentityClientType.ServiceAccount || + _currentContext.IdentityClientType == IdentityClientType.Organization) + { + // Already verified Secrets Manager access above + return new SecretVersionResponseModel(secretVersion); + } + + var userId = _userService.GetProperUserId(User); + if (!userId.HasValue) + { + throw new NotFoundException(); + } + + var orgAdmin = await _currentContext.OrganizationAdmin(secret.OrganizationId); + var accessClient = AccessClientHelper.ToAccessClient(_currentContext.IdentityClientType, orgAdmin); + + var access = await _secretRepository.AccessToSecretAsync(secretVersion.SecretId, userId.Value, accessClient); + if (!access.Read) + { + throw new NotFoundException(); + } + + return new SecretVersionResponseModel(secretVersion); + } + + [HttpPost("secret-versions/get-by-ids")] + public async Task> GetManyByIdsAsync([FromBody] List ids) + { + if (!ids.Any()) + { + throw new BadRequestException("No version IDs provided."); + } + + // Get all versions + var versions = (await _secretVersionRepository.GetManyByIdsAsync(ids)).ToList(); + if (!versions.Any()) + { + throw new NotFoundException(); + } + + // Get all associated secrets and check permissions + var secretIds = versions.Select(v => v.SecretId).Distinct().ToList(); + var secrets = (await _secretRepository.GetManyByIds(secretIds)).ToList(); + + if (!secrets.Any()) + { + throw new NotFoundException(); + } + + // Ensure all secrets belong to the same organization + var organizationId = secrets.First().OrganizationId; + if (secrets.Any(s => s.OrganizationId != organizationId) || + !_currentContext.AccessSecretsManager(organizationId)) + { + throw new NotFoundException(); + } + + // For service accounts and organization API, skip user-level access checks + if (_currentContext.IdentityClientType == IdentityClientType.ServiceAccount || + _currentContext.IdentityClientType == IdentityClientType.Organization) + { + // Already verified Secrets Manager access and organization ownership above + var serviceAccountResponses = versions.Select(v => new SecretVersionResponseModel(v)); + return new ListResponseModel(serviceAccountResponses); + } + + var userId = _userService.GetProperUserId(User); + if (!userId.HasValue) + { + throw new NotFoundException(); + } + + var isAdmin = await _currentContext.OrganizationAdmin(organizationId); + var accessClient = AccessClientHelper.ToAccessClient(_currentContext.IdentityClientType, isAdmin); + + // Verify read access to all associated secrets + var accessResults = await _secretRepository.AccessToSecretsAsync(secretIds, userId.Value, accessClient); + if (accessResults.Values.Any(access => !access.Read)) + { + throw new NotFoundException(); + } + + var responses = versions.Select(v => new SecretVersionResponseModel(v)); + return new ListResponseModel(responses); + } + + [HttpPut("secrets/{secretId}/versions/restore")] + public async Task RestoreVersionAsync([FromRoute] Guid secretId, [FromBody] RestoreSecretVersionRequestModel request) + { + if (!(_currentContext.IdentityClientType == IdentityClientType.User || _currentContext.IdentityClientType == IdentityClientType.ServiceAccount)) + { + throw new NotFoundException(); + } + + var secret = await _secretRepository.GetByIdAsync(secretId); + if (secret == null || !_currentContext.AccessSecretsManager(secret.OrganizationId)) + { + throw new NotFoundException(); + } + + // Get the version first to validate it belongs to this secret + var version = await _secretVersionRepository.GetByIdAsync(request.VersionId); + if (version == null || version.SecretId != secretId) + { + throw new NotFoundException(); + } + + // Store the current value before restoration + var currentValue = secret.Value; + + // For service accounts and organization API, skip user-level access checks + if (_currentContext.IdentityClientType == IdentityClientType.ServiceAccount) + { + // Save current value as a version before restoring + if (currentValue != version.Value) + { + var editorUserId = _userService.GetProperUserId(User); + if (editorUserId.HasValue) + { + var currentVersionSnapshot = new Core.SecretsManager.Entities.SecretVersion + { + SecretId = secretId, + Value = currentValue!, + VersionDate = DateTime.UtcNow, + EditorServiceAccountId = editorUserId.Value + }; + + await _secretVersionRepository.CreateAsync(currentVersionSnapshot); + } + } + + // Already verified Secrets Manager access above + secret.Value = version.Value; + secret.RevisionDate = DateTime.UtcNow; + var updatedSec = await _secretRepository.UpdateAsync(secret); + return new SecretResponseModel(updatedSec, true, true); + } + + var userId = _userService.GetProperUserId(User); + if (!userId.HasValue) + { + throw new NotFoundException(); + } + + var orgAdmin = await _currentContext.OrganizationAdmin(secret.OrganizationId); + var accessClient = AccessClientHelper.ToAccessClient(_currentContext.IdentityClientType, orgAdmin); + + var access = await _secretRepository.AccessToSecretAsync(secretId, userId.Value, accessClient); + if (!access.Write) + { + throw new NotFoundException(); + } + + // Save current value as a version before restoring + if (currentValue != version.Value) + { + var orgUser = await _organizationUserRepository.GetByOrganizationAsync(secret.OrganizationId, userId.Value); + if (orgUser == null) + { + throw new NotFoundException(); + } + + var currentVersionSnapshot = new Core.SecretsManager.Entities.SecretVersion + { + SecretId = secretId, + Value = currentValue!, + VersionDate = DateTime.UtcNow, + EditorOrganizationUserId = orgUser.Id + }; + + await _secretVersionRepository.CreateAsync(currentVersionSnapshot); + } + + // Update the secret with the version's value + secret.Value = version.Value; + secret.RevisionDate = DateTime.UtcNow; + + var updatedSecret = await _secretRepository.UpdateAsync(secret); + + return new SecretResponseModel(updatedSecret, true, true); + } + + [HttpPost("secret-versions/delete")] + public async Task BulkDeleteAsync([FromBody] List ids) + { + if (!ids.Any()) + { + throw new BadRequestException("No version IDs provided."); + } + + var secretVersions = (await _secretVersionRepository.GetManyByIdsAsync(ids)).ToList(); + if (secretVersions.Count != ids.Count) + { + throw new NotFoundException(); + } + + // Ensure all versions belong to secrets in the same organization + var secretIds = secretVersions.Select(v => v.SecretId).Distinct().ToList(); + var secrets = await _secretRepository.GetManyByIds(secretIds); + var secretsList = secrets.ToList(); + + if (!secretsList.Any()) + { + throw new NotFoundException(); + } + + var organizationId = secretsList.First().OrganizationId; + if (secretsList.Any(s => s.OrganizationId != organizationId) || + !_currentContext.AccessSecretsManager(organizationId)) + { + throw new NotFoundException(); + } + + // For service accounts and organization API, skip user-level access checks + if (_currentContext.IdentityClientType == IdentityClientType.ServiceAccount || + _currentContext.IdentityClientType == IdentityClientType.Organization) + { + // Already verified Secrets Manager access and organization ownership above + await _secretVersionRepository.DeleteManyByIdAsync(ids); + return Ok(); + } + + var userId = _userService.GetProperUserId(User); + if (!userId.HasValue) + { + throw new NotFoundException(); + } + + var orgAdmin = await _currentContext.OrganizationAdmin(organizationId); + var accessClient = AccessClientHelper.ToAccessClient(_currentContext.IdentityClientType, orgAdmin); + + // Verify write access to all associated secrets + var accessResults = await _secretRepository.AccessToSecretsAsync(secretIds, userId.Value, accessClient); + if (accessResults.Values.Any(access => !access.Write)) + { + throw new NotFoundException(); + } + + await _secretVersionRepository.DeleteManyByIdAsync(ids); + + return Ok(); + } +} diff --git a/src/Api/SecretsManager/Controllers/SecretsController.cs b/src/Api/SecretsManager/Controllers/SecretsController.cs index e263b9747d..dcfe1be111 100644 --- a/src/Api/SecretsManager/Controllers/SecretsController.cs +++ b/src/Api/SecretsManager/Controllers/SecretsController.cs @@ -8,6 +8,7 @@ using Bit.Core.Auth.Identity; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.Repositories; using Bit.Core.SecretsManager.AuthorizationRequirements; using Bit.Core.SecretsManager.Commands.Secrets.Interfaces; using Bit.Core.SecretsManager.Entities; @@ -29,6 +30,7 @@ public class SecretsController : Controller private readonly ICurrentContext _currentContext; private readonly IProjectRepository _projectRepository; private readonly ISecretRepository _secretRepository; + private readonly ISecretVersionRepository _secretVersionRepository; private readonly ICreateSecretCommand _createSecretCommand; private readonly IUpdateSecretCommand _updateSecretCommand; private readonly IDeleteSecretCommand _deleteSecretCommand; @@ -38,11 +40,13 @@ public class SecretsController : Controller private readonly IUserService _userService; private readonly IEventService _eventService; private readonly IAuthorizationService _authorizationService; + private readonly IOrganizationUserRepository _organizationUserRepository; public SecretsController( ICurrentContext currentContext, IProjectRepository projectRepository, ISecretRepository secretRepository, + ISecretVersionRepository secretVersionRepository, ICreateSecretCommand createSecretCommand, IUpdateSecretCommand updateSecretCommand, IDeleteSecretCommand deleteSecretCommand, @@ -51,11 +55,13 @@ public class SecretsController : Controller ISecretAccessPoliciesUpdatesQuery secretAccessPoliciesUpdatesQuery, IUserService userService, IEventService eventService, - IAuthorizationService authorizationService) + IAuthorizationService authorizationService, + IOrganizationUserRepository organizationUserRepository) { _currentContext = currentContext; _projectRepository = projectRepository; _secretRepository = secretRepository; + _secretVersionRepository = secretVersionRepository; _createSecretCommand = createSecretCommand; _updateSecretCommand = updateSecretCommand; _deleteSecretCommand = deleteSecretCommand; @@ -65,6 +71,7 @@ public class SecretsController : Controller _userService = userService; _eventService = eventService; _authorizationService = authorizationService; + _organizationUserRepository = organizationUserRepository; } @@ -190,6 +197,44 @@ public class SecretsController : Controller } } + // Create a version record if the value changed + if (updateRequest.ValueChanged) + { + // Store the old value before updating + var oldValue = secret.Value; + var userId = _userService.GetProperUserId(User)!.Value; + Guid? editorServiceAccountId = null; + Guid? editorOrganizationUserId = null; + + if (_currentContext.IdentityClientType == IdentityClientType.ServiceAccount) + { + editorServiceAccountId = userId; + } + else if (_currentContext.IdentityClientType == IdentityClientType.User) + { + var orgUser = await _organizationUserRepository.GetByOrganizationAsync(secret.OrganizationId, userId); + if (orgUser != null) + { + editorOrganizationUserId = orgUser.Id; + } + else + { + throw new NotFoundException(); + } + } + + var secretVersion = new SecretVersion + { + SecretId = id, + Value = oldValue, + VersionDate = DateTime.UtcNow, + EditorServiceAccountId = editorServiceAccountId, + EditorOrganizationUserId = editorOrganizationUserId + }; + + await _secretVersionRepository.CreateAsync(secretVersion); + } + var result = await _updateSecretCommand.UpdateAsync(updatedSecret, accessPoliciesUpdates); await LogSecretEventAsync(secret, EventType.Secret_Edited); diff --git a/src/Api/SecretsManager/Controllers/SecretsManagerEventsController.cs b/src/Api/SecretsManager/Controllers/SecretsManagerEventsController.cs index af162fe399..0f467a4c78 100644 --- a/src/Api/SecretsManager/Controllers/SecretsManagerEventsController.cs +++ b/src/Api/SecretsManager/Controllers/SecretsManagerEventsController.cs @@ -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; diff --git a/src/Api/SecretsManager/Models/Request/RestoreSecretVersionRequestModel.cs b/src/Api/SecretsManager/Models/Request/RestoreSecretVersionRequestModel.cs new file mode 100644 index 0000000000..19a6b35a75 --- /dev/null +++ b/src/Api/SecretsManager/Models/Request/RestoreSecretVersionRequestModel.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Api.SecretsManager.Models.Request; + +public class RestoreSecretVersionRequestModel +{ + [Required] + public Guid VersionId { get; set; } +} diff --git a/src/Api/SecretsManager/Models/Request/SecretUpdateRequestModel.cs b/src/Api/SecretsManager/Models/Request/SecretUpdateRequestModel.cs index b95bc9e500..9d19e1d8cc 100644 --- a/src/Api/SecretsManager/Models/Request/SecretUpdateRequestModel.cs +++ b/src/Api/SecretsManager/Models/Request/SecretUpdateRequestModel.cs @@ -28,6 +28,8 @@ public class SecretUpdateRequestModel : IValidatableObject public SecretAccessPoliciesRequestsModel AccessPoliciesRequests { get; set; } + public bool ValueChanged { get; set; } = false; + public Secret ToSecret(Secret secret) { secret.Key = Key; diff --git a/src/Api/SecretsManager/Models/Response/SecretVersionResponseModel.cs b/src/Api/SecretsManager/Models/Response/SecretVersionResponseModel.cs new file mode 100644 index 0000000000..07b8e88f7e --- /dev/null +++ b/src/Api/SecretsManager/Models/Response/SecretVersionResponseModel.cs @@ -0,0 +1,28 @@ +using Bit.Core.Models.Api; +using Bit.Core.SecretsManager.Entities; + +namespace Bit.Api.SecretsManager.Models.Response; + +public class SecretVersionResponseModel : ResponseModel +{ + private const string _objectName = "secretVersion"; + + public Guid Id { get; set; } + public Guid SecretId { get; set; } + public string Value { get; set; } = string.Empty; + public DateTime VersionDate { get; set; } + public Guid? EditorServiceAccountId { get; set; } + public Guid? EditorOrganizationUserId { get; set; } + + public SecretVersionResponseModel() : base(_objectName) { } + + public SecretVersionResponseModel(SecretVersion secretVersion) : base(_objectName) + { + Id = secretVersion.Id; + SecretId = secretVersion.SecretId; + Value = secretVersion.Value; + VersionDate = secretVersion.VersionDate; + EditorServiceAccountId = secretVersion.EditorServiceAccountId; + EditorOrganizationUserId = secretVersion.EditorOrganizationUserId; + } +} diff --git a/src/Api/Startup.cs b/src/Api/Startup.cs index 0967b4f662..b201cef0f3 100644 --- a/src/Api/Startup.cs +++ b/src/Api/Startup.cs @@ -85,6 +85,7 @@ public class Startup // Repositories services.AddDatabaseRepositories(globalSettings); + services.AddTestPlayIdTracking(globalSettings); // Context services.AddScoped(); @@ -187,7 +188,6 @@ public class Startup services.AddBillingOperations(); services.AddReportingServices(); services.AddImportServices(); - services.AddPhishingDomainServices(globalSettings); services.AddSendServices(); @@ -216,7 +216,7 @@ public class Startup config.Conventions.Add(new PublicApiControllersModelConvention()); }); - services.AddSwagger(globalSettings, Environment); + services.AddSwaggerGen(globalSettings, Environment); Jobs.JobsHostedService.AddJobsServices(services, globalSettings.SelfHosted); services.AddHostedService(); @@ -226,7 +226,8 @@ public class Startup services.AddHostedService(); } - // Add Slack / Teams Services for OAuth API requests - if configured + // Add Event Integrations services + services.AddEventIntegrationsCommandsQueries(globalSettings); services.AddSlackService(globalSettings); services.AddTeamsService(globalSettings); } @@ -234,12 +235,10 @@ public class Startup public void Configure( IApplicationBuilder app, IWebHostEnvironment env, - IHostApplicationLifetime appLifetime, GlobalSettings globalSettings, ILogger logger) { IdentityModelEventSource.ShowPII = true; - app.UseSerilog(env, appLifetime, globalSettings); // Add general security headers app.UseMiddleware(); @@ -294,17 +293,59 @@ public class Startup }); // Add Swagger + // Note that the swagger.json generation is configured in the call to AddSwaggerGen above. if (Environment.IsDevelopment() || globalSettings.SelfHosted) { + // adds the middleware to serve the swagger.json while the server is running app.UseSwagger(config => { config.RouteTemplate = "specs/{documentName}/swagger.json"; + + // Remove all Bitwarden cloud servers and only register the local server config.PreSerializeFilters.Add((swaggerDoc, httpReq) => - swaggerDoc.Servers = new List + { + swaggerDoc.Servers.Clear(); + swaggerDoc.Servers.Add(new OpenApiServer { - new OpenApiServer { Url = globalSettings.BaseServiceUri.Api } + Url = globalSettings.BaseServiceUri.Api, }); + + swaggerDoc.Components.SecuritySchemes.Clear(); + swaggerDoc.Components.SecuritySchemes.Add("oauth2-client-credentials", new OpenApiSecurityScheme + { + Type = SecuritySchemeType.OAuth2, + Flows = new OpenApiOAuthFlows + { + ClientCredentials = new OpenApiOAuthFlow + { + TokenUrl = new Uri($"{globalSettings.BaseServiceUri.Identity}/connect/token"), + Scopes = new Dictionary + { + { ApiScopes.ApiOrganization, "Organization APIs" } + } + } + } + }); + + swaggerDoc.SecurityRequirements.Clear(); + swaggerDoc.SecurityRequirements.Add(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = "oauth2-client-credentials" + } + }, + [ApiScopes.ApiOrganization] + } + }); + }); }); + + // adds the middleware to display the web UI app.UseSwaggerUI(config => { config.DocumentTitle = "Bitwarden API Documentation"; diff --git a/src/Api/Tools/Controllers/OrganizationExportController.cs b/src/Api/Tools/Controllers/OrganizationExportController.cs index dd039bc4a5..578220075a 100644 --- a/src/Api/Tools/Controllers/OrganizationExportController.cs +++ b/src/Api/Tools/Controllers/OrganizationExportController.cs @@ -1,6 +1,5 @@ using Bit.Api.Tools.Authorization; using Bit.Api.Tools.Models.Response; -using Bit.Core; using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization; using Bit.Core.Exceptions; using Bit.Core.Repositories; @@ -21,7 +20,6 @@ public class OrganizationExportController : Controller private readonly IAuthorizationService _authorizationService; private readonly IOrganizationCiphersQuery _organizationCiphersQuery; private readonly ICollectionRepository _collectionRepository; - private readonly IFeatureService _featureService; public OrganizationExportController( IUserService userService, @@ -36,7 +34,6 @@ public class OrganizationExportController : Controller _authorizationService = authorizationService; _organizationCiphersQuery = organizationCiphersQuery; _collectionRepository = collectionRepository; - _featureService = featureService; } [HttpGet("export")] @@ -46,33 +43,20 @@ public class OrganizationExportController : Controller VaultExportOperations.ExportWholeVault); var canExportManaged = await _authorizationService.AuthorizeAsync(User, new OrganizationScope(organizationId), VaultExportOperations.ExportManagedCollections); - var createDefaultLocationEnabled = _featureService.IsEnabled(FeatureFlagKeys.CreateDefaultLocation); if (canExportAll.Succeeded) { - if (createDefaultLocationEnabled) - { - var allOrganizationCiphers = - await _organizationCiphersQuery.GetAllOrganizationCiphersExcludingDefaultUserCollections( - organizationId); + var allOrganizationCiphers = + await _organizationCiphersQuery.GetAllOrganizationCiphersExcludingDefaultUserCollections( + organizationId); - var allCollections = await _collectionRepository - .GetManySharedCollectionsByOrganizationIdAsync( - organizationId); + var allCollections = await _collectionRepository + .GetManySharedCollectionsByOrganizationIdAsync( + organizationId); - return Ok(new OrganizationExportResponseModel(allOrganizationCiphers, allCollections, - _globalSettings)); - } - else - { - var allOrganizationCiphers = await _organizationCiphersQuery.GetAllOrganizationCiphers(organizationId); - - var allCollections = await _collectionRepository.GetManyByOrganizationIdAsync(organizationId); - - return Ok(new OrganizationExportResponseModel(allOrganizationCiphers, allCollections, - _globalSettings)); - } + return Ok(new OrganizationExportResponseModel(allOrganizationCiphers, allCollections, + _globalSettings)); } if (canExportManaged.Succeeded) diff --git a/src/Api/Tools/Controllers/SendsController.cs b/src/Api/Tools/Controllers/SendsController.cs index c54a9b90c9..f9f71d076d 100644 --- a/src/Api/Tools/Controllers/SendsController.cs +++ b/src/Api/Tools/Controllers/SendsController.cs @@ -1,21 +1,21 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using System.Text.Json; +using System.Text.Json; using Azure.Messaging.EventGrid; using Bit.Api.Models.Response; using Bit.Api.Tools.Models.Request; using Bit.Api.Tools.Models.Response; using Bit.Api.Utilities; using Bit.Core; +using Bit.Core.Auth.Identity; +using Bit.Core.Auth.UserFeatures.SendAccess; using Bit.Core.Exceptions; +using Bit.Core.Platform.Push; using Bit.Core.Services; -using Bit.Core.Settings; using Bit.Core.Tools.Enums; using Bit.Core.Tools.Models.Data; using Bit.Core.Tools.Repositories; using Bit.Core.Tools.SendFeatures; using Bit.Core.Tools.SendFeatures.Commands.Interfaces; +using Bit.Core.Tools.SendFeatures.Queries.Interfaces; using Bit.Core.Tools.Services; using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; @@ -24,7 +24,6 @@ using Microsoft.AspNetCore.Mvc; namespace Bit.Api.Tools.Controllers; [Route("sends")] -[Authorize("Application")] public class SendsController : Controller { private readonly ISendRepository _sendRepository; @@ -33,8 +32,10 @@ public class SendsController : Controller private readonly ISendFileStorageService _sendFileStorageService; private readonly IAnonymousSendCommand _anonymousSendCommand; private readonly INonAnonymousSendCommand _nonAnonymousSendCommand; + private readonly ISendOwnerQuery _sendOwnerQuery; private readonly ILogger _logger; - private readonly GlobalSettings _globalSettings; + private readonly IFeatureService _featureService; + private readonly IPushNotificationService _pushNotificationService; public SendsController( ISendRepository sendRepository, @@ -42,21 +43,26 @@ public class SendsController : Controller ISendAuthorizationService sendAuthorizationService, IAnonymousSendCommand anonymousSendCommand, INonAnonymousSendCommand nonAnonymousSendCommand, + ISendOwnerQuery sendOwnerQuery, ISendFileStorageService sendFileStorageService, ILogger logger, - GlobalSettings globalSettings) + IFeatureService featureService, + IPushNotificationService pushNotificationService) { _sendRepository = sendRepository; _userService = userService; _sendAuthorizationService = sendAuthorizationService; _anonymousSendCommand = anonymousSendCommand; _nonAnonymousSendCommand = nonAnonymousSendCommand; + _sendOwnerQuery = sendOwnerQuery; _sendFileStorageService = sendFileStorageService; _logger = logger; - _globalSettings = globalSettings; + _featureService = featureService; + _pushNotificationService = pushNotificationService; } #region Anonymous endpoints + [AllowAnonymous] [HttpPost("access/{id}")] public async Task Access(string id, [FromBody] SendAccessRequestModel model) @@ -70,28 +76,44 @@ public class SendsController : Controller var guid = new Guid(CoreHelpers.Base64UrlDecode(id)); var send = await _sendRepository.GetByIdAsync(guid); - SendAccessResult sendAuthResult = + + if (send == null) + { + throw new BadRequestException("Could not locate send"); + } + + /* This guard can be removed once feature flag is retired*/ + var sendEmailOtpEnabled = _featureService.IsEnabled(FeatureFlagKeys.SendEmailOTP); + if (sendEmailOtpEnabled && send.AuthType == AuthType.Email && send.Emails is not null) + { + return new UnauthorizedResult(); + } + + var sendAuthResult = await _sendAuthorizationService.AccessAsync(send, model.Password); if (sendAuthResult.Equals(SendAccessResult.PasswordRequired)) { return new UnauthorizedResult(); } + if (sendAuthResult.Equals(SendAccessResult.PasswordInvalid)) { await Task.Delay(2000); throw new BadRequestException("Invalid password."); } + if (sendAuthResult.Equals(SendAccessResult.Denied)) { throw new NotFoundException(); } - var sendResponse = new SendAccessResponseModel(send, _globalSettings); + var sendResponse = new SendAccessResponseModel(send); if (send.UserId.HasValue && !send.HideEmail.GetValueOrDefault()) { var creator = await _userService.GetUserByIdAsync(send.UserId.Value); sendResponse.CreatorIdentifier = creator.Email; } + return new ObjectResult(sendResponse); } @@ -115,6 +137,13 @@ public class SendsController : Controller throw new BadRequestException("Could not locate send"); } + /* This guard can be removed once feature flag is retired*/ + var sendEmailOtpEnabled = _featureService.IsEnabled(FeatureFlagKeys.SendEmailOTP); + if (sendEmailOtpEnabled && send.AuthType == AuthType.Email && send.Emails is not null) + { + return new UnauthorizedResult(); + } + var (url, result) = await _anonymousSendCommand.GetSendFileDownloadUrlAsync(send, fileId, model.Password); @@ -122,21 +151,19 @@ public class SendsController : Controller { return new UnauthorizedResult(); } + if (result.Equals(SendAccessResult.PasswordInvalid)) { await Task.Delay(2000); throw new BadRequestException("Invalid password."); } + if (result.Equals(SendAccessResult.Denied)) { throw new NotFoundException(); } - return new ObjectResult(new SendFileDownloadDataResponseModel() - { - Id = fileId, - Url = url, - }); + return new ObjectResult(new SendFileDownloadDataResponseModel() { Id = fileId, Url = url, }); } [AllowAnonymous] @@ -150,7 +177,8 @@ public class SendsController : Controller { try { - var blobName = eventGridEvent.Subject.Split($"{AzureSendFileStorageService.FilesContainerName}/blobs/")[1]; + var blobName = + eventGridEvent.Subject.Split($"{AzureSendFileStorageService.FilesContainerName}/blobs/")[1]; var sendId = AzureSendFileStorageService.SendIdFromBlobName(blobName); var send = await _sendRepository.GetByIdAsync(new Guid(sendId)); if (send == null) @@ -159,6 +187,7 @@ public class SendsController : Controller { await azureSendFileStorageService.DeleteBlobAsync(blobName); } + return; } @@ -166,7 +195,8 @@ public class SendsController : Controller } catch (Exception e) { - _logger.LogError(e, "Uncaught exception occurred while handling event grid event: {Event}", JsonSerializer.Serialize(eventGridEvent)); + _logger.LogError(e, "Uncaught exception occurred while handling event grid event: {Event}", + JsonSerializer.Serialize(eventGridEvent)); return; } } @@ -178,38 +208,98 @@ public class SendsController : Controller #region Non-anonymous endpoints + [Authorize(Policies.Application)] [HttpGet("{id}")] public async Task Get(string id) { - var userId = _userService.GetProperUserId(User).Value; - var send = await _sendRepository.GetByIdAsync(new Guid(id)); - if (send == null || send.UserId != userId) + var sendId = new Guid(id); + var send = await _sendOwnerQuery.Get(sendId, User); + return new SendResponseModel(send); + } + + [Authorize(Policies.Application)] + [HttpGet("")] + public async Task> GetAll() + { + var sends = await _sendOwnerQuery.GetOwned(User); + var responses = sends.Select(s => new SendResponseModel(s)); + var result = new ListResponseModel(responses); + + return result; + } + + [Authorize(Policy = Policies.Send)] + // [RequireFeature(FeatureFlagKeys.SendEmailOTP)] /* Uncomment once client fallback re-try logic is added */ + [HttpPost("access/")] + public async Task AccessUsingAuth() + { + var guid = User.GetSendId(); + var send = await _sendRepository.GetByIdAsync(guid); + if (send == null) + { + throw new BadRequestException("Could not locate send"); + } + if (send.MaxAccessCount.GetValueOrDefault(int.MaxValue) <= send.AccessCount || + send.ExpirationDate.GetValueOrDefault(DateTime.MaxValue) < DateTime.UtcNow || send.Disabled || + send.DeletionDate < DateTime.UtcNow) { throw new NotFoundException(); } - return new SendResponseModel(send, _globalSettings); + var sendResponse = new SendAccessResponseModel(send); + if (send.UserId.HasValue && !send.HideEmail.GetValueOrDefault()) + { + var creator = await _userService.GetUserByIdAsync(send.UserId.Value); + sendResponse.CreatorIdentifier = creator.Email; + } + + send.AccessCount++; + await _sendRepository.ReplaceAsync(send); + await _pushNotificationService.PushSyncSendUpdateAsync(send); + + return new ObjectResult(sendResponse); } - [HttpGet("")] - public async Task> GetAll() + [Authorize(Policy = Policies.Send)] + // [RequireFeature(FeatureFlagKeys.SendEmailOTP)] /* Uncomment once client fallback re-try logic is added */ + [HttpPost("access/file/{fileId}")] + public async Task GetSendFileDownloadDataUsingAuth(string fileId) { - var userId = _userService.GetProperUserId(User).Value; - var sends = await _sendRepository.GetManyByUserIdAsync(userId); - var responses = sends.Select(s => new SendResponseModel(s, _globalSettings)); - return new ListResponseModel(responses); + var sendId = User.GetSendId(); + var send = await _sendRepository.GetByIdAsync(sendId); + + if (send == null) + { + throw new BadRequestException("Could not locate send"); + } + if (send.MaxAccessCount.GetValueOrDefault(int.MaxValue) <= send.AccessCount || + send.ExpirationDate.GetValueOrDefault(DateTime.MaxValue) < DateTime.UtcNow || send.Disabled || + send.DeletionDate < DateTime.UtcNow) + { + throw new NotFoundException(); + } + + var url = await _sendFileStorageService.GetSendFileDownloadUrlAsync(send, fileId); + + send.AccessCount++; + await _sendRepository.ReplaceAsync(send); + await _pushNotificationService.PushSyncSendUpdateAsync(send); + + return new ObjectResult(new SendFileDownloadDataResponseModel() { Id = fileId, Url = url }); } + [Authorize(Policies.Application)] [HttpPost("")] public async Task Post([FromBody] SendRequestModel model) { model.ValidateCreation(); - var userId = _userService.GetProperUserId(User).Value; + var userId = _userService.GetProperUserId(User) ?? throw new InvalidOperationException("User ID not found"); var send = model.ToSend(userId, _sendAuthorizationService); await _nonAnonymousSendCommand.SaveSendAsync(send); - return new SendResponseModel(send, _globalSettings); + return new SendResponseModel(send); } + [Authorize(Policies.Application)] [HttpPost("file/v2")] public async Task PostFile([FromBody] SendRequestModel model) { @@ -229,27 +319,28 @@ public class SendsController : Controller } model.ValidateCreation(); - var userId = _userService.GetProperUserId(User).Value; + var userId = _userService.GetProperUserId(User) ?? throw new InvalidOperationException("User ID not found"); var (send, data) = model.ToSend(userId, model.File.FileName, _sendAuthorizationService); var uploadUrl = await _nonAnonymousSendCommand.SaveFileSendAsync(send, data, model.FileLength.Value); return new SendFileUploadDataResponseModel { Url = uploadUrl, FileUploadType = _sendFileStorageService.FileUploadType, - SendResponse = new SendResponseModel(send, _globalSettings) + SendResponse = new SendResponseModel(send) }; } + [Authorize(Policies.Application)] [HttpGet("{id}/file/{fileId}")] public async Task RenewFileUpload(string id, string fileId) { - var userId = _userService.GetProperUserId(User).Value; + var userId = _userService.GetProperUserId(User) ?? throw new InvalidOperationException("User ID not found"); var sendId = new Guid(id); var send = await _sendRepository.GetByIdAsync(sendId); - var fileData = JsonSerializer.Deserialize(send?.Data); + var fileData = JsonSerializer.Deserialize(send?.Data ?? string.Empty); if (send == null || send.Type != SendType.File || (send.UserId.HasValue && send.UserId.Value != userId) || - !send.UserId.HasValue || fileData.Id != fileId || fileData.Validated) + !send.UserId.HasValue || fileData?.Id != fileId || fileData.Validated) { // Not found if Send isn't found, user doesn't have access, request is faulty, // or we've already validated the file. This last is to emulate create-only blob permissions for Azure @@ -260,62 +351,95 @@ public class SendsController : Controller { Url = await _sendFileStorageService.GetSendFileUploadUrlAsync(send, fileId), FileUploadType = _sendFileStorageService.FileUploadType, - SendResponse = new SendResponseModel(send, _globalSettings), + SendResponse = new SendResponseModel(send), }; } + [Authorize(Policies.Application)] [HttpPost("{id}/file/{fileId}")] [SelfHosted(SelfHostedOnly = true)] [RequestSizeLimit(Constants.FileSize501mb)] [DisableFormValueModelBinding] public async Task PostFileForExistingSend(string id, string fileId) { - if (!Request?.ContentType.Contains("multipart/") ?? true) + if (!Request?.ContentType?.Contains("multipart/") ?? true) { throw new BadRequestException("Invalid content."); } var send = await _sendRepository.GetByIdAsync(new Guid(id)); + if (send == null) + { + throw new BadRequestException("Could not locate send"); + } + await Request.GetFileAsync(async (stream) => { await _nonAnonymousSendCommand.UploadFileToExistingSendAsync(stream, send); }); } + [Authorize(Policies.Application)] [HttpPut("{id}")] public async Task Put(string id, [FromBody] SendRequestModel model) { model.ValidateEdit(); - var userId = _userService.GetProperUserId(User).Value; + var userId = _userService.GetProperUserId(User) ?? throw new InvalidOperationException("User ID not found"); var send = await _sendRepository.GetByIdAsync(new Guid(id)); if (send == null || send.UserId != userId) { throw new NotFoundException(); } - await _nonAnonymousSendCommand.SaveSendAsync(model.ToSend(send, _sendAuthorizationService)); - return new SendResponseModel(send, _globalSettings); + await _nonAnonymousSendCommand.SaveSendAsync(model.UpdateSend(send, _sendAuthorizationService)); + return new SendResponseModel(send); } + [Authorize(Policies.Application)] [HttpPut("{id}/remove-password")] public async Task PutRemovePassword(string id) { - var userId = _userService.GetProperUserId(User).Value; + var userId = _userService.GetProperUserId(User) ?? throw new InvalidOperationException("User ID not found"); var send = await _sendRepository.GetByIdAsync(new Guid(id)); if (send == null || send.UserId != userId) { throw new NotFoundException(); } + // This endpoint exists because PUT preserves existing Password/Emails when not provided. + // This allows clients to update other fields without re-submitting sensitive auth data. send.Password = null; + send.AuthType = AuthType.None; await _nonAnonymousSendCommand.SaveSendAsync(send); - return new SendResponseModel(send, _globalSettings); + return new SendResponseModel(send); } + // Removes ALL authentication (email or password) if any is present + [Authorize(Policies.Application)] + [HttpPut("{id}/remove-auth")] + public async Task PutRemoveAuth(string id) + { + var userId = _userService.GetProperUserId(User) ?? throw new InvalidOperationException("User ID not found"); + var send = await _sendRepository.GetByIdAsync(new Guid(id)); + if (send == null || send.UserId != userId) + { + throw new NotFoundException(); + } + + // This endpoint exists because PUT preserves existing Password/Emails when not provided. + // This allows clients to update other fields without re-submitting sensitive auth data. + send.Password = null; + send.Emails = null; + send.AuthType = AuthType.None; + await _nonAnonymousSendCommand.SaveSendAsync(send); + return new SendResponseModel(send); + } + + [Authorize(Policies.Application)] [HttpDelete("{id}")] public async Task Delete(string id) { - var userId = _userService.GetProperUserId(User).Value; + var userId = _userService.GetProperUserId(User) ?? throw new InvalidOperationException("User ID not found"); var send = await _sendRepository.GetByIdAsync(new Guid(id)); if (send == null || send.UserId != userId) { diff --git a/src/Api/Tools/Models/Request/SendRequestModel.cs b/src/Api/Tools/Models/Request/SendRequestModel.cs index a38257db60..f3308dbd5a 100644 --- a/src/Api/Tools/Models/Request/SendRequestModel.cs +++ b/src/Api/Tools/Models/Request/SendRequestModel.cs @@ -3,6 +3,7 @@ using System.ComponentModel.DataAnnotations; using System.Text.Json; +using Bit.Api.Tools.Utilities; using Bit.Core.Exceptions; using Bit.Core.Tools.Entities; using Bit.Core.Tools.Enums; @@ -10,35 +11,119 @@ using Bit.Core.Tools.Models.Data; using Bit.Core.Tools.Services; using Bit.Core.Utilities; +using static System.StringSplitOptions; + namespace Bit.Api.Tools.Models.Request; +/// +/// A send request issued by a Bitwarden client +/// public class SendRequestModel { + /// + /// Indicates whether the send contains text or file data. + /// public SendType Type { get; set; } + + /// + /// Specifies the authentication method required to access this Send. + /// + public AuthType? AuthType { get; set; } + + /// + /// Estimated length of the file accompanying the send. when + /// is . + /// public long? FileLength { get; set; } = null; + + /// + /// Label for the send. + /// [EncryptedString] [EncryptedStringLength(1000)] public string Name { get; set; } + + /// + /// Notes for the send. This is only visible to the owner of the send. + /// [EncryptedString] [EncryptedStringLength(1000)] public string Notes { get; set; } + + /// + /// A base64-encoded byte array containing the Send's encryption key. This key is + /// also provided to send recipients in the Send's URL. + /// [Required] [EncryptedString] [EncryptedStringLength(1000)] public string Key { get; set; } + + /// + /// The maximum number of times a send can be accessed before it expires. + /// When this value is , there is no limit. + /// [Range(1, int.MaxValue)] public int? MaxAccessCount { get; set; } + + /// + /// The date after which a send cannot be accessed. When this value is + /// , there is no expiration date. + /// public DateTime? ExpirationDate { get; set; } + + /// + /// The date after which a send may be automatically deleted from the server. + /// When this is , the send may be deleted after it has + /// exceeded the global send timeout limit. + /// [Required] public DateTime? DeletionDate { get; set; } + + /// + /// Contains file metadata uploaded with the send. + /// The file content is uploaded separately. + /// public SendFileModel File { get; set; } + + /// + /// Contains text data uploaded with the send. + /// public SendTextModel Text { get; set; } + + /// + /// Base64-encoded byte array of a password hash that grants access to the send. + /// Mutually exclusive with . + /// [StringLength(1000)] public string Password { get; set; } + + /// + /// Comma-separated list of emails that may access the send using OTP + /// authentication. Mutually exclusive with . + /// + [StringLength(4000)] + public string Emails { get; set; } + + /// + /// When , send access is disabled. + /// Defaults to . + /// [Required] public bool? Disabled { get; set; } + + /// + /// When send access hides the user's email address + /// and displays a confirmation message instead. Defaults to . + /// public bool? HideEmail { get; set; } + /// + /// Transforms the request into a send object. + /// + /// The user that owns the send. + /// Hashes the send password. + /// The send object public Send ToSend(Guid userId, ISendAuthorizationService sendAuthorizationService) { var send = new Send @@ -46,12 +131,21 @@ public class SendRequestModel Type = Type, UserId = (Guid?)userId }; - ToSend(send, sendAuthorizationService); + send = UpdateSend(send, sendAuthorizationService); return send; } + /// + /// Transforms the request into a send object and file data. + /// + /// The user that owns the send. + /// Name of the file uploaded with the send. + /// Hashes the send password. + /// The send object and file data. public (Send, SendFileData) ToSend(Guid userId, string fileName, ISendAuthorizationService sendAuthorizationService) { + // FIXME: This method does two things: creates a send and a send file data. + // It should only do one thing. var send = ToSendBase(new Send { Type = Type, @@ -61,7 +155,13 @@ public class SendRequestModel return (send, data); } - public Send ToSend(Send existingSend, ISendAuthorizationService sendAuthorizationService) + /// + /// Update a send object with request content + /// + /// The send to update + /// Hashes the send password. + /// The send object + public Send UpdateSend(Send existingSend, ISendAuthorizationService sendAuthorizationService) { existingSend = ToSendBase(existingSend, sendAuthorizationService); switch (existingSend.Type) @@ -81,6 +181,12 @@ public class SendRequestModel return existingSend; } + /// + /// Validates that the request is internally consistent for send creation. + /// + /// + /// Thrown when the send's expiration date has already expired. + /// public void ValidateCreation() { var now = DateTime.UtcNow; @@ -94,6 +200,13 @@ public class SendRequestModel ValidateEdit(); } + /// + /// Validates that the request is internally consistent for send administration. + /// + /// + /// Thrown when the send's deletion date has already expired or when its + /// expiration occurs after its deletion. + /// public void ValidateEdit() { var now = DateTime.UtcNow; @@ -134,12 +247,30 @@ public class SendRequestModel existingSend.ExpirationDate = ExpirationDate; existingSend.DeletionDate = DeletionDate.Value; existingSend.MaxAccessCount = MaxAccessCount; - if (!string.IsNullOrWhiteSpace(Password)) + + if (!string.IsNullOrWhiteSpace(Emails)) + { + // normalize encoding + var emails = Emails.Split(',', RemoveEmptyEntries | TrimEntries); + existingSend.Emails = string.Join(",", emails); + existingSend.Password = null; + existingSend.AuthType = Core.Tools.Enums.AuthType.Email; + } + else if (!string.IsNullOrWhiteSpace(Password)) { existingSend.Password = authorizationService.HashPassword(Password); + existingSend.Emails = null; + existingSend.AuthType = Core.Tools.Enums.AuthType.Password; } + else + { + // Neither Password nor Emails provided - preserve existing values and infer AuthType + existingSend.AuthType = SendUtilities.InferAuthType(existingSend); + } + existingSend.Disabled = Disabled.GetValueOrDefault(); existingSend.HideEmail = HideEmail.GetValueOrDefault(); + return existingSend; } @@ -149,8 +280,15 @@ public class SendRequestModel } } +/// +/// A send request issued by a Bitwarden client +/// public class SendWithIdRequestModel : SendRequestModel { + /// + /// Identifies the send. When this is , the client is requesting + /// a new send. + /// [Required] public Guid? Id { get; set; } } diff --git a/src/Api/Tools/Models/Response/SendAccessResponseModel.cs b/src/Api/Tools/Models/Response/SendAccessResponseModel.cs index b544862fcd..b722dd5fff 100644 --- a/src/Api/Tools/Models/Response/SendAccessResponseModel.cs +++ b/src/Api/Tools/Models/Response/SendAccessResponseModel.cs @@ -3,7 +3,6 @@ using System.Text.Json; using Bit.Core.Models.Api; -using Bit.Core.Settings; using Bit.Core.Tools.Entities; using Bit.Core.Tools.Enums; using Bit.Core.Tools.Models.Data; @@ -11,9 +10,22 @@ using Bit.Core.Utilities; namespace Bit.Api.Tools.Models.Response; +/// +/// A response issued to a Bitwarden client in response to access operations. +/// public class SendAccessResponseModel : ResponseModel { - public SendAccessResponseModel(Send send, GlobalSettings globalSettings) + /// + /// Instantiates a send access response model + /// + /// Content to transmit to the client. + /// + /// Thrown when is + /// + /// + /// Thrown when has an invalid . + /// + public SendAccessResponseModel(Send send) : base("send-access") { if (send == null) @@ -23,6 +35,7 @@ public class SendAccessResponseModel : ResponseModel Id = CoreHelpers.Base64UrlEncode(send.Id.ToByteArray()); Type = send.Type; + AuthType = send.AuthType; SendData sendData; switch (send.Type) @@ -45,11 +58,52 @@ public class SendAccessResponseModel : ResponseModel ExpirationDate = send.ExpirationDate; } + /// + /// Identifies the send in a send URL + /// public string Id { get; set; } + + /// + /// Indicates whether the send contains text or file data. + /// public SendType Type { get; set; } + + /// + /// Specifies the authentication method required to access this Send. + /// + public AuthType? AuthType { get; set; } + + /// + /// Label for the send. This is only visible to the owner of the send. + /// + /// + /// This field contains a base64-encoded byte array. The array contains + /// the E2E-encrypted encrypted content. + /// public string Name { get; set; } + + /// + /// Describes the file attached to the send. + /// + /// + /// File content is downloaded separately using + /// + /// public SendFileModel File { get; set; } + + /// + /// Contains text data uploaded with the send. + /// public SendTextModel Text { get; set; } + + /// + /// The date after which a send cannot be accessed. When this value is + /// , there is no expiration date. + /// public DateTime? ExpirationDate { get; set; } + + /// + /// Indicates the person that created the send to the accessor. + /// public string CreatorIdentifier { get; set; } } diff --git a/src/Api/Tools/Models/Response/SendResponseModel.cs b/src/Api/Tools/Models/Response/SendResponseModel.cs index 17a70cd2db..f7f6b683d6 100644 --- a/src/Api/Tools/Models/Response/SendResponseModel.cs +++ b/src/Api/Tools/Models/Response/SendResponseModel.cs @@ -2,8 +2,8 @@ #nullable disable using System.Text.Json; +using Bit.Api.Tools.Utilities; using Bit.Core.Models.Api; -using Bit.Core.Settings; using Bit.Core.Tools.Entities; using Bit.Core.Tools.Enums; using Bit.Core.Tools.Models.Data; @@ -11,9 +11,23 @@ using Bit.Core.Utilities; namespace Bit.Api.Tools.Models.Response; +/// +/// A response issued to a Bitwarden client in response to ownership operations. +/// +/// public class SendResponseModel : ResponseModel { - public SendResponseModel(Send send, GlobalSettings globalSettings) + /// + /// Instantiates a send response model + /// + /// Content to transmit to the client. + /// + /// Thrown when is + /// + /// + /// Thrown when has an invalid . + /// + public SendResponseModel(Send send) : base("send") { if (send == null) @@ -24,6 +38,7 @@ public class SendResponseModel : ResponseModel Id = send.Id; AccessId = CoreHelpers.Base64UrlEncode(send.Id.ToByteArray()); Type = send.Type; + AuthType = send.AuthType ?? SendUtilities.InferAuthType(send); Key = send.Key; MaxAccessCount = send.MaxAccessCount; AccessCount = send.AccessCount; @@ -31,6 +46,7 @@ public class SendResponseModel : ResponseModel ExpirationDate = send.ExpirationDate; DeletionDate = send.DeletionDate; Password = send.Password; + Emails = send.Emails; Disabled = send.Disabled; HideEmail = send.HideEmail.GetValueOrDefault(); @@ -55,20 +71,113 @@ public class SendResponseModel : ResponseModel Notes = sendData.Notes; } + /// + /// Identifies the send to its owner + /// public Guid Id { get; set; } + + /// + /// Identifies the send in a send URL + /// public string AccessId { get; set; } + + /// + /// Indicates whether the send contains text or file data. + /// public SendType Type { get; set; } + + /// + /// Specifies the authentication method required to access this Send. + /// + public AuthType? AuthType { get; set; } + + /// + /// Label for the send. + /// + /// + /// This field contains a base64-encoded byte array. The array contains + /// the E2E-encrypted encrypted content. + /// public string Name { get; set; } + + /// + /// Notes for the send. This is only visible to the owner of the send. + /// This field is encrypted. + /// + /// + /// This field contains a base64-encoded byte array. The array contains + /// the E2E-encrypted encrypted content. + /// public string Notes { get; set; } + + /// + /// Contains file metadata uploaded with the send. + /// The file content is uploaded separately. + /// public SendFileModel File { get; set; } + + /// + /// Contains text data uploaded with the send. + /// public SendTextModel Text { get; set; } + + /// + /// A base64-encoded byte array containing the Send's encryption key. + /// It's also provided to send recipients in the Send's URL. + /// + /// + /// This field contains a base64-encoded byte array. The array contains + /// the E2E-encrypted content. + /// public string Key { get; set; } + + /// + /// The maximum number of times a send can be accessed before it expires. + /// When this value is , there is no limit. + /// public int? MaxAccessCount { get; set; } + + /// + /// The number of times a send has been accessed since it was created. + /// public int AccessCount { get; set; } + + /// + /// Base64-encoded byte array of a password hash that grants access to the send. + /// Mutually exclusive with . + /// public string Password { get; set; } + + /// + /// Comma-separated list of emails that may access the send using OTP + /// authentication. Mutually exclusive with . + /// + public string Emails { get; set; } + + /// + /// When , send access is disabled. + /// public bool Disabled { get; set; } + + /// + /// The last time this send's data changed. + /// public DateTime RevisionDate { get; set; } + + /// + /// The date after which a send cannot be accessed. When this value is + /// , there is no expiration date. + /// public DateTime? ExpirationDate { get; set; } + + /// + /// The date after which a send may be automatically deleted from the server. + /// public DateTime DeletionDate { get; set; } + + /// + /// When send access hides the user's email address + /// and displays a confirmation message instead. + /// public bool HideEmail { get; set; } } diff --git a/src/Api/Tools/Utilities/InferAuthType.cs b/src/Api/Tools/Utilities/InferAuthType.cs new file mode 100644 index 0000000000..785fde1ec9 --- /dev/null +++ b/src/Api/Tools/Utilities/InferAuthType.cs @@ -0,0 +1,23 @@ +namespace Bit.Api.Tools.Utilities; + +using Bit.Core.Tools.Entities; +using Bit.Core.Tools.Enums; + +public class SendUtilities +{ + public static AuthType InferAuthType(Send send) + { + if (!string.IsNullOrWhiteSpace(send.Password)) + { + return AuthType.Password; + } + + if (!string.IsNullOrWhiteSpace(send.Emails)) + { + return AuthType.Email; + } + + return AuthType.None; + } +} + diff --git a/src/Api/Utilities/DiagnosticTools/EventDiagnosticLogger.cs b/src/Api/Utilities/DiagnosticTools/EventDiagnosticLogger.cs index 9f6a8d2639..af34931181 100644 --- a/src/Api/Utilities/DiagnosticTools/EventDiagnosticLogger.cs +++ b/src/Api/Utilities/DiagnosticTools/EventDiagnosticLogger.cs @@ -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 data, + IEnumerable data, string? continuationToken, DateTime? queryStart = null, DateTime? queryEnd = null) diff --git a/src/Api/Utilities/ServiceCollectionExtensions.cs b/src/Api/Utilities/ServiceCollectionExtensions.cs index 6af688f548..b773abf6ef 100644 --- a/src/Api/Utilities/ServiceCollectionExtensions.cs +++ b/src/Api/Utilities/ServiceCollectionExtensions.cs @@ -1,15 +1,11 @@ using Bit.Api.AdminConsole.Authorization; using Bit.Api.Tools.Authorization; -using Bit.Core.Auth.IdentityServer; -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; using Bit.SharedWeb.Health; using Bit.SharedWeb.Swagger; +using Bit.SharedWeb.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.OpenApi.Models; @@ -17,7 +13,10 @@ namespace Bit.Api.Utilities; public static class ServiceCollectionExtensions { - public static void AddSwagger(this IServiceCollection services, GlobalSettings globalSettings, IWebHostEnvironment environment) + /// + /// Configures the generation of swagger.json OpenAPI spec. + /// + public static void AddSwaggerGen(this IServiceCollection services, GlobalSettings globalSettings, IWebHostEnvironment environment) { services.AddSwaggerGen(config => { @@ -36,6 +35,8 @@ public static class ServiceCollectionExtensions organizations tools for managing members, collections, groups, event logs, and policies. If you are looking for the Vault Management API, refer instead to [this document](https://bitwarden.com/help/vault-management-api/). + + **Note:** your authorization must match the server you have selected. """, License = new OpenApiLicense { @@ -46,36 +47,20 @@ public static class ServiceCollectionExtensions config.SwaggerDoc("internal", new OpenApiInfo { Title = "Bitwarden Internal API", Version = "latest" }); - config.AddSecurityDefinition("oauth2-client-credentials", new OpenApiSecurityScheme - { - Type = SecuritySchemeType.OAuth2, - Flows = new OpenApiOAuthFlows - { - ClientCredentials = new OpenApiOAuthFlow - { - TokenUrl = new Uri($"{globalSettings.BaseServiceUri.Identity}/connect/token"), - Scopes = new Dictionary - { - { ApiScopes.ApiOrganization, "Organization APIs" }, - }, - } - }, - }); + // Configure Bitwarden cloud US and EU servers. These will appear in the swagger.json build artifact + // used for our help center. These are overwritten with the local server when running in self-hosted + // or dev mode (see Api Startup.cs). + config.AddSwaggerServerWithSecurity( + serverId: "US_server", + serverUrl: "https://api.bitwarden.com", + identityTokenUrl: "https://identity.bitwarden.com/connect/token", + serverDescription: "US server"); - config.AddSecurityRequirement(new OpenApiSecurityRequirement - { - { - new OpenApiSecurityScheme - { - Reference = new OpenApiReference - { - Type = ReferenceType.SecurityScheme, - Id = "oauth2-client-credentials" - }, - }, - new[] { ApiScopes.ApiOrganization } - } - }); + config.AddSwaggerServerWithSecurity( + serverId: "EU_server", + serverUrl: "https://api.bitwarden.eu", + identityTokenUrl: "https://identity.bitwarden.eu/connect/token", + serverDescription: "EU server"); config.DescribeAllParametersInCamelCase(); // config.UseReferencedDefinitionsForEnums(); @@ -114,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(); - services.AddSingleton(); - - if (globalSettings.SelfHosted) - { - services.AddScoped(); - } - else - { - services.AddScoped(); - } - } } diff --git a/src/Api/Vault/Controllers/CiphersController.cs b/src/Api/Vault/Controllers/CiphersController.cs index c200810156..9e107b491d 100644 --- a/src/Api/Vault/Controllers/CiphersController.cs +++ b/src/Api/Vault/Controllers/CiphersController.cs @@ -10,7 +10,6 @@ using Bit.Api.Utilities; using Bit.Api.Vault.Models.Request; using Bit.Api.Vault.Models.Response; using Bit.Core; -using Bit.Core.AdminConsole.Services; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; @@ -43,7 +42,6 @@ public class CiphersController : Controller private readonly ICipherService _cipherService; private readonly IUserService _userService; private readonly IAttachmentStorageService _attachmentStorageService; - private readonly IProviderService _providerService; private readonly ICurrentContext _currentContext; private readonly ILogger _logger; private readonly GlobalSettings _globalSettings; @@ -52,7 +50,6 @@ public class CiphersController : Controller private readonly ICollectionRepository _collectionRepository; private readonly IArchiveCiphersCommand _archiveCiphersCommand; private readonly IUnarchiveCiphersCommand _unarchiveCiphersCommand; - private readonly IFeatureService _featureService; public CiphersController( ICipherRepository cipherRepository, @@ -60,7 +57,6 @@ public class CiphersController : Controller ICipherService cipherService, IUserService userService, IAttachmentStorageService attachmentStorageService, - IProviderService providerService, ICurrentContext currentContext, ILogger logger, GlobalSettings globalSettings, @@ -68,15 +64,13 @@ public class CiphersController : Controller IApplicationCacheService applicationCacheService, ICollectionRepository collectionRepository, IArchiveCiphersCommand archiveCiphersCommand, - IUnarchiveCiphersCommand unarchiveCiphersCommand, - IFeatureService featureService) + IUnarchiveCiphersCommand unarchiveCiphersCommand) { _cipherRepository = cipherRepository; _collectionCipherRepository = collectionCipherRepository; _cipherService = cipherService; _userService = userService; _attachmentStorageService = attachmentStorageService; - _providerService = providerService; _currentContext = currentContext; _logger = logger; _globalSettings = globalSettings; @@ -85,7 +79,6 @@ public class CiphersController : Controller _collectionRepository = collectionRepository; _archiveCiphersCommand = archiveCiphersCommand; _unarchiveCiphersCommand = unarchiveCiphersCommand; - _featureService = featureService; } [HttpGet("{id}")] @@ -344,8 +337,7 @@ public class CiphersController : Controller throw new NotFoundException(); } - bool excludeDefaultUserCollections = _featureService.IsEnabled(FeatureFlagKeys.CreateDefaultLocation) && !includeMemberItems; - var allOrganizationCiphers = excludeDefaultUserCollections + var allOrganizationCiphers = !includeMemberItems ? await _organizationCiphersQuery.GetAllOrganizationCiphersExcludingDefaultUserCollections(organizationId) : @@ -757,15 +749,10 @@ public class CiphersController : Controller } } - if (cipher.ArchivedDate.HasValue) - { - throw new BadRequestException("Cannot move an archived item to an organization."); - } - ValidateClientVersionForFido2CredentialSupport(cipher); var original = cipher.Clone(); - await _cipherService.ShareAsync(original, model.Cipher.ToCipher(cipher), new Guid(model.Cipher.OrganizationId), + await _cipherService.ShareAsync(original, model.Cipher.ToCipher(cipher, user.Id), new Guid(model.Cipher.OrganizationId), model.CollectionIds.Select(c => new Guid(c)), user.Id, model.Cipher.LastKnownRevisionDate); var sharedCipher = await GetByIdAsync(id, user.Id); @@ -916,7 +903,7 @@ public class CiphersController : Controller [HttpPut("{id}/archive")] [RequireFeature(FeatureFlagKeys.ArchiveVaultItems)] - public async Task PutArchive(Guid id) + public async Task PutArchive(Guid id) { var userId = _userService.GetProperUserId(User).Value; @@ -927,12 +914,16 @@ public class CiphersController : Controller throw new BadRequestException("Cipher was not archived. Ensure the provided ID is correct and you have permission to archive it."); } - return new CipherMiniResponseModel(archivedCipherOrganizationDetails.First(), _globalSettings, archivedCipherOrganizationDetails.First().OrganizationUseTotp); + return new CipherResponseModel(archivedCipherOrganizationDetails.First(), + await _userService.GetUserByPrincipalAsync(User), + await _applicationCacheService.GetOrganizationAbilitiesAsync(), + _globalSettings + ); } [HttpPut("archive")] [RequireFeature(FeatureFlagKeys.ArchiveVaultItems)] - public async Task> PutArchiveMany([FromBody] CipherBulkArchiveRequestModel model) + public async Task> PutArchiveMany([FromBody] CipherBulkArchiveRequestModel model) { if (!_globalSettings.SelfHosted && model.Ids.Count() > 500) { @@ -940,6 +931,7 @@ public class CiphersController : Controller } var userId = _userService.GetProperUserId(User).Value; + var user = await _userService.GetUserByPrincipalAsync(User); var cipherIdsToArchive = new HashSet(model.Ids); @@ -950,9 +942,14 @@ public class CiphersController : Controller throw new BadRequestException("No ciphers were archived. Ensure the provided IDs are correct and you have permission to archive them."); } - var responses = archivedCiphers.Select(c => new CipherMiniResponseModel(c, _globalSettings, c.OrganizationUseTotp)); + var organizationAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync(); + var responses = archivedCiphers.Select(c => new CipherResponseModel(c, + user, + organizationAbilities, + _globalSettings + )); - return new ListResponseModel(responses); + return new ListResponseModel(responses); } [HttpDelete("{id}")] @@ -1114,7 +1111,7 @@ public class CiphersController : Controller [HttpPut("{id}/unarchive")] [RequireFeature(FeatureFlagKeys.ArchiveVaultItems)] - public async Task PutUnarchive(Guid id) + public async Task PutUnarchive(Guid id) { var userId = _userService.GetProperUserId(User).Value; @@ -1125,12 +1122,16 @@ public class CiphersController : Controller throw new BadRequestException("Cipher was not unarchived. Ensure the provided ID is correct and you have permission to archive it."); } - return new CipherMiniResponseModel(unarchivedCipherDetails.First(), _globalSettings, unarchivedCipherDetails.First().OrganizationUseTotp); + return new CipherResponseModel(unarchivedCipherDetails.First(), + await _userService.GetUserByPrincipalAsync(User), + await _applicationCacheService.GetOrganizationAbilitiesAsync(), + _globalSettings + ); } [HttpPut("unarchive")] [RequireFeature(FeatureFlagKeys.ArchiveVaultItems)] - public async Task> PutUnarchiveMany([FromBody] CipherBulkUnarchiveRequestModel model) + public async Task> PutUnarchiveMany([FromBody] CipherBulkUnarchiveRequestModel model) { if (!_globalSettings.SelfHosted && model.Ids.Count() > 500) { @@ -1138,6 +1139,8 @@ public class CiphersController : Controller } var userId = _userService.GetProperUserId(User).Value; + var user = await _userService.GetUserByPrincipalAsync(User); + var organizationAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync(); var cipherIdsToUnarchive = new HashSet(model.Ids); @@ -1148,9 +1151,9 @@ public class CiphersController : Controller throw new BadRequestException("Ciphers were not unarchived. Ensure the provided ID is correct and you have permission to archive it."); } - var responses = unarchivedCipherOrganizationDetails.Select(c => new CipherMiniResponseModel(c, _globalSettings, c.OrganizationUseTotp)); + var responses = unarchivedCipherOrganizationDetails.Select(c => new CipherResponseModel(c, user, organizationAbilities, _globalSettings)); - return new ListResponseModel(responses); + return new ListResponseModel(responses); } [HttpPut("{id}/restore")] @@ -1271,11 +1274,6 @@ public class CiphersController : Controller _logger.LogError("Cipher was not encrypted for the current user. CipherId: {CipherId}, CurrentUser: {CurrentUserId}, EncryptedFor: {EncryptedFor}", cipher.Id, userId, cipher.EncryptedFor); throw new BadRequestException("Cipher was not encrypted for the current user. Please try again."); } - - if (cipher.ArchivedDate.HasValue) - { - throw new BadRequestException("Cannot move archived items to an organization."); - } } var shareCiphers = new List<(CipherDetails, DateTime?)>(); @@ -1288,11 +1286,6 @@ public class CiphersController : Controller ValidateClientVersionForFido2CredentialSupport(existingCipher); - if (existingCipher.ArchivedDate.HasValue) - { - throw new BadRequestException("Cannot move archived items to an organization."); - } - shareCiphers.Add((cipher.ToCipherDetails(existingCipher), cipher.LastKnownRevisionDate)); } diff --git a/src/Api/Vault/Models/Request/CipherRequestModel.cs b/src/Api/Vault/Models/Request/CipherRequestModel.cs index b0589a62f9..9a2c279a28 100644 --- a/src/Api/Vault/Models/Request/CipherRequestModel.cs +++ b/src/Api/Vault/Models/Request/CipherRequestModel.cs @@ -80,11 +80,12 @@ public class CipherRequestModel { existingCipher.FolderId = string.IsNullOrWhiteSpace(FolderId) ? null : (Guid?)new Guid(FolderId); existingCipher.Favorite = Favorite; + existingCipher.ArchivedDate = ArchivedDate; ToCipher(existingCipher); return existingCipher; } - public Cipher ToCipher(Cipher existingCipher) + public Cipher ToCipher(Cipher existingCipher, Guid? userId = null) { // If Data field is provided, use it directly if (!string.IsNullOrWhiteSpace(Data)) @@ -124,9 +125,12 @@ public class CipherRequestModel } } + var userIdKey = userId.HasValue ? userId.ToString().ToUpperInvariant() : null; existingCipher.Reprompt = Reprompt; existingCipher.Key = Key; - existingCipher.ArchivedDate = ArchivedDate; + existingCipher.Folders = UpdateUserSpecificJsonField(existingCipher.Folders, userIdKey, FolderId); + existingCipher.Favorites = UpdateUserSpecificJsonField(existingCipher.Favorites, userIdKey, Favorite); + existingCipher.Archives = UpdateUserSpecificJsonField(existingCipher.Archives, userIdKey, ArchivedDate); var hasAttachments2 = (Attachments2?.Count ?? 0) > 0; var hasAttachments = (Attachments?.Count ?? 0) > 0; @@ -291,6 +295,37 @@ public class CipherRequestModel KeyFingerprint = SSHKey.KeyFingerprint, }; } + + /// + /// Updates a JSON string representing a dictionary by adding, updating, or removing a key-value pair + /// based on the provided userIdKey and newValue. + /// + private static string UpdateUserSpecificJsonField(string existingJson, string userIdKey, object newValue) + { + if (userIdKey == null) + { + return existingJson; + } + + var jsonDict = string.IsNullOrWhiteSpace(existingJson) + ? new Dictionary() + : JsonSerializer.Deserialize>(existingJson) ?? new Dictionary(); + + var shouldRemove = newValue == null || + (newValue is string strValue && string.IsNullOrWhiteSpace(strValue)) || + (newValue is bool boolValue && !boolValue); + + if (shouldRemove) + { + jsonDict.Remove(userIdKey); + } + else + { + jsonDict[userIdKey] = newValue is string str ? str.ToUpperInvariant() : newValue; + } + + return jsonDict.Count == 0 ? null : JsonSerializer.Serialize(jsonDict); + } } public class CipherWithIdRequestModel : CipherRequestModel diff --git a/src/Api/Vault/Models/Response/CipherResponseModel.cs b/src/Api/Vault/Models/Response/CipherResponseModel.cs index dfacc1a551..ac11eb3cd3 100644 --- a/src/Api/Vault/Models/Response/CipherResponseModel.cs +++ b/src/Api/Vault/Models/Response/CipherResponseModel.cs @@ -70,7 +70,6 @@ public class CipherMiniResponseModel : ResponseModel DeletedDate = cipher.DeletedDate; Reprompt = cipher.Reprompt.GetValueOrDefault(CipherRepromptType.None); Key = cipher.Key; - ArchivedDate = cipher.ArchivedDate; } public Guid Id { get; set; } @@ -111,7 +110,6 @@ public class CipherMiniResponseModel : ResponseModel public DateTime? DeletedDate { get; set; } public CipherRepromptType Reprompt { get; set; } public string Key { get; set; } - public DateTime? ArchivedDate { get; set; } } public class CipherResponseModel : CipherMiniResponseModel @@ -127,6 +125,7 @@ public class CipherResponseModel : CipherMiniResponseModel FolderId = cipher.FolderId; Favorite = cipher.Favorite; Edit = cipher.Edit; + ArchivedDate = cipher.ArchivedDate; ViewPassword = cipher.ViewPassword; Permissions = new CipherPermissionsResponseModel(user, cipher, organizationAbilities); } @@ -135,6 +134,7 @@ public class CipherResponseModel : CipherMiniResponseModel public bool Favorite { get; set; } public bool Edit { get; set; } public bool ViewPassword { get; set; } + public DateTime? ArchivedDate { get; set; } public CipherPermissionsResponseModel Permissions { get; set; } } diff --git a/src/Api/Vault/Models/Response/SyncResponseModel.cs b/src/Api/Vault/Models/Response/SyncResponseModel.cs index 1981ac834e..c965320b94 100644 --- a/src/Api/Vault/Models/Response/SyncResponseModel.cs +++ b/src/Api/Vault/Models/Response/SyncResponseModel.cs @@ -56,7 +56,7 @@ public class SyncResponseModel() : ResponseModel("sync") c => new CollectionDetailsResponseModel(c)) ?? new List(); Domains = excludeDomains ? null : new DomainsResponseModel(user, false); Policies = policies?.Select(p => new PolicyResponseModel(p)) ?? new List(); - Sends = sends.Select(s => new SendResponseModel(s, globalSettings)); + Sends = sends.Select(s => new SendResponseModel(s)); UserDecryption = new UserDecryptionResponseModel { MasterPasswordUnlock = user.HasMasterPassword() diff --git a/src/Api/appsettings.Development.json b/src/Api/appsettings.Development.json index 87e92c4516..deb0a35d84 100644 --- a/src/Api/appsettings.Development.json +++ b/src/Api/appsettings.Development.json @@ -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" } } diff --git a/src/Api/appsettings.Production.json b/src/Api/appsettings.Production.json index d9efbcda12..a6578c08dc 100644 --- a/src/Api/appsettings.Production.json +++ b/src/Api/appsettings.Production.json @@ -23,11 +23,9 @@ } }, "Logging": { - "IncludeScopes": false, "LogLevel": { - "Default": "Debug", - "System": "Information", - "Microsoft": "Information" + "Default": "Information", + "Microsoft.AspNetCore": "Warning" }, "Console": { "IncludeScopes": true, diff --git a/src/Api/appsettings.json b/src/Api/appsettings.json index 98bb4df8ac..8850c3d269 100644 --- a/src/Api/appsettings.json +++ b/src/Api/appsettings.json @@ -32,9 +32,6 @@ "send": { "connectionString": "SECRET" }, - "sentry": { - "dsn": "SECRET" - }, "notificationHub": { "connectionString": "SECRET", "hubName": "SECRET" @@ -72,9 +69,6 @@ "accessKeySecret": "SECRET", "region": "SECRET" }, - "phishingDomain": { - "updateUrl": "SECRET" - }, "distributedIpRateLimiting": { "enabled": true, "maxRedisTimeoutsThreshold": 10, diff --git a/src/Billing/Billing.csproj b/src/Billing/Billing.csproj index e2b7447eb7..27ee9a7ce3 100644 --- a/src/Billing/Billing.csproj +++ b/src/Billing/Billing.csproj @@ -1,7 +1,16 @@  + bitwarden-Billing + + $(WarningsNotAsErrors);CA1305 + + + + + false + false @@ -11,7 +20,7 @@ - + diff --git a/src/Billing/BillingSettings.cs b/src/Billing/BillingSettings.cs index 64a52ed290..2830f603ac 100644 --- a/src/Billing/BillingSettings.cs +++ b/src/Billing/BillingSettings.cs @@ -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; } - /// - /// Indicates the data center region. Valid values are "US" and "EU" - /// - 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; - } } diff --git a/src/Billing/Controllers/BitPayController.cs b/src/Billing/Controllers/BitPayController.cs index b24a8d8c36..f55b4523af 100644 --- a/src/Billing/Controllers/BitPayController.cs +++ b/src/Billing/Controllers/BitPayController.cs @@ -29,7 +29,7 @@ public class BitPayController( IUserRepository userRepository, IProviderRepository providerRepository, IMailService mailService, - IPaymentService paymentService, + IStripePaymentService paymentService, ILogger logger, IPremiumUserBillingService premiumUserBillingService) : Controller diff --git a/src/Billing/Controllers/FreshdeskController.cs b/src/Billing/Controllers/FreshdeskController.cs deleted file mode 100644 index 38ed05cfdf..0000000000 --- a/src/Billing/Controllers/FreshdeskController.cs +++ /dev/null @@ -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 _logger; - private readonly GlobalSettings _globalSettings; - private readonly IHttpClientFactory _httpClientFactory; - - public FreshdeskController( - IUserRepository userRepository, - IOrganizationRepository organizationRepository, - IOptions billingSettings, - ILogger 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 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(); - var note = string.Empty; - note += $"
  • Region: {_billingSettings.FreshDesk.Region}
  • "; - var customFields = new Dictionary(); - var user = await _userRepository.GetByEmailAsync(ticketContactEmail); - if (user == null) - { - note += $"
  • No user found: {ticketContactEmail}
  • "; - await CreateNote(ticketId, note); - } - - if (user != null) - { - var userLink = $"{_globalSettings.BaseServiceUri.Admin}/users/edit/{user.Id}"; - note += $"
  • User, {user.Email}: {userLink}
  • "; - customFields.Add(_billingSettings.FreshDesk.UserFieldName, userLink); - var tags = new HashSet(); - 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 += $"
  • Org, {orgNote}
  • "; - 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(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 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 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 - { - { "body", $"
      {note}
    " }, - { "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 - { - { "body", $"Onyx AI:
      {note}
    " }, - { "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 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(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(onyxSimpleRequest); - return (request, onyxSimpleResponse); - } - - private async Task CallOnyxApi(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(responseStr, options: new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true, - }); - - return responseJson ?? new T(); - } - - private TAttribute? GetAttribute(Enum enumValue) where TAttribute : Attribute - { - var memberInfo = enumValue.GetType().GetMember(enumValue.ToString()).FirstOrDefault(); - return memberInfo != null ? memberInfo.GetCustomAttribute() : null; - } -} diff --git a/src/Billing/Controllers/FreshsalesController.cs b/src/Billing/Controllers/FreshsalesController.cs deleted file mode 100644 index 68382fbd5d..0000000000 --- a/src/Billing/Controllers/FreshsalesController.cs +++ /dev/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, - ILogger 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 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>( - $"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(); - - if (user.Premium) - { - newTags.Add("Premium"); - } - - var noteItems = new List - { - $"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 CreateWrapper(T lead) - { - return new LeadWrapper - { - 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 -{ - [JsonPropertyName("lead")] - public T Lead { get; set; } - - public static LeadWrapper Create(TItem lead) - { - return new LeadWrapper - { - 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; } -} diff --git a/src/Billing/Controllers/JobsController.cs b/src/Billing/Controllers/JobsController.cs new file mode 100644 index 0000000000..6a5e8e5531 --- /dev/null +++ b/src/Billing/Controllers/JobsController.cs @@ -0,0 +1,36 @@ +using Bit.Billing.Jobs; +using Bit.Core.Utilities; +using Microsoft.AspNetCore.Mvc; + +namespace Bit.Billing.Controllers; + +[Route("jobs")] +[SelfHosted(NotSelfHostedOnly = true)] +[RequireLowerEnvironment] +public class JobsController( + JobsHostedService jobsHostedService) : Controller +{ + [HttpPost("run/{jobName}")] + public async Task RunJobAsync(string jobName) + { + if (jobName == nameof(ReconcileAdditionalStorageJob)) + { + await jobsHostedService.RunJobAdHocAsync(); + return Ok(new { message = $"Job {jobName} scheduled successfully" }); + } + + return BadRequest(new { error = $"Unknown job name: {jobName}" }); + } + + [HttpPost("stop/{jobName}")] + public async Task StopJobAsync(string jobName) + { + if (jobName == nameof(ReconcileAdditionalStorageJob)) + { + await jobsHostedService.InterruptAdHocJobAsync(); + return Ok(new { message = $"Job {jobName} queued for cancellation" }); + } + + return BadRequest(new { error = $"Unknown job name: {jobName}" }); + } +} diff --git a/src/Billing/Controllers/PayPalController.cs b/src/Billing/Controllers/PayPalController.cs index 8039680fd5..70023b6bdb 100644 --- a/src/Billing/Controllers/PayPalController.cs +++ b/src/Billing/Controllers/PayPalController.cs @@ -23,7 +23,7 @@ public class PayPalController : Controller private readonly ILogger _logger; private readonly IMailService _mailService; private readonly IOrganizationRepository _organizationRepository; - private readonly IPaymentService _paymentService; + private readonly IStripePaymentService _paymentService; private readonly ITransactionRepository _transactionRepository; private readonly IUserRepository _userRepository; private readonly IProviderRepository _providerRepository; @@ -34,7 +34,7 @@ public class PayPalController : Controller ILogger logger, IMailService mailService, IOrganizationRepository organizationRepository, - IPaymentService paymentService, + IStripePaymentService paymentService, ITransactionRepository transactionRepository, IUserRepository userRepository, IProviderRepository providerRepository, diff --git a/src/Billing/Jobs/AliveJob.cs b/src/Billing/Jobs/AliveJob.cs index 42f64099ac..1769cc94e2 100644 --- a/src/Billing/Jobs/AliveJob.cs +++ b/src/Billing/Jobs/AliveJob.cs @@ -10,4 +10,13 @@ public class AliveJob(ILogger logger) : BaseJob(logger) _logger.LogInformation(Core.Constants.BypassFiltersEventId, null, "Billing service is alive!"); return Task.FromResult(0); } + + public static ITrigger GetTrigger() + { + return TriggerBuilder.Create() + .WithIdentity("EveryTopOfTheHourTrigger") + .StartNow() + .WithCronSchedule("0 0 * * * ?") + .Build(); + } } diff --git a/src/Billing/Jobs/JobsHostedService.cs b/src/Billing/Jobs/JobsHostedService.cs index a6e702c662..25c57044da 100644 --- a/src/Billing/Jobs/JobsHostedService.cs +++ b/src/Billing/Jobs/JobsHostedService.cs @@ -1,29 +1,27 @@ -using Bit.Core.Jobs; +using Bit.Core.Exceptions; +using Bit.Core.Jobs; using Bit.Core.Settings; using Quartz; namespace Bit.Billing.Jobs; -public class JobsHostedService : BaseJobsHostedService +public class JobsHostedService( + GlobalSettings globalSettings, + IServiceProvider serviceProvider, + ILogger logger, + ILogger listenerLogger, + ISchedulerFactory schedulerFactory) + : BaseJobsHostedService(globalSettings, serviceProvider, logger, listenerLogger) { - public JobsHostedService( - GlobalSettings globalSettings, - IServiceProvider serviceProvider, - ILogger logger, - ILogger listenerLogger) - : base(globalSettings, serviceProvider, logger, listenerLogger) { } + private List AdHocJobKeys { get; } = []; + private IScheduler? _adHocScheduler; public override async Task StartAsync(CancellationToken cancellationToken) { - var everyTopOfTheHourTrigger = TriggerBuilder.Create() - .WithIdentity("EveryTopOfTheHourTrigger") - .StartNow() - .WithCronSchedule("0 0 * * * ?") - .Build(); - Jobs = new List> { - new Tuple(typeof(AliveJob), everyTopOfTheHourTrigger) + new(typeof(AliveJob), AliveJob.GetTrigger()), + new(typeof(ReconcileAdditionalStorageJob), ReconcileAdditionalStorageJob.GetTrigger()) }; await base.StartAsync(cancellationToken); @@ -33,5 +31,54 @@ public class JobsHostedService : BaseJobsHostedService { services.AddTransient(); services.AddTransient(); + services.AddTransient(); + // add this service as a singleton so we can inject it where needed + services.AddSingleton(); + services.AddHostedService(sp => sp.GetRequiredService()); + } + + public async Task InterruptAdHocJobAsync(CancellationToken cancellationToken = default) where T : class, IJob + { + if (_adHocScheduler == null) + { + throw new InvalidOperationException("AdHocScheduler is null, cannot interrupt ad-hoc job."); + } + + var jobKey = AdHocJobKeys.FirstOrDefault(j => j.Name == typeof(T).ToString()); + if (jobKey == null) + { + throw new NotFoundException($"Cannot find job key: {typeof(T)}, not running?"); + } + logger.LogInformation("CANCELLING ad-hoc job with key: {JobKey}", jobKey); + AdHocJobKeys.Remove(jobKey); + await _adHocScheduler.Interrupt(jobKey, cancellationToken); + } + + public async Task RunJobAdHocAsync(CancellationToken cancellationToken = default) where T : class, IJob + { + _adHocScheduler ??= await schedulerFactory.GetScheduler(cancellationToken); + + var jobKey = new JobKey(typeof(T).ToString()); + + var currentlyExecuting = await _adHocScheduler.GetCurrentlyExecutingJobs(cancellationToken); + if (currentlyExecuting.Any(j => j.JobDetail.Key.Equals(jobKey))) + { + throw new InvalidOperationException($"Job {jobKey} is already running"); + } + + AdHocJobKeys.Add(jobKey); + + var job = JobBuilder.Create() + .WithIdentity(jobKey) + .Build(); + + var trigger = TriggerBuilder.Create() + .WithIdentity(typeof(T).ToString()) + .StartNow() + .Build(); + + logger.LogInformation("Scheduling ad-hoc job with key: {JobKey}", jobKey); + + await _adHocScheduler.ScheduleJob(job, trigger, cancellationToken); } } diff --git a/src/Billing/Jobs/ReconcileAdditionalStorageJob.cs b/src/Billing/Jobs/ReconcileAdditionalStorageJob.cs new file mode 100644 index 0000000000..7dfc44069c --- /dev/null +++ b/src/Billing/Jobs/ReconcileAdditionalStorageJob.cs @@ -0,0 +1,369 @@ +using System.Globalization; +using System.Text.Json; +using Bit.Billing.Services; +using Bit.Core; +using Bit.Core.Billing.Constants; +using Bit.Core.Jobs; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Quartz; +using Stripe; + +namespace Bit.Billing.Jobs; + +public class ReconcileAdditionalStorageJob( + IStripeFacade stripeFacade, + ILogger logger, + IFeatureService featureService, + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IStripeEventUtilityService stripeEventUtilityService) : BaseJob(logger) +{ + private const string _storageGbMonthlyPriceId = "storage-gb-monthly"; + private const string _storageGbAnnuallyPriceId = "storage-gb-annually"; + private const string _personalStorageGbAnnuallyPriceId = "personal-storage-gb-annually"; + private const int _storageGbToRemove = 4; + private const short _includedStorageGb = 5; + + public enum SubscriptionPlanTier + { + Personal, + Organization, + Unknown + } + + protected override async Task ExecuteJobAsync(IJobExecutionContext context) + { + if (!featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob)) + { + logger.LogInformation("Skipping ReconcileAdditionalStorageJob, feature flag off."); + return; + } + + var liveMode = featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode); + + // Execution tracking + var subscriptionsFound = 0; + var subscriptionsUpdated = 0; + var subscriptionsWithErrors = 0; + var databaseUpdatesFailed = 0; + var failures = new List(); + + 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, Price = priceId }; + + await foreach (var subscription in stripeFacade.ListSubscriptionsAutoPagingAsync(options)) + { + if (context.CancellationToken.IsCancellationRequested) + { + logger.LogWarning( + "Job cancelled!! Exiting. Progress at time of cancellation: Subscriptions found: {SubscriptionsFound}, " + + "Stripe updates: {StripeUpdates}, Database updates: {DatabaseFailed} failed, " + + "Errors: {SubscriptionsWithErrors}{Failures}", + subscriptionsFound, + liveMode + ? subscriptionsUpdated + : $"(In live mode, would have updated) {subscriptionsUpdated}", + databaseUpdatesFailed, + subscriptionsWithErrors, + failures.Count > 0 + ? $", Failures: {Environment.NewLine}{string.Join(Environment.NewLine, failures)}" + : string.Empty + ); + return; + } + + if (subscription == null) + { + 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++; + + if (subscription.Metadata?.TryGetValue(StripeConstants.MetadataKeys.StorageReconciled2025, out var dateString) == true) + { + if (DateTime.TryParse(dateString, null, DateTimeStyles.RoundtripKind, out var dateProcessed)) + { + logger.LogInformation("Skipping subscription {SubscriptionId} - already processed on {Date}", + subscription.Id, + dateProcessed.ToString("f")); + continue; + } + } + + var updateOptions = BuildSubscriptionUpdateOptions(subscription, priceId); + + if (updateOptions == null) + { + logger.LogInformation("Skipping subscription {SubscriptionId} - no updates needed", subscription.Id); + continue; + } + + subscriptionsUpdated++; + + // Now, prepare the database update so we can log details out if not in live mode + var (organizationId, userId, _) = stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata ?? new Dictionary()); + var subscriptionPlanTier = DetermineSubscriptionPlanTier(userId, organizationId); + + if (subscriptionPlanTier == SubscriptionPlanTier.Unknown) + { + logger.LogError( + "Cannot determine subscription plan tier for {SubscriptionId}. Skipping subscription. ", + subscription.Id); + subscriptionsWithErrors++; + continue; + } + + var entityId = + subscriptionPlanTier switch + { + SubscriptionPlanTier.Personal => userId!.Value, + SubscriptionPlanTier.Organization => organizationId!.Value, + _ => throw new ArgumentOutOfRangeException(nameof(subscriptionPlanTier), subscriptionPlanTier, null) + }; + + // Calculate new MaxStorageGb + var currentStorageQuantity = GetCurrentStorageQuantityFromSubscription(subscription, priceId); + var newMaxStorageGb = CalculateNewMaxStorageGb(currentStorageQuantity, updateOptions); + + if (!liveMode) + { + logger.LogInformation( + "Not live mode (dry-run): Would have updated subscription {SubscriptionId} with item changes: {NewLine}{UpdateOptions}" + + "{NewLine2}And would have updated database record tier: {Tier} to new MaxStorageGb: {MaxStorageGb}", + subscription.Id, + Environment.NewLine, + JsonSerializer.Serialize(updateOptions), + Environment.NewLine, + subscriptionPlanTier, + newMaxStorageGb); + continue; + } + + // Live mode enabled - continue with updates to stripe and database + try + { + await stripeFacade.UpdateSubscription(subscription.Id, updateOptions); + logger.LogInformation("Successfully updated Stripe subscription: {SubscriptionId}", subscription.Id); + + logger.LogInformation( + "Updating MaxStorageGb in database for subscription {SubscriptionId} ({Type}): New MaxStorageGb: {MaxStorage}", + subscription.Id, + subscriptionPlanTier, + newMaxStorageGb); + + var dbUpdateSuccess = await UpdateDatabaseMaxStorageAsync( + subscriptionPlanTier, + entityId, + newMaxStorageGb, + subscription.Id); + + if (!dbUpdateSuccess) + { + databaseUpdatesFailed++; + failures.Add($"Subscription {subscription.Id}: Database update failed"); + } + } + catch (Exception ex) + { + subscriptionsWithErrors++; + failures.Add($"Subscription {subscription.Id}: {ex.Message}"); + logger.LogError(ex, "Failed to update subscription {SubscriptionId}: {ErrorMessage}", + subscription.Id, ex.Message); + } + } + } + + logger.LogInformation( + "ReconcileAdditionalStorageJob FINISHED. Subscriptions found: {SubscriptionsFound}, " + + "Subscriptions updated: {SubscriptionsUpdated}, Database failures: {DatabaseFailed}, " + + "Total Subscriptions With Errors: {SubscriptionsWithErrors}{Failures}", + subscriptionsFound, + liveMode + ? subscriptionsUpdated + : $"(In live mode, would have updated) {subscriptionsUpdated}", + databaseUpdatesFailed, + subscriptionsWithErrors, + failures.Count > 0 + ? $", Failures: {Environment.NewLine}{string.Join(Environment.NewLine, failures)}" + : string.Empty + ); + } + + private SubscriptionUpdateOptions? BuildSubscriptionUpdateOptions( + Subscription subscription, + string targetPriceId) + { + if (subscription.Items?.Data == null) + { + return null; + } + + var updateOptions = new SubscriptionUpdateOptions { ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations, Metadata = new Dictionary { [StripeConstants.MetadataKeys.StorageReconciled2025] = DateTime.UtcNow.ToString("o") }, Items = [] }; + + var hasUpdates = false; + + foreach (var item in subscription.Items.Data.Where(item => item?.Price?.Id == targetPriceId)) + { + hasUpdates = true; + var currentQuantity = item.Quantity; + + if (currentQuantity > _storageGbToRemove) + { + var newQuantity = currentQuantity - _storageGbToRemove; + logger.LogInformation( + "Subscription {SubscriptionId}: reducing quantity from {CurrentQuantity} to {NewQuantity} for price {PriceId}", + subscription.Id, + currentQuantity, + newQuantity, + item.Price.Id); + + updateOptions.Items.Add(new SubscriptionItemOptions { Id = item.Id, Quantity = newQuantity }); + } + else + { + logger.LogInformation("Subscription {SubscriptionId}: deleting storage item with quantity {CurrentQuantity} for price {PriceId}", + subscription.Id, + currentQuantity, + item.Price.Id); + + updateOptions.Items.Add(new SubscriptionItemOptions { Id = item.Id, Deleted = true }); + } + } + + return hasUpdates ? updateOptions : null; + } + + public SubscriptionPlanTier DetermineSubscriptionPlanTier( + Guid? userId, + Guid? organizationId) + { + return userId.HasValue + ? SubscriptionPlanTier.Personal + : organizationId.HasValue + ? SubscriptionPlanTier.Organization + : SubscriptionPlanTier.Unknown; + } + + public long GetCurrentStorageQuantityFromSubscription( + Subscription subscription, + string storagePriceId) + { + return subscription.Items?.Data?.FirstOrDefault(item => item?.Price?.Id == storagePriceId)?.Quantity ?? 0; + } + + public short CalculateNewMaxStorageGb( + long currentQuantity, + SubscriptionUpdateOptions? updateOptions) + { + if (updateOptions?.Items == null) + { + return (short)(_includedStorageGb + currentQuantity); + } + + // If the update marks item as deleted, new quantity is whatever the base storage gb + if (updateOptions.Items.Any(i => i.Deleted == true)) + { + return _includedStorageGb; + } + + // If the update has a new quantity, use it to calculate the new max + var updatedItem = updateOptions.Items.FirstOrDefault(i => i.Quantity.HasValue); + if (updatedItem?.Quantity != null) + { + return (short)(_includedStorageGb + updatedItem.Quantity.Value); + } + + // Otherwise, no change + return (short)(_includedStorageGb + currentQuantity); + } + + public async Task UpdateDatabaseMaxStorageAsync( + SubscriptionPlanTier subscriptionPlanTier, + Guid entityId, + short newMaxStorageGb, + string subscriptionId) + { + try + { + switch (subscriptionPlanTier) + { + case SubscriptionPlanTier.Personal: + { + var user = await userRepository.GetByIdAsync(entityId); + if (user == null) + { + logger.LogError( + "User not found for subscription {SubscriptionId}. Database not updated.", + subscriptionId); + return false; + } + + user.MaxStorageGb = newMaxStorageGb; + await userRepository.ReplaceAsync(user); + + logger.LogInformation( + "Successfully updated User {UserId} MaxStorageGb to {MaxStorageGb} for subscription {SubscriptionId}", + user.Id, + newMaxStorageGb, + subscriptionId); + return true; + } + case SubscriptionPlanTier.Organization: + { + var organization = await organizationRepository.GetByIdAsync(entityId); + if (organization == null) + { + logger.LogError( + "Organization not found for subscription {SubscriptionId}. Database not updated.", + subscriptionId); + return false; + } + + organization.MaxStorageGb = newMaxStorageGb; + await organizationRepository.ReplaceAsync(organization); + + logger.LogInformation( + "Successfully updated Organization {OrganizationId} MaxStorageGb to {MaxStorageGb} for subscription {SubscriptionId}", + organization.Id, + newMaxStorageGb, + subscriptionId); + return true; + } + case SubscriptionPlanTier.Unknown: + default: + return false; + } + } + catch (Exception ex) + { + logger.LogError(ex, + "Failed to update database MaxStorageGb for subscription {SubscriptionId} (Plan Tier: {SubscriptionType})", + subscriptionId, + subscriptionPlanTier); + return false; + } + } + + public static ITrigger GetTrigger() + { + return TriggerBuilder.Create() + .WithIdentity("EveryMorningTrigger") + .StartNow() + .WithCronSchedule("0 0 16 * * ?") // 10am CST daily; the pods execute in UTC time + .Build(); + } +} diff --git a/src/Billing/Jobs/SubscriptionCancellationJob.cs b/src/Billing/Jobs/SubscriptionCancellationJob.cs index 69b7bc876d..60b671df3d 100644 --- a/src/Billing/Jobs/SubscriptionCancellationJob.cs +++ b/src/Billing/Jobs/SubscriptionCancellationJob.cs @@ -1,16 +1,17 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using Bit.Billing.Services; +using Bit.Billing.Services; +using Bit.Core.Billing.Constants; using Bit.Core.Repositories; using Quartz; using Stripe; namespace Bit.Billing.Jobs; +using static StripeConstants; + public class SubscriptionCancellationJob( IStripeFacade stripeFacade, - IOrganizationRepository organizationRepository) + IOrganizationRepository organizationRepository, + ILogger logger) : IJob { public async Task Execute(IJobExecutionContext context) @@ -21,20 +22,31 @@ public class SubscriptionCancellationJob( var organization = await organizationRepository.GetByIdAsync(organizationId); if (organization == null || organization.Enabled) { + logger.LogWarning("{Job} skipped for subscription ({SubscriptionID}) because organization is either null or enabled", nameof(SubscriptionCancellationJob), subscriptionId); // Organization was deleted or re-enabled by CS, skip cancellation return; } - var subscription = await stripeFacade.GetSubscription(subscriptionId); - if (subscription?.Status != "unpaid" || - subscription.LatestInvoice?.BillingReason is not ("subscription_cycle" or "subscription_create")) + var subscription = await stripeFacade.GetSubscription(subscriptionId, new SubscriptionGetOptions { + Expand = ["latest_invoice"] + }); + + if (subscription is not + { + Status: SubscriptionStatus.Unpaid, + LatestInvoice: { BillingReason: BillingReasons.SubscriptionCreate or BillingReasons.SubscriptionCycle } + }) + { + logger.LogWarning("{Job} skipped for subscription ({SubscriptionID}) because subscription is not unpaid or does not have a cancellable billing reason", nameof(SubscriptionCancellationJob), subscriptionId); return; } // Cancel the subscription await stripeFacade.CancelSubscription(subscriptionId, new SubscriptionCancelOptions()); + logger.LogInformation("{Job} cancelled subscription ({SubscriptionID})", nameof(SubscriptionCancellationJob), subscriptionId); + // Void any open invoices var options = new InvoiceListOptions { @@ -46,6 +58,7 @@ public class SubscriptionCancellationJob( foreach (var invoice in invoices) { await stripeFacade.VoidInvoice(invoice.Id); + logger.LogInformation("{Job} voided invoice ({InvoiceID}) for subscription ({SubscriptionID})", nameof(SubscriptionCancellationJob), invoice.Id, subscriptionId); } while (invoices.HasMore) @@ -55,6 +68,7 @@ public class SubscriptionCancellationJob( foreach (var invoice in invoices) { await stripeFacade.VoidInvoice(invoice.Id); + logger.LogInformation("{Job} voided invoice ({InvoiceID}) for subscription ({SubscriptionID})", nameof(SubscriptionCancellationJob), invoice.Id, subscriptionId); } } } diff --git a/src/Billing/Models/FreshdeskReplyRequestModel.cs b/src/Billing/Models/FreshdeskReplyRequestModel.cs deleted file mode 100644 index 3927039769..0000000000 --- a/src/Billing/Models/FreshdeskReplyRequestModel.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Bit.Billing.Models; - -public class FreshdeskReplyRequestModel -{ - [JsonPropertyName("body")] - public required string Body { get; set; } -} diff --git a/src/Billing/Models/FreshdeskWebhookModel.cs b/src/Billing/Models/FreshdeskWebhookModel.cs deleted file mode 100644 index aac0e9339d..0000000000 --- a/src/Billing/Models/FreshdeskWebhookModel.cs +++ /dev/null @@ -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; } -} diff --git a/src/Billing/Models/OnyxAnswerWithCitationRequestModel.cs b/src/Billing/Models/OnyxAnswerWithCitationRequestModel.cs deleted file mode 100644 index 9a753be4bc..0000000000 --- a/src/Billing/Models/OnyxAnswerWithCitationRequestModel.cs +++ /dev/null @@ -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; - } -} - -/// -/// This is used with the onyx endpoint /query/answer-with-citation -/// which has been deprecated. This can be removed once later -/// -public class OnyxAnswerWithCitationRequestModel : OnyxRequestModel -{ - [JsonPropertyName("messages")] - public List Messages { get; set; } = new List(); - - public OnyxAnswerWithCitationRequestModel(string message, OnyxSettings onyxSettings) : base(onyxSettings) - { - message = message.Replace(Environment.NewLine, " ").Replace('\r', ' ').Replace('\n', ' '); - Messages = new List() { new Message() { MessageText = message } }; - } -} - -/// -/// This is used with the onyx endpoint /chat/send-message-simple-api -/// -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"; -} diff --git a/src/Billing/Models/OnyxResponseModel.cs b/src/Billing/Models/OnyxResponseModel.cs deleted file mode 100644 index 96fa134c40..0000000000 --- a/src/Billing/Models/OnyxResponseModel.cs +++ /dev/null @@ -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; -} diff --git a/src/Billing/Models/PayPalIPNTransactionModel.cs b/src/Billing/Models/PayPalIPNTransactionModel.cs index 34db5fdd04..240fa5ab76 100644 --- a/src/Billing/Models/PayPalIPNTransactionModel.cs +++ b/src/Billing/Models/PayPalIPNTransactionModel.cs @@ -43,7 +43,7 @@ public class PayPalIPNTransactionModel var merchantGross = Extract(data, "mc_gross"); if (!string.IsNullOrEmpty(merchantGross)) { - MerchantGross = decimal.Parse(merchantGross); + MerchantGross = decimal.Parse(merchantGross, CultureInfo.InvariantCulture); } MerchantCurrency = Extract(data, "mc_currency"); diff --git a/src/Billing/Program.cs b/src/Billing/Program.cs index 3e005ce7fd..334dc49368 100644 --- a/src/Billing/Program.cs +++ b/src/Billing/Program.cs @@ -8,28 +8,12 @@ public class Program { Host .CreateDefaultBuilder(args) + .UseBitwardenSdk() .ConfigureWebHostDefaults(webBuilder => { webBuilder.UseStartup(); - webBuilder.ConfigureLogging((hostingContext, logging) => - logging.AddSerilog(hostingContext, (e, globalSettings) => - { - var context = e.Properties["SourceContext"].ToString(); - if (context.StartsWith("\"Bit.Billing.Jobs") || context.StartsWith("\"Bit.Core.Jobs")) - { - return e.Level >= globalSettings.MinLogLevel.BillingSettings.Jobs; - } - - if (e.Properties.TryGetValue("RequestPath", out var requestPath) && - !string.IsNullOrWhiteSpace(requestPath?.ToString()) && - (context.Contains(".Server.Kestrel") || context.Contains(".Core.IISHttpServer"))) - { - return false; - } - - return e.Level >= globalSettings.MinLogLevel.BillingSettings.Default; - })); }) + .AddSerilogFileLogging() .Build() .Run(); } diff --git a/src/Billing/Services/IStripeEventUtilityService.cs b/src/Billing/Services/IStripeEventUtilityService.cs index a5f536ad11..058f56c887 100644 --- a/src/Billing/Services/IStripeEventUtilityService.cs +++ b/src/Billing/Services/IStripeEventUtilityService.cs @@ -36,7 +36,7 @@ public interface IStripeEventUtilityService /// /// /// /// - Transaction FromChargeToTransaction(Charge charge, Guid? organizationId, Guid? userId, Guid? providerId); + Task FromChargeToTransactionAsync(Charge charge, Guid? organizationId, Guid? userId, Guid? providerId); /// /// Attempts to pay the specified invoice. If a customer is eligible, the invoice is paid using Braintree or Stripe. diff --git a/src/Billing/Services/IStripeFacade.cs b/src/Billing/Services/IStripeFacade.cs index 280a3aca3c..c7073b9cf9 100644 --- a/src/Billing/Services/IStripeFacade.cs +++ b/src/Billing/Services/IStripeFacade.cs @@ -20,6 +20,12 @@ public interface IStripeFacade RequestOptions requestOptions = null, CancellationToken cancellationToken = default); + IAsyncEnumerable GetCustomerCashBalanceTransactions( + string customerId, + CustomerCashBalanceTransactionListOptions customerCashBalanceTransactionListOptions = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default); + Task UpdateCustomer( string customerId, CustomerUpdateOptions customerUpdateOptions = null, @@ -78,6 +84,11 @@ public interface IStripeFacade RequestOptions requestOptions = null, CancellationToken cancellationToken = default); + IAsyncEnumerable ListSubscriptionsAutoPagingAsync( + SubscriptionListOptions options = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default); + Task GetSubscription( string subscriptionId, SubscriptionGetOptions subscriptionGetOptions = null, @@ -111,4 +122,10 @@ public interface IStripeFacade TestClockGetOptions testClockGetOptions = null, RequestOptions requestOptions = null, CancellationToken cancellationToken = default); + + Task GetCoupon( + string couponId, + CouponGetOptions couponGetOptions = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default); } diff --git a/src/Billing/Services/Implementations/ChargeRefundedHandler.cs b/src/Billing/Services/Implementations/ChargeRefundedHandler.cs index 905491b6c5..8cc3cb2ce6 100644 --- a/src/Billing/Services/Implementations/ChargeRefundedHandler.cs +++ b/src/Billing/Services/Implementations/ChargeRefundedHandler.cs @@ -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); diff --git a/src/Billing/Services/Implementations/ChargeSucceededHandler.cs b/src/Billing/Services/Implementations/ChargeSucceededHandler.cs index bd8ea7def2..20c4dcfa98 100644 --- a/src/Billing/Services/Implementations/ChargeSucceededHandler.cs +++ b/src/Billing/Services/Implementations/ChargeSucceededHandler.cs @@ -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); diff --git a/src/Billing/Services/Implementations/InvoiceCreatedHandler.cs b/src/Billing/Services/Implementations/InvoiceCreatedHandler.cs index 101b0e26b9..0db498844e 100644 --- a/src/Billing/Services/Implementations/InvoiceCreatedHandler.cs +++ b/src/Billing/Services/Implementations/InvoiceCreatedHandler.cs @@ -1,12 +1,13 @@ using Bit.Core.Billing.Constants; +using Bit.Core.Services; using Event = Stripe.Event; namespace Bit.Billing.Services.Implementations; public class InvoiceCreatedHandler( + IBraintreeService braintreeService, ILogger logger, IStripeEventService stripeEventService, - IStripeEventUtilityService stripeEventUtilityService, IProviderEventService providerEventService) : IInvoiceCreatedHandler { @@ -29,9 +30,9 @@ public class InvoiceCreatedHandler( { try { - var invoice = await stripeEventService.GetInvoice(parsedEvent, true, ["customer"]); + var invoice = await stripeEventService.GetInvoice(parsedEvent, true, ["customer", "parent.subscription_details.subscription"]); - var usingPayPal = invoice.Customer?.Metadata.ContainsKey("btCustomerId") ?? false; + var usingPayPal = invoice.Customer.Metadata.ContainsKey("btCustomerId"); if (usingPayPal && invoice is { @@ -39,13 +40,12 @@ public class InvoiceCreatedHandler( Status: not StripeConstants.InvoiceStatus.Paid, CollectionMethod: "charge_automatically", BillingReason: - "subscription_create" or "subscription_cycle" or "automatic_pending_invoice_item_invoice", - Parent.SubscriptionDetails: not null + Parent.SubscriptionDetails.Subscription: not null }) { - await stripeEventUtilityService.AttemptToPayInvoiceAsync(invoice); + await braintreeService.PayInvoice(invoice.Parent.SubscriptionDetails.Subscription, invoice); } } catch (Exception exception) diff --git a/src/Billing/Services/Implementations/SetupIntentSucceededHandler.cs b/src/Billing/Services/Implementations/SetupIntentSucceededHandler.cs index bc3fa1bd56..89e40f0e43 100644 --- a/src/Billing/Services/Implementations/SetupIntentSucceededHandler.cs +++ b/src/Billing/Services/Implementations/SetupIntentSucceededHandler.cs @@ -2,8 +2,8 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Caches; +using Bit.Core.Billing.Services; using Bit.Core.Repositories; -using Bit.Core.Services; using OneOf; using Stripe; using Event = Stripe.Event; @@ -59,10 +59,10 @@ public class SetupIntentSucceededHandler( return; } - await stripeAdapter.PaymentMethodAttachAsync(paymentMethod.Id, + await stripeAdapter.AttachPaymentMethodAsync(paymentMethod.Id, new PaymentMethodAttachOptions { Customer = customerId }); - await stripeAdapter.CustomerUpdateAsync(customerId, new CustomerUpdateOptions + await stripeAdapter.UpdateCustomerAsync(customerId, new CustomerUpdateOptions { InvoiceSettings = new CustomerInvoiceSettingsOptions { diff --git a/src/Billing/Services/Implementations/StripeEventUtilityService.cs b/src/Billing/Services/Implementations/StripeEventUtilityService.cs index 06a5d8a890..53512427c0 100644 --- a/src/Billing/Services/Implementations/StripeEventUtilityService.cs +++ b/src/Billing/Services/Implementations/StripeEventUtilityService.cs @@ -124,7 +124,7 @@ public class StripeEventUtilityService : IStripeEventUtilityService /// /// /// /// - public Transaction FromChargeToTransaction(Charge charge, Guid? organizationId, Guid? userId, Guid? providerId) + public async Task 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; } } + + /// + /// Retrieves the bank transfer type that funded a charge paid via customer balance. + /// + /// The charge to analyze. + /// + /// 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. + /// + private async Task 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; + } } diff --git a/src/Billing/Services/Implementations/StripeFacade.cs b/src/Billing/Services/Implementations/StripeFacade.cs index eef7ce009e..49cde981cd 100644 --- a/src/Billing/Services/Implementations/StripeFacade.cs +++ b/src/Billing/Services/Implementations/StripeFacade.cs @@ -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(); @@ -18,6 +19,7 @@ public class StripeFacade : IStripeFacade private readonly DiscountService _discountService = new(); private readonly SetupIntentService _setupIntentService = new(); private readonly TestClockService _testClockService = new(); + private readonly CouponService _couponService = new(); public async Task GetCharge( string chargeId, @@ -40,6 +42,13 @@ public class StripeFacade : IStripeFacade CancellationToken cancellationToken = default) => await _customerService.GetAsync(customerId, customerGetOptions, requestOptions, cancellationToken); + public IAsyncEnumerable GetCustomerCashBalanceTransactions( + string customerId, + CustomerCashBalanceTransactionListOptions customerCashBalanceTransactionListOptions = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) + => _customerCashBalanceTransactionService.ListAutoPagingAsync(customerId, customerCashBalanceTransactionListOptions, requestOptions, cancellationToken); + public async Task UpdateCustomer( string customerId, CustomerUpdateOptions customerUpdateOptions = null, @@ -98,6 +107,12 @@ public class StripeFacade : IStripeFacade CancellationToken cancellationToken = default) => await _subscriptionService.ListAsync(options, requestOptions, cancellationToken); + public IAsyncEnumerable ListSubscriptionsAutoPagingAsync( + SubscriptionListOptions options = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) => + _subscriptionService.ListAutoPagingAsync(options, requestOptions, cancellationToken); + public async Task GetSubscription( string subscriptionId, SubscriptionGetOptions subscriptionGetOptions = null, @@ -137,4 +152,11 @@ public class StripeFacade : IStripeFacade RequestOptions requestOptions = null, CancellationToken cancellationToken = default) => _testClockService.GetAsync(testClockId, testClockGetOptions, requestOptions, cancellationToken); + + public Task GetCoupon( + string couponId, + CouponGetOptions couponGetOptions = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) => + _couponService.GetAsync(couponId, couponGetOptions, requestOptions, cancellationToken); } diff --git a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs index 81aeb460c2..9e20bd3191 100644 --- a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs +++ b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs @@ -1,7 +1,5 @@ -using System.Globalization; -using Bit.Billing.Constants; +using Bit.Billing.Constants; using Bit.Billing.Jobs; -using Bit.Core; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; @@ -111,8 +109,7 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler break; } - if (subscription.Status is StripeSubscriptionStatus.Unpaid && - subscription.Items.Any(i => i.Price.Id is IStripeEventUtilityService.PremiumPlanId or IStripeEventUtilityService.PremiumPlanIdAppStore)) + if (await IsPremiumSubscriptionAsync(subscription)) { await CancelSubscription(subscription.Id); await VoidOpenInvoices(subscription.Id); @@ -120,6 +117,20 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler await _userService.DisablePremiumAsync(userId.Value, currentPeriodEnd); + break; + } + case StripeSubscriptionStatus.Incomplete when userId.HasValue: + { + // Handle Incomplete subscriptions for Premium users that have open invoices from failed payments + // This prevents duplicate subscriptions when users retry the subscription flow + if (await IsPremiumSubscriptionAsync(subscription) && + subscription.LatestInvoice is { Status: StripeInvoiceStatus.Open }) + { + await CancelSubscription(subscription.Id); + await VoidOpenInvoices(subscription.Id); + await _userService.DisablePremiumAsync(userId.Value, currentPeriodEnd); + } + break; } case StripeSubscriptionStatus.Active when organizationId.HasValue: @@ -134,11 +145,6 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler } case StripeSubscriptionStatus.Active when providerId.HasValue: { - var providerPortalTakeover = _featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover); - if (!providerPortalTakeover) - { - break; - } var provider = await _providerRepository.GetByIdAsync(providerId.Value); if (provider != null) { @@ -197,6 +203,13 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler } } + private async Task IsPremiumSubscriptionAsync(Subscription subscription) + { + var premiumPlans = await _pricingClient.ListPremiumPlans(); + var premiumPriceIds = premiumPlans.SelectMany(p => new[] { p.Seat.StripePriceId, p.Storage.StripePriceId }).ToHashSet(); + return subscription.Items.Any(i => premiumPriceIds.Contains(i.Price.Id)); + } + /// /// Checks if the provider subscription status has changed from a non-active to an active status type /// If the previous status is already active(active,past-due,trialing),canceled,or null, then this will return false. @@ -262,17 +275,24 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler .PreviousAttributes .ToObject() as Subscription; + // Get all plan IDs that include Secrets Manager support to check if the organization has secret manager in the + // previous and/or current subscriptions. + var planIdsOfPlansWithSecretManager = (await _pricingClient.ListPlans()) + .Where(orgPlan => orgPlan.SupportsSecretsManager && orgPlan.SecretsManager.StripeSeatPlanId != null) + .Select(orgPlan => orgPlan.SecretsManager.StripeSeatPlanId) + .ToHashSet(); + // This being false doesn't necessarily mean that the organization doesn't subscribe to Secrets Manager. // If there are changes to any subscription item, Stripe sends every item in the subscription, both // changed and unchanged. var previousSubscriptionHasSecretsManager = previousSubscription?.Items is not null && previousSubscription.Items.Any( - previousSubscriptionItem => previousSubscriptionItem.Plan.Id == plan.SecretsManager.StripeSeatPlanId); + previousSubscriptionItem => planIdsOfPlansWithSecretManager.Contains(previousSubscriptionItem.Plan.Id)); var currentSubscriptionHasSecretsManager = subscription.Items.Any( - currentSubscriptionItem => currentSubscriptionItem.Plan.Id == plan.SecretsManager.StripeSeatPlanId); + currentSubscriptionItem => planIdsOfPlansWithSecretManager.Contains(currentSubscriptionItem.Plan.Id)); if (!previousSubscriptionHasSecretsManager || currentSubscriptionHasSecretsManager) { @@ -321,13 +341,6 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler Event parsedEvent, Subscription currentSubscription) { - var providerPortalTakeover = _featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover); - - if (!providerPortalTakeover) - { - return; - } - var provider = await _providerRepository.GetByIdAsync(providerId); if (provider == null) { @@ -343,22 +356,17 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler { var previousSubscription = parsedEvent.Data.PreviousAttributes.ToObject() as Subscription; - var updateIsSubscriptionGoingUnpaid = previousSubscription is - { - Status: + if (previousSubscription is + { + Status: StripeSubscriptionStatus.Trialing or StripeSubscriptionStatus.Active or StripeSubscriptionStatus.PastDue - } && currentSubscription is - { - Status: StripeSubscriptionStatus.Unpaid, - LatestInvoice.BillingReason: "subscription_cycle" or "subscription_create" - }; - - var updateIsManualSuspensionViaMetadata = CheckForManualSuspensionViaMetadata( - previousSubscription, currentSubscription); - - if (updateIsSubscriptionGoingUnpaid || updateIsManualSuspensionViaMetadata) + } && currentSubscription is + { + Status: StripeSubscriptionStatus.Unpaid, + LatestInvoice.BillingReason: "subscription_cycle" or "subscription_create" + }) { if (currentSubscription.TestClock != null) { @@ -369,14 +377,6 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler var subscriptionUpdateOptions = new SubscriptionUpdateOptions { CancelAt = now.AddDays(7) }; - if (updateIsManualSuspensionViaMetadata) - { - subscriptionUpdateOptions.Metadata = new Dictionary - { - ["suspended_provider_via_webhook_at"] = DateTime.UtcNow.ToString(CultureInfo.InvariantCulture) - }; - } - await _stripeFacade.UpdateSubscription(currentSubscription.Id, subscriptionUpdateOptions); } } @@ -399,37 +399,4 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler } } } - - private static bool CheckForManualSuspensionViaMetadata( - Subscription? previousSubscription, - Subscription currentSubscription) - { - /* - * When metadata on a subscription is updated, we'll receive an event that has: - * Previous Metadata: { newlyAddedKey: null } - * Current Metadata: { newlyAddedKey: newlyAddedValue } - * - * As such, our check for a manual suspension must ensure that the 'previous_attributes' does contain the - * 'metadata' property, but also that the "suspend_provider" key in that metadata is set to null. - * - * If we don't do this and instead do a null coalescing check on 'previous_attributes?.metadata?.TryGetValue', - * we'll end up marking an event where 'previous_attributes.metadata' = null (which could be any subscription update - * that does not update the metadata) the same as a manual suspension. - */ - const string key = "suspend_provider"; - - if (previousSubscription is not { Metadata: not null } || - !previousSubscription.Metadata.TryGetValue(key, out var previousValue)) - { - return false; - } - - if (previousValue == null) - { - return !string.IsNullOrEmpty( - currentSubscription.Metadata.TryGetValue(key, out var currentValue) ? currentValue : null); - } - - return false; - } } diff --git a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs index 6db0cb6373..ae2a76a7ce 100644 --- a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs +++ b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs @@ -1,4 +1,5 @@ -using Bit.Core; +using System.Globalization; +using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Repositories; @@ -8,7 +9,9 @@ using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Payment.Queries; using Bit.Core.Billing.Pricing; using Bit.Core.Entities; -using Bit.Core.Models.Mail.UpdatedInvoiceIncoming; +using Bit.Core.Models.Mail.Billing.Renewal.Families2019Renewal; +using Bit.Core.Models.Mail.Billing.Renewal.Families2020Renewal; +using Bit.Core.Models.Mail.Billing.Renewal.Premium; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.Platform.Mail.Mailer; using Bit.Core.Repositories; @@ -16,6 +19,7 @@ using Bit.Core.Services; using Stripe; using Event = Stripe.Event; using Plan = Bit.Core.Models.StaticStore.Plan; +using PremiumPlan = Bit.Core.Billing.Pricing.Premium.Plan; namespace Bit.Billing.Services.Implementations; @@ -107,13 +111,22 @@ public class UpcomingInvoiceHandler( var milestone3 = featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3); - await AlignOrganizationSubscriptionConcernsAsync( + var subscriptionAligned = await AlignOrganizationSubscriptionConcernsAsync( organization, @event, subscription, plan, milestone3); + /* + * Subscription alignment sends out a different version of our Upcoming Invoice email, so we don't need to continue + * with processing. + */ + if (subscriptionAligned) + { + return; + } + // Don't send the upcoming invoice email unless the organization's on an annual plan. if (!plan.IsAnnual) { @@ -135,9 +148,7 @@ public class UpcomingInvoiceHandler( } } - await (milestone3 - ? SendUpdatedUpcomingInvoiceEmailsAsync([organization.BillingEmail]) - : SendUpcomingInvoiceEmailsAsync([organization.BillingEmail], invoice)); + await SendUpcomingInvoiceEmailsAsync([organization.BillingEmail], invoice); } private async Task AlignOrganizationTaxConcernsAsync( @@ -188,7 +199,16 @@ public class UpcomingInvoiceHandler( } } - private async Task AlignOrganizationSubscriptionConcernsAsync( + /// + /// Aligns the organization's subscription details with the specified plan and milestone requirements. + /// + /// The organization whose subscription is being updated. + /// The Stripe event associated with this operation. + /// The organization's subscription. + /// The organization's current plan. + /// A flag indicating whether the third milestone is enabled. + /// Whether the operation resulted in an updated subscription. + private async Task AlignOrganizationSubscriptionConcernsAsync( Organization organization, Event @event, Subscription subscription, @@ -198,7 +218,7 @@ public class UpcomingInvoiceHandler( // currently these are the only plans that need aligned and both require the same flag and share most of the logic if (!milestone3 || plan.Type is not (PlanType.FamiliesAnnually2019 or PlanType.FamiliesAnnually2025)) { - return; + return false; } var passwordManagerItem = @@ -208,15 +228,15 @@ public class UpcomingInvoiceHandler( { logger.LogWarning("Could not find Organization's ({OrganizationId}) password manager item while processing '{EventType}' event ({EventID})", organization.Id, @event.Type, @event.Id); - return; + return false; } - var families = await pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually); + var familiesPlan = await pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually); - organization.PlanType = families.Type; - organization.Plan = families.Name; - organization.UsersGetPremium = families.UsersGetPremium; - organization.Seats = families.PasswordManager.BaseSeats; + organization.PlanType = familiesPlan.Type; + organization.Plan = familiesPlan.Name; + organization.UsersGetPremium = familiesPlan.UsersGetPremium; + organization.Seats = familiesPlan.PasswordManager.BaseSeats; var options = new SubscriptionUpdateOptions { @@ -225,7 +245,7 @@ public class UpcomingInvoiceHandler( new SubscriptionItemOptions { Id = passwordManagerItem.Id, - Price = families.PasswordManager.StripePlanId + Price = familiesPlan.PasswordManager.StripePlanId } ], ProrationBehavior = ProrationBehavior.None @@ -266,6 +286,8 @@ public class UpcomingInvoiceHandler( { await organizationRepository.ReplaceAsync(organization); await stripeFacade.UpdateSubscription(subscription.Id, options); + await SendFamiliesRenewalEmailAsync(organization, familiesPlan, plan); + return true; } catch (Exception exception) { @@ -275,6 +297,7 @@ public class UpcomingInvoiceHandler( organization.Id, @event.Type, @event.Id); + return false; } } @@ -303,14 +326,21 @@ public class UpcomingInvoiceHandler( var milestone2Feature = featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2); if (milestone2Feature) { - await AlignPremiumUsersSubscriptionConcernsAsync(user, @event, subscription); + var subscriptionAligned = await AlignPremiumUsersSubscriptionConcernsAsync(user, @event, subscription); + + /* + * Subscription alignment sends out a different version of our Upcoming Invoice email, so we don't need to continue + * with processing. + */ + if (subscriptionAligned) + { + return; + } } if (user.Premium) { - await (milestone2Feature - ? SendUpdatedUpcomingInvoiceEmailsAsync(new List { user.Email }) - : SendUpcomingInvoiceEmailsAsync(new List { user.Email }, invoice)); + await SendUpcomingInvoiceEmailsAsync(new List { user.Email }, invoice); } } @@ -341,7 +371,7 @@ public class UpcomingInvoiceHandler( } } - private async Task AlignPremiumUsersSubscriptionConcernsAsync( + private async Task AlignPremiumUsersSubscriptionConcernsAsync( User user, Event @event, Subscription subscription) @@ -352,7 +382,7 @@ public class UpcomingInvoiceHandler( { logger.LogWarning("Could not find User's ({UserID}) premium subscription item while processing '{EventType}' event ({EventID})", user.Id, @event.Type, @event.Id); - return; + return false; } try @@ -371,6 +401,8 @@ public class UpcomingInvoiceHandler( ], ProrationBehavior = ProrationBehavior.None }); + await SendPremiumRenewalEmailAsync(user, plan); + return true; } catch (Exception exception) { @@ -379,6 +411,7 @@ public class UpcomingInvoiceHandler( "Failed to update user's ({UserID}) subscription price id while processing event with ID {EventID}", user.Id, @event.Id); + return false; } } @@ -513,15 +546,92 @@ public class UpcomingInvoiceHandler( } } - private async Task SendUpdatedUpcomingInvoiceEmailsAsync(IEnumerable emails) + private async Task SendFamiliesRenewalEmailAsync( + Organization organization, + Plan familiesPlan, + Plan planBeforeAlignment) { - var validEmails = emails.Where(e => !string.IsNullOrEmpty(e)); - var updatedUpcomingEmail = new UpdatedInvoiceUpcomingMail + await (planBeforeAlignment switch { - ToEmails = validEmails, - View = new UpdatedInvoiceUpcomingView() + { Type: PlanType.FamiliesAnnually2025 } => SendFamilies2020RenewalEmailAsync(organization, familiesPlan), + { Type: PlanType.FamiliesAnnually2019 } => SendFamilies2019RenewalEmailAsync(organization, familiesPlan), + _ => throw new InvalidOperationException("Unsupported families plan in SendFamiliesRenewalEmailAsync().") + }); + } + + private async Task SendFamilies2020RenewalEmailAsync(Organization organization, Plan familiesPlan) + { + var email = new Families2020RenewalMail + { + ToEmails = [organization.BillingEmail], + View = new Families2020RenewalMailView + { + MonthlyRenewalPrice = (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US")) + } }; - await mailer.SendEmail(updatedUpcomingEmail); + + await mailer.SendEmail(email); + } + + private async Task SendFamilies2019RenewalEmailAsync(Organization organization, Plan familiesPlan) + { + var coupon = await stripeFacade.GetCoupon(CouponIDs.Milestone3SubscriptionDiscount); + if (coupon == null) + { + throw new InvalidOperationException($"Coupon for sending families 2019 email id:{CouponIDs.Milestone3SubscriptionDiscount} not found"); + } + + if (coupon.PercentOff == null) + { + throw new InvalidOperationException($"coupon.PercentOff for sending families 2019 email id:{CouponIDs.Milestone3SubscriptionDiscount} is null"); + } + + var discountedAnnualRenewalPrice = familiesPlan.PasswordManager.BasePrice * (100 - coupon.PercentOff.Value) / 100; + + var email = new Families2019RenewalMail + { + ToEmails = [organization.BillingEmail], + View = new Families2019RenewalMailView + { + BaseMonthlyRenewalPrice = (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US")), + BaseAnnualRenewalPrice = familiesPlan.PasswordManager.BasePrice.ToString("C", new CultureInfo("en-US")), + DiscountAmount = $"{coupon.PercentOff}%", + DiscountedAnnualRenewalPrice = discountedAnnualRenewalPrice.ToString("C", new CultureInfo("en-US")) + } + }; + + await mailer.SendEmail(email); + } + + private async Task SendPremiumRenewalEmailAsync( + User user, + PremiumPlan premiumPlan) + { + var coupon = await stripeFacade.GetCoupon(CouponIDs.Milestone2SubscriptionDiscount); + if (coupon == null) + { + throw new InvalidOperationException($"Coupon for sending premium renewal email id:{CouponIDs.Milestone2SubscriptionDiscount} not found"); + } + + if (coupon.PercentOff == null) + { + throw new InvalidOperationException($"coupon.PercentOff for sending premium renewal email id:{CouponIDs.Milestone2SubscriptionDiscount} is null"); + } + + var discountedAnnualRenewalPrice = premiumPlan.Seat.Price * (100 - coupon.PercentOff.Value) / 100; + + var email = new PremiumRenewalMail + { + ToEmails = [user.Email], + View = new PremiumRenewalMailView + { + BaseMonthlyRenewalPrice = (premiumPlan.Seat.Price / 12).ToString("C", new CultureInfo("en-US")), + DiscountAmount = $"{coupon.PercentOff}%", + DiscountedAnnualRenewalPrice = discountedAnnualRenewalPrice.ToString("C", new CultureInfo("en-US")) + } + }; + + await mailer.SendEmail(email); } #endregion diff --git a/src/Billing/Startup.cs b/src/Billing/Startup.cs index cdb9700ad5..f5f98bfd53 100644 --- a/src/Billing/Startup.cs +++ b/src/Billing/Startup.cs @@ -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; @@ -10,7 +9,6 @@ using Bit.Core.Billing.Extensions; using Bit.Core.Context; using Bit.Core.SecretsManager.Repositories; using Bit.Core.SecretsManager.Repositories.Noop; -using Bit.Core.Settings; using Bit.Core.Utilities; using Bit.SharedWeb.Utilities; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -50,6 +48,7 @@ public class Startup // Repositories services.AddDatabaseRepositories(globalSettings); + services.AddTestPlayIdTracking(globalSettings); // PayPal IPN Client services.AddHttpClient(); @@ -99,13 +98,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(); services.AddScoped(); services.AddScoped(); @@ -129,12 +121,8 @@ public class Startup public void Configure( IApplicationBuilder app, - IWebHostEnvironment env, - IHostApplicationLifetime appLifetime, - GlobalSettings globalSettings) + IWebHostEnvironment env) { - app.UseSerilog(env, appLifetime, globalSettings); - // Add general security headers app.UseMiddleware(); diff --git a/src/Billing/appsettings.Development.json b/src/Billing/appsettings.Development.json index fe8e47b2f6..77057fde7f 100644 --- a/src/Billing/appsettings.Development.json +++ b/src/Billing/appsettings.Development.json @@ -32,10 +32,5 @@ "connectionString": "UseDevelopmentStorage=true" } }, - "billingSettings": { - "onyx": { - "personaId": 68 - } - }, "pricingUri": "https://billingpricing.qa.bitwarden.pw" } diff --git a/src/Billing/appsettings.Production.json b/src/Billing/appsettings.Production.json index 4be5d51a52..819986181f 100644 --- a/src/Billing/appsettings.Production.json +++ b/src/Billing/appsettings.Production.json @@ -26,10 +26,7 @@ "payPal": { "production": true, "businessId": "4ZDA7DLUUJGMN" - }, - "onyx": { - "personaId": 7 - } + } }, "Logging": { "IncludeScopes": false, diff --git a/src/Billing/appsettings.json b/src/Billing/appsettings.json index a2d6acd0a1..7093b6a923 100644 --- a/src/Billing/appsettings.json +++ b/src/Billing/appsettings.json @@ -30,9 +30,6 @@ "connectionString": "SECRET", "applicationCacheTopicName": "SECRET" }, - "sentry": { - "dsn": "SECRET" - }, "notificationHub": { "connectionString": "SECRET", "hubName": "SECRET" @@ -64,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": "Greetings,

    Thank you for contacting Bitwarden. The reply below was generated by our AI agent based on your message:

    ", - "autoReplySalutation": "

    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.

    Best Regards,
    The Bitwarden Customer Success Team

    " - }, - "onyx": { - "apiKey": "SECRET", - "baseUrl": "https://cloud.onyx.app/api", - "path": "/chat/send-message-simple-api", - "useAnswerWithCitationModels": true, - "personaId": 7, - "searchSettings": { - "runSearch": "always", - "realTime": true - } } } } diff --git a/src/Core/AdminConsole/Entities/Organization.cs b/src/Core/AdminConsole/Entities/Organization.cs index 73aa162f22..266779d574 100644 --- a/src/Core/AdminConsole/Entities/Organization.cs +++ b/src/Core/AdminConsole/Entities/Organization.cs @@ -134,6 +134,16 @@ public class Organization : ITableObject, IStorableSubscriber, IRevisable ///
    public bool UseAutomaticUserConfirmation { get; set; } + /// + /// If set to true, disables Secrets Manager ads for users in the organization + /// + public bool UseDisableSmAdsForUsers { get; set; } + + /// + /// If set to true, the organization has phishing protection enabled. + /// + public bool UsePhishingBlocker { get; set; } + public void SetNewId() { if (Id == default(Guid)) @@ -333,6 +343,8 @@ public class Organization : ITableObject, IStorableSubscriber, IRevisable UseRiskInsights = license.UseRiskInsights; UseOrganizationDomains = license.UseOrganizationDomains; UseAdminSponsoredFamilies = license.UseAdminSponsoredFamilies; + UseDisableSmAdsForUsers = license.UseDisableSmAdsForUsers; UseAutomaticUserConfirmation = license.UseAutomaticUserConfirmation; + UsePhishingBlocker = license.UsePhishingBlocker; } } diff --git a/src/Core/AdminConsole/Enums/PolicyType.cs b/src/Core/AdminConsole/Enums/PolicyType.cs index 09fa4ec955..bd6daf7cdf 100644 --- a/src/Core/AdminConsole/Enums/PolicyType.cs +++ b/src/Core/AdminConsole/Enums/PolicyType.cs @@ -21,6 +21,7 @@ public enum PolicyType : byte UriMatchDefaults = 16, AutotypeDefaultSetting = 17, AutomaticUserConfirmation = 18, + BlockClaimedDomainAccountCreation = 19, } public static class PolicyTypeExtensions @@ -52,6 +53,7 @@ public static class PolicyTypeExtensions PolicyType.UriMatchDefaults => "URI match defaults", PolicyType.AutotypeDefaultSetting => "Autotype default setting", PolicyType.AutomaticUserConfirmation => "Automatically confirm invited users", + PolicyType.BlockClaimedDomainAccountCreation => "Block account creation for claimed domains", }; } } diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/DatadogIntegration.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/DatadogIntegration.cs deleted file mode 100644 index 8785a74896..0000000000 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/DatadogIntegration.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; - -public record DatadogIntegration(string ApiKey, Uri Uri); diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationHandlerResult.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationHandlerResult.cs deleted file mode 100644 index 8db054561b..0000000000 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationHandlerResult.cs +++ /dev/null @@ -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; -} diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackIntegration.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackIntegration.cs deleted file mode 100644 index dc2733c889..0000000000 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackIntegration.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; - -public record SlackIntegration(string Token); diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackIntegrationConfiguration.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackIntegrationConfiguration.cs deleted file mode 100644 index 5b4fae0c76..0000000000 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackIntegrationConfiguration.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; - -public record SlackIntegrationConfiguration(string ChannelId); diff --git a/src/Core/AdminConsole/Models/Data/IProfileOrganizationDetails.cs b/src/Core/AdminConsole/Models/Data/IProfileOrganizationDetails.cs index 820b65dbfd..ed1d9e5f13 100644 --- a/src/Core/AdminConsole/Models/Data/IProfileOrganizationDetails.cs +++ b/src/Core/AdminConsole/Models/Data/IProfileOrganizationDetails.cs @@ -53,4 +53,7 @@ public interface IProfileOrganizationDetails bool UseAdminSponsoredFamilies { get; set; } bool UseOrganizationDomains { get; set; } bool UseAutomaticUserConfirmation { get; set; } + bool UseDisableSMAdsForUsers { get; set; } + + bool UsePhishingBlocker { get; set; } } diff --git a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserOrganizationDetails.cs b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserOrganizationDetails.cs index 8d30bfc250..2a7c2cb628 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserOrganizationDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserOrganizationDetails.cs @@ -65,4 +65,6 @@ public class OrganizationUserOrganizationDetails : IProfileOrganizationDetails public bool UseAdminSponsoredFamilies { get; set; } public bool? IsAdminInitiated { get; set; } public bool UseAutomaticUserConfirmation { get; set; } + public bool UseDisableSMAdsForUsers { get; set; } + public bool UsePhishingBlocker { get; set; } } diff --git a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserUserDetails.cs b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserUserDetails.cs index 6d182e197f..00ba706a41 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserUserDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserUserDetails.cs @@ -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; } + /// + /// 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). + /// public bool? Premium { get; set; } public OrganizationUserStatusType Status { get; set; } public OrganizationUserType Type { get; set; } @@ -63,11 +69,6 @@ public class OrganizationUserUserDetails : IExternal, ITwoFactorProvidersUser, I return UserId; } - public bool GetPremium() - { - return Premium.GetValueOrDefault(false); - } - public Permissions GetPermissions() { return string.IsNullOrWhiteSpace(Permissions) ? null diff --git a/src/Core/AdminConsole/Models/Data/Organizations/SelfHostedOrganizationDetails.cs b/src/Core/AdminConsole/Models/Data/Organizations/SelfHostedOrganizationDetails.cs index 84ff164943..5ec9dc255a 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/SelfHostedOrganizationDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/SelfHostedOrganizationDetails.cs @@ -128,6 +128,7 @@ public class SelfHostedOrganizationDetails : Organization UseApi = UseApi, UseResetPassword = UseResetPassword, UseSecretsManager = UseSecretsManager, + UsePasswordManager = UsePasswordManager, SelfHost = SelfHost, UsersGetPremium = UsersGetPremium, UseCustomPermissions = UseCustomPermissions, @@ -154,6 +155,10 @@ public class SelfHostedOrganizationDetails : Organization Status = Status, UseRiskInsights = UseRiskInsights, UseAdminSponsoredFamilies = UseAdminSponsoredFamilies, + UseDisableSmAdsForUsers = UseDisableSmAdsForUsers, + UsePhishingBlocker = UsePhishingBlocker, + UseOrganizationDomains = UseOrganizationDomains, + UseAutomaticUserConfirmation = UseAutomaticUserConfirmation, }; } } diff --git a/src/Core/AdminConsole/Models/Data/Provider/ProviderUserOrganizationDetails.cs b/src/Core/AdminConsole/Models/Data/Provider/ProviderUserOrganizationDetails.cs index 0d48f5cfa9..69b17a9a80 100644 --- a/src/Core/AdminConsole/Models/Data/Provider/ProviderUserOrganizationDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Provider/ProviderUserOrganizationDetails.cs @@ -56,4 +56,6 @@ public class ProviderUserOrganizationDetails : IProfileOrganizationDetails public string? SsoExternalId { get; set; } public string? Permissions { get; set; } public string? ResetPasswordKey { get; set; } + public bool UseDisableSMAdsForUsers { get; set; } + public bool UsePhishingBlocker { get; set; } } diff --git a/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationBaseView.cs b/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationBaseView.cs new file mode 100644 index 0000000000..f888496fe8 --- /dev/null +++ b/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationBaseView.cs @@ -0,0 +1,12 @@ +using Bit.Core.Platform.Mail.Mailer; + +namespace Bit.Core.AdminConsole.Models.Mail.Mailer.OrganizationConfirmation; + +public abstract class OrganizationConfirmationBaseView : BaseMailView +{ + public required string OrganizationName { get; set; } + public required string TitleFirst { get; set; } + public required string TitleSecondBold { get; set; } + public required string TitleThird { get; set; } + public required string WebVaultUrl { get; set; } +} diff --git a/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationEnterpriseTeamsView.cs b/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationEnterpriseTeamsView.cs new file mode 100644 index 0000000000..6a8a0e18fa --- /dev/null +++ b/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationEnterpriseTeamsView.cs @@ -0,0 +1,12 @@ +using Bit.Core.Platform.Mail.Mailer; + +namespace Bit.Core.AdminConsole.Models.Mail.Mailer.OrganizationConfirmation; + +public class OrganizationConfirmationEnterpriseTeamsView : OrganizationConfirmationBaseView +{ +} + +public class OrganizationConfirmationEnterpriseTeams : BaseMail +{ + public override required string Subject { get; set; } +} diff --git a/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationEnterpriseTeamsView.html.hbs b/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationEnterpriseTeamsView.html.hbs new file mode 100644 index 0000000000..3c8f498403 --- /dev/null +++ b/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationEnterpriseTeamsView.html.hbs @@ -0,0 +1,814 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    + + + + + + + +
    + + + + + + + + +
    + + + + + +
    + + + + + + + +
    + + +
    + + + + + + + + + + + + + + + + + +
    + + + + + + + +
    + + + +
    + +
    + +

    + You can now share passwords with members of {{OrganizationName}}! +

    + +
    + + + + + + + +
    + + Log in + +
    + +
    + +
    + + + +
    + + + + + + + + + +
    + + + + + + + +
    + + + +
    + +
    + +
    + + +
    + +
    + + + + + +
    + + +
    + +
    + + + + + + + + + +
    + + + + + + + +
    + + + +
    + + + + + + + +
    + + +
    + + + + + + + + + +
    + +
    As a member of {{OrganizationName}}:
    + +
    + +
    + + +
    + +
    + + + + + +
    + + + + + + + +
    + + +
    + + +
    + + + + + + + + + +
    + + + + + + + +
    + + Organization Icon + +
    + +
    + +
    + + + +
    + + + + + + + + + +
    + +
    Your account is owned by {{OrganizationName}} and is subject to their security and management policies.
    + +
    + +
    + + +
    + + +
    + +
    + + + + + +
    + + + + + + + +
    + + +
    + + +
    + + + + + + + + + +
    + + + + + + + +
    + + Group Users Icon + +
    + +
    + +
    + + + +
    + + + + + + + + + + + + + +
    + +
    You can easily access and share passwords with your team.
    + +
    + + + +
    + +
    + + +
    + + +
    + +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + +
    + +
    + + + + + + + + + +
    + + + + + + + +
    + + + +
    + + + + + + + +
    + + +
    + + + + + + + + + +
    + +

    + Learn more about Bitwarden +

    + Find user guides, product documentation, and videos on the + Bitwarden Help Center.
    + +
    + +
    + + + +
    + + + + + + + + + +
    + +
    + + +
    + +
    + + + +
    + +
    + + + + + + + + + +
    + + + + + + + +
    + + +
    + + + + + + + + + + + + + +
    + + + + + + + + + + + + +
    + + + + + + +
    + + + +
    +
    + + + + + + + + + + +
    + + + + + + +
    + + + +
    +
    + + + + + + + + + + +
    + + + + + + +
    + + + +
    +
    + + + + + + + + + + +
    + + + + + + +
    + + + +
    +
    + + + + + + + + + + +
    + + + + + + +
    + + + +
    +
    + + + + + + + + + + +
    + + + + + + +
    + + + +
    +
    + + + + + + + + + + +
    + + + + + + +
    + + + +
    +
    + + + +
    + +

    + © 2025 Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa + Barbara, CA, USA +

    +

    + Always confirm you are on a trusted Bitwarden domain before logging + in:
    + bitwarden.com | + Learn why we include this +

    + +
    + +
    + + +
    + +
    + + + + + +
    + + + diff --git a/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationEnterpriseTeamsView.text.hbs b/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationEnterpriseTeamsView.text.hbs new file mode 100644 index 0000000000..38c45f2dd1 --- /dev/null +++ b/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationEnterpriseTeamsView.text.hbs @@ -0,0 +1,4 @@ +{{#>TitleContactUsTextLayout}} + You may now access logins and other items {{OrganizationName}} has shared with you from your Bitwarden vault. + Tip: Use the Bitwarden mobile app to quickly save logins and auto-fill forms. Download from the App Store or Google Play. +{{/TitleContactUsTextLayout}} diff --git a/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationFamilyFreeView.cs b/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationFamilyFreeView.cs new file mode 100644 index 0000000000..9228ec2208 --- /dev/null +++ b/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationFamilyFreeView.cs @@ -0,0 +1,12 @@ +using Bit.Core.Platform.Mail.Mailer; + +namespace Bit.Core.AdminConsole.Models.Mail.Mailer.OrganizationConfirmation; + +public class OrganizationConfirmationFamilyFreeView : OrganizationConfirmationBaseView +{ +} + +public class OrganizationConfirmationFamilyFree : BaseMail +{ + public override required string Subject { get; set; } +} diff --git a/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationFamilyFreeView.html.hbs b/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationFamilyFreeView.html.hbs new file mode 100644 index 0000000000..c0f838e0c7 --- /dev/null +++ b/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationFamilyFreeView.html.hbs @@ -0,0 +1,983 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    + + + + + + + +
    + + + + + + + + +
    + + + + + +
    + + + + + + + +
    + + +
    + + + + + + + + + + + + + + + + + +
    + + + + + + + +
    + + + +
    + +
    + +

    + You can now share passwords with members of {{OrganizationName}}! +

    + +
    + + + + + + + +
    + + Log in + +
    + +
    + +
    + + + +
    + + + + + + + + + +
    + + + + + + + +
    + + + +
    + +
    + +
    + + +
    + +
    + + + + + +
    + + +
    + +
    + + + + + + + + + +
    + + + + + + + +
    + + + +
    + + + + + + + +
    + + +
    + + + + + + + + + +
    + +
    As a member of {{OrganizationName}}:
    + +
    + +
    + + +
    + +
    + + + + + +
    + + + + + + + +
    + + +
    + + +
    + + + + + + + + + +
    + + + + + + + +
    + + Collections Icon + +
    + +
    + +
    + + + +
    + + + + + + + + + +
    + +
    You can access passwords {{OrganizationName}} has shared with you.
    + +
    + +
    + + +
    + + +
    + +
    + + + + + +
    + + + + + + + +
    + + +
    + + +
    + + + + + + + + + +
    + + + + + + + +
    + + Group Users Icon + +
    + +
    + +
    + + + +
    + + + + + + + + + + + + + +
    + +
    You can easily share passwords with friends, family, or coworkers.
    + +
    + + + +
    + +
    + + +
    + + +
    + +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + +
    + +
    + + + + + + + + + +
    + + + + + + + +
    + + + +
    + + + + + + + +
    + + +
    + + + + + + + + + + + + + +
    + +
    Download Bitwarden on all devices
    + +
    + +
    Already using the browser extension? + Download the Bitwarden mobile app from the + App Store + or Google Play + to quickly save logins and autofill forms on the go.
    + +
    + +
    + + +
    + +
    + + + + + +
    + + + + + + + +
    + + +
    + + +
    + + + + + + + + + +
    + + + + + + + +
    + + + + Download on the App Store + + + +
    + +
    + +
    + + + +
    + + + + + + + + + +
    + + + + + + + +
    + + + + Get it on Google Play + + + +
    + +
    + +
    + + +
    + + +
    + +
    + + + +
    + +
    + + + + + + + + + +
    + + + + + + + +
    + + + +
    + + + + + + + +
    + + +
    + + + + + + + + + +
    + +

    + Learn more about Bitwarden +

    + Find user guides, product documentation, and videos on the + Bitwarden Help Center.
    + +
    + +
    + + + +
    + + + + + + + + + +
    + +
    + + +
    + +
    + + + +
    + +
    + + + + + + + + + +
    + + + + + + + +
    + + +
    + + + + + + + + + + + + + +
    + + + + + + + + + + + + +
    + + + + + + +
    + + + +
    +
    + + + + + + + + + + +
    + + + + + + +
    + + + +
    +
    + + + + + + + + + + +
    + + + + + + +
    + + + +
    +
    + + + + + + + + + + +
    + + + + + + +
    + + + +
    +
    + + + + + + + + + + +
    + + + + + + +
    + + + +
    +
    + + + + + + + + + + +
    + + + + + + +
    + + + +
    +
    + + + + + + + + + + +
    + + + + + + +
    + + + +
    +
    + + + +
    + +

    + © 2025 Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa + Barbara, CA, USA +

    +

    + Always confirm you are on a trusted Bitwarden domain before logging + in:
    + bitwarden.com | + Learn why we include this +

    + +
    + +
    + + +
    + +
    + + + + + +
    + + + + diff --git a/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationFamilyFreeView.text.hbs b/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationFamilyFreeView.text.hbs new file mode 100644 index 0000000000..38c45f2dd1 --- /dev/null +++ b/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationFamilyFreeView.text.hbs @@ -0,0 +1,4 @@ +{{#>TitleContactUsTextLayout}} + You may now access logins and other items {{OrganizationName}} has shared with you from your Bitwarden vault. + Tip: Use the Bitwarden mobile app to quickly save logins and auto-fill forms. Download from the App Store or Google Play. +{{/TitleContactUsTextLayout}} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Collections/CollectionUtils.cs b/src/Core/AdminConsole/OrganizationFeatures/Collections/CollectionUtils.cs new file mode 100644 index 0000000000..116992146f --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Collections/CollectionUtils.cs @@ -0,0 +1,53 @@ +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Utilities; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Collections; + +public static class CollectionUtils +{ + /// + /// Arranges Collection and CollectionUser objects to create default user collections. + /// + /// The organization ID. + /// The IDs for organization users who need default collections. + /// The encrypted string to use as the default collection name. + /// A tuple containing the collections and collection users. + public static (ICollection collections, ICollection collectionUsers) + BuildDefaultUserCollections(Guid organizationId, IEnumerable organizationUserIds, + string defaultCollectionName) + { + var now = DateTime.UtcNow; + + var collectionUsers = new List(); + var collections = new List(); + + foreach (var orgUserId in organizationUserIds) + { + var collectionId = CoreHelpers.GenerateComb(); + + collections.Add(new Collection + { + Id = collectionId, + OrganizationId = organizationId, + Name = defaultCollectionName, + CreationDate = now, + RevisionDate = now, + Type = CollectionType.DefaultUserCollection, + DefaultUserCollectionEmail = null + + }); + + collectionUsers.Add(new CollectionUser + { + CollectionId = collectionId, + OrganizationUserId = orgUserId, + ReadOnly = false, + HidePasswords = false, + Manage = true, + }); + } + + return (collections, collectionUsers); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Import/ImportOrganizationUsersAndGroupsCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Import/ImportOrganizationUsersAndGroupsCommand.cs index a78dd95260..b9bad6a346 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Import/ImportOrganizationUsersAndGroupsCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Import/ImportOrganizationUsersAndGroupsCommand.cs @@ -2,6 +2,7 @@ using Bit.Core.AdminConsole.Models.Business; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Services; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -18,7 +19,7 @@ public class ImportOrganizationUsersAndGroupsCommand : IImportOrganizationUsersA { private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationUserRepository _organizationUserRepository; - private readonly IPaymentService _paymentService; + private readonly IStripePaymentService _paymentService; private readonly IGroupRepository _groupRepository; private readonly IEventService _eventService; private readonly IOrganizationService _organizationService; @@ -27,7 +28,7 @@ public class ImportOrganizationUsersAndGroupsCommand : IImportOrganizationUsersA public ImportOrganizationUsersAndGroupsCommand(IOrganizationRepository organizationRepository, IOrganizationUserRepository organizationUserRepository, - IPaymentService paymentService, + IStripePaymentService paymentService, IGroupRepository groupRepository, IEventService eventService, IOrganizationService organizationService) diff --git a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationAbility.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationAbility/OrganizationAbility.cs similarity index 91% rename from src/Core/AdminConsole/Models/Data/Organizations/OrganizationAbility.cs rename to src/Core/AdminConsole/OrganizationFeatures/OrganizationAbility/OrganizationAbility.cs index 3c02a4f50b..40249fa2be 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationAbility.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationAbility/OrganizationAbility.cs @@ -29,6 +29,8 @@ public class OrganizationAbility UseOrganizationDomains = organization.UseOrganizationDomains; UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies; UseAutomaticUserConfirmation = organization.UseAutomaticUserConfirmation; + UseDisableSmAdsForUsers = organization.UseDisableSmAdsForUsers; + UsePhishingBlocker = organization.UsePhishingBlocker; } public Guid Id { get; set; } @@ -51,4 +53,6 @@ public class OrganizationAbility public bool UseOrganizationDomains { get; set; } public bool UseAdminSponsoredFamilies { get; set; } public bool UseAutomaticUserConfirmation { get; set; } + public bool UseDisableSmAdsForUsers { get; set; } + public bool UsePhishingBlocker { get; set; } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationAbility/README.md b/src/Core/AdminConsole/OrganizationFeatures/OrganizationAbility/README.md new file mode 100644 index 0000000000..7b92ba3fef --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationAbility/README.md @@ -0,0 +1,141 @@ +# Organization Ability Flags + +## Overview + +Many Bitwarden features are tied to specific subscription plans. For example, SCIM and SSO are Enterprise features, +while Event Logs are available to Teams and Enterprise plans. When developing features that require plan-based access +control, we use **Organization Ability Flags** (or simply _abilities_) — explicit boolean properties on the Organization +entity that indicate whether an organization can use a specific feature. + +## The Rule + +**Never check plan types to control feature access.** Always use a dedicated ability flag on the Organization entity. + +### ❌ Don't Do This + +```csharp +// Checking plan type directly +if (organization.PlanType == PlanType.Enterprise || + organization.PlanType == PlanType.Teams || + organization.PlanType == PlanType.Family) +{ + // allow feature... +} +``` + +### ❌ Don't Do This + +```csharp +// Piggybacking off another feature's ability +if (organization.PlanType == PlanType.Enterprise && organization.UseEvents) +{ + // assume they can use some other feature... +} +``` + +### ✅ Do This Instead + +```csharp +// Check the explicit ability flag +if (organization.UseEvents) +{ + // allow UseEvents feature... +} +``` + +## Why This Pattern Matters + +Using explicit ability flags instead of plan type checks provides several benefits: + +1. **Simplicity** — A single boolean check is cleaner and less error-prone than maintaining lists of plan types. + +2. **Centralized Control** — Feature access is managed in one place: the ability assignment during organization + creation/upgrade. No need to hunt through the codebase for scattered plan type checks. + +3. **Flexibility** — Abilities can be set independently of plan type, enabling: + + - Early access programs for features not yet tied to a plan + - Trial access to help customers evaluate a feature before upgrading + - Custom arrangements for specific customers + - A/B testing of features across different cohorts + +4. **Safe Refactoring** — When plans change (e.g., adding a new plan tier, renaming plans, or moving features between + tiers), we only update the ability assignment logic—not every place the feature is used. + +5. **Graceful Downgrades** — When an organization downgrades, we update their abilities. All feature checks + automatically respect the new access level. + +## How It Works + +### Ability Assignment at Signup/Upgrade + +When an organization is created or changes plans, the ability flags are set based on the plan's capabilities: + +```csharp +// During organization creation or plan change +organization.UseGroups = plan.HasGroups; +organization.UseSso = plan.HasSso; +organization.UseScim = plan.HasScim; +organization.UsePolicies = plan.HasPolicies; +organization.UseEvents = plan.HasEvents; +// ... etc +``` + +### Modifying Abilities for Existing Organizations + +To change abilities for existing organizations (e.g., rolling out a feature to a new plan tier), create a database +migration that updates the relevant flag: + +```sql +-- Example: Enable UseEvents for all Teams organizations +UPDATE [dbo].[Organization] +SET UseEvents = 1 +WHERE PlanType IN (17, 18) -- TeamsMonthly = 17, TeamsAnnually = 18 +``` + +Then update the plan-to-ability assignment code so new organizations get the correct value. + +## Adding a New Ability + +When developing a new plan-gated feature: + +1. **Add the ability to the Organization and OrganizationAbility entities** — Create a `Use[FeatureName]` boolean + property. + +2. **Add a database migration** — Add the new column to the Organization table. + +3. **Update plan definitions** — Add a corresponding `Has[FeatureName]` property to the Plan model and configure which + plans include it. + +4. **Update organization creation/upgrade logic** — Ensure the ability is set based on the plan. + +5. **Update the organization license claims** (if applicable) - to make the feature available on self-hosted instances. + +6. **Implement checks throughout client and server** — Use the ability consistently everywhere the feature is accessed. + - Clients: get the organization object from `OrganizationService`. + - Server: if you already have the full `Organization` object in scope, you can use it directly. If not, use the + `IApplicationCacheService` to retrieve the `OrganizationAbility`, which is a simplified, cached representation + of the organization ability flags. Note that some older flags may be missing from `OrganizationAbility` but + can be added if needed. + +## Existing Abilities + +For reference, here are some current organization ability flags (not a complete list): + +| Ability | Description | Plans | +|--------------------------|-------------------------------|-------------------| +| `UseGroups` | Group-based collection access | Teams, Enterprise | +| `UseDirectory` | Directory Connector sync | Teams, Enterprise | +| `UseEvents` | Event logging | Teams, Enterprise | +| `UseTotp` | Authenticator (TOTP) | Teams, Enterprise | +| `UseSso` | Single Sign-On | Enterprise | +| `UseScim` | SCIM provisioning | Teams, Enterprise | +| `UsePolicies` | Enterprise policies | Enterprise | +| `UseResetPassword` | Admin password reset | Enterprise | +| `UseOrganizationDomains` | Domain verification/claiming | Enterprise | + +## Questions? + +If you're unsure whether your feature needs a new ability or which existing ability to use, reach out to your team lead +or members of the Admin Console or Architecture teams. When in doubt, adding an explicit ability is almost always the +right choice—it's easy to do and keeps our access control clean and maintainable. diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs index 595e487580..e6cc3da2a2 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs @@ -4,7 +4,6 @@ using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces; -using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; using Bit.Core.Context; @@ -25,8 +24,6 @@ public class VerifyOrganizationDomainCommand( IEventService eventService, IGlobalSettings globalSettings, ICurrentContext currentContext, - IFeatureService featureService, - ISavePolicyCommand savePolicyCommand, IVNextSavePolicyCommand vNextSavePolicyCommand, IMailService mailService, IOrganizationUserRepository organizationUserRepository, @@ -144,15 +141,8 @@ public class VerifyOrganizationDomainCommand( PerformedBy = actingUser }; - if (featureService.IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor)) - { - var savePolicyModel = new SavePolicyModel(policyUpdate, actingUser); - await vNextSavePolicyCommand.SaveAsync(savePolicyModel); - } - else - { - await savePolicyCommand.SaveAsync(policyUpdate); - } + var savePolicyModel = new SavePolicyModel(policyUpdate, actingUser); + await vNextSavePolicyCommand.SaveAsync(savePolicyModel); } private async Task SendVerifiedDomainUserEmailAsync(OrganizationDomain domain) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs index 63f177b3f3..50f194b578 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs @@ -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 _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 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 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 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); + } + } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/AutomaticallyConfirmOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/AutomaticallyConfirmOrganizationUserCommand.cs index 67b5f0da80..0292381857 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/AutomaticallyConfirmOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/AutomaticallyConfirmOrganizationUserCommand.cs @@ -1,10 +1,10 @@ -using Bit.Core.AdminConsole.Models.Data.OrganizationUsers; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Models.Data.OrganizationUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.OrganizationConfirmation; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; -using Bit.Core.Entities; using Bit.Core.Enums; -using Bit.Core.Models.Data; using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; @@ -25,6 +25,8 @@ public class AutomaticallyConfirmOrganizationUserCommand(IOrganizationUserReposi IPushNotificationService pushNotificationService, IPolicyRequirementQuery policyRequirementQuery, ICollectionRepository collectionRepository, + IFeatureService featureService, + ISendOrganizationConfirmationCommand sendOrganizationConfirmationCommand, TimeProvider timeProvider, ILogger logger) : IAutomaticallyConfirmOrganizationUserCommand { @@ -79,19 +81,10 @@ public class AutomaticallyConfirmOrganizationUserCommand(IOrganizationUserReposi return; } - await collectionRepository.CreateAsync( - new Collection - { - OrganizationId = request.Organization!.Id, - Name = request.DefaultUserCollectionName, - Type = CollectionType.DefaultUserCollection - }, - groups: null, - [new CollectionAccessSelection - { - Id = request.OrganizationUser!.Id, - Manage = true - }]); + await collectionRepository.CreateDefaultCollectionsAsync( + request.Organization!.Id, + [request.OrganizationUser!.Id], + request.DefaultUserCollectionName); } catch (Exception ex) { @@ -143,9 +136,7 @@ public class AutomaticallyConfirmOrganizationUserCommand(IOrganizationUserReposi { var user = await userRepository.GetByIdAsync(request.OrganizationUser!.UserId!.Value); - await mailService.SendOrganizationConfirmedEmailAsync(request.Organization!.Name, - user!.Email, - request.OrganizationUser.AccessSecretsManager); + await SendOrganizationConfirmedEmailAsync(request.Organization!, user!.Email, request.OrganizationUser.AccessSecretsManager); } catch (Exception ex) { @@ -183,4 +174,23 @@ public class AutomaticallyConfirmOrganizationUserCommand(IOrganizationUserReposi Organization = await organizationRepository.GetByIdAsync(request.OrganizationId) }; } + + /// + /// Sends the organization confirmed email using either the new mailer pattern or the legacy mail service, + /// depending on the feature flag. + /// + /// The organization the user was confirmed to. + /// The email address of the confirmed user. + /// Whether the user has access to Secrets Manager. + internal async Task SendOrganizationConfirmedEmailAsync(Organization organization, string userEmail, bool accessSecretsManager) + { + if (featureService.IsEnabled(FeatureFlagKeys.OrganizationConfirmationEmail)) + { + await sendOrganizationConfirmationCommand.SendConfirmationAsync(organization, userEmail, accessSecretsManager); + } + else + { + await mailService.SendOrganizationConfirmedEmailAsync(organization.Name, userEmail, accessSecretsManager); + } + } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/AutomaticallyConfirmOrganizationUsersValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/AutomaticallyConfirmOrganizationUsersValidator.cs index 11b89de680..3375120516 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/AutomaticallyConfirmOrganizationUsersValidator.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/AutomaticallyConfirmOrganizationUsersValidator.cs @@ -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> 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 OrganizationHasAutomaticallyConfirmUsersPolicyEnabledAsync( - AutomaticallyConfirmOrganizationUserValidationRequest request) => - await policyRepository.GetByOrganizationIdTypeAsync(request.OrganizationId, - PolicyType.AutomaticUserConfirmation) is { Enabled: true } + private async Task OrganizationHasAutomaticallyConfirmUsersPolicyEnabledAsync(AutomaticallyConfirmOrganizationUserValidationRequest request) => + await policyRepository.GetByOrganizationIdTypeAsync(request.OrganizationId, PolicyType.AutomaticUserConfirmation) is { Enabled: true } && request.Organization is { UseAutomaticUserConfirmation: true }; private async Task OrganizationUserConformsToTwoFactorRequiredPolicyAsync(AutomaticallyConfirmOrganizationUserValidationRequest request) @@ -87,30 +89,37 @@ public class AutomaticallyConfirmOrganizationUsersValidator( .IsTwoFactorRequiredForOrganization(request.Organization!.Id); } - private async Task OrganizationUserConformsToSingleOrgPolicyAsync( + /// + /// 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. + /// + /// + /// The request model encapsulates the current organization, the user being validated, and all organization users associated + /// with that user. + /// + /// + /// An if the user fails to meet the automatic user confirmation policy, or null if the validation succeeds. + /// + private async Task 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(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, + _ => null + ); } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/Errors.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/Errors.cs index 1564daca6c..e65db00f73 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/Errors.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/Errors.cs @@ -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."); diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/README.md b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/README.md new file mode 100644 index 0000000000..063b2f6a5c --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/README.md @@ -0,0 +1,22 @@ +# Automatic User Confirmation + +Owned by: admin-console + +Automatic confirmation requests are server driven events that are sent to the admin's client where via a background service the confirmation will occur. The basic model +for the workflow is as follows: + +- The Api server sends an invite email to a user. +- The user accepts the invite request, which is sent back to the Api server +- The Api server sends a push-notification with the OrganizationId and UserId to a client admin session. +- The Client performs the key exchange in the background and POSTs the ConfirmRequest back to the Api server +- The Api server runs the OrgUser_Confirm sproc to confirm the user in the DB + +This Feature has the following security measures in place in order to achieve our security goals: + +- The single organization exemption for admins/owners is removed for this policy. + - This is enforced by preventing enabling the policy and organization plan feature if there are non-compliant users +- Emergency access is removed for all organization users +- Automatic confirmation will only apply to the User role (You cannot auto confirm admins/owners to an organization) +- The organization has no members with the Provider user type. + - This will also prevent the policy and organization plan feature from being enabled + - This will prevent sending organization invites to provider users diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs index 2fbe6be5c6..02f3346ba6 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs @@ -1,9 +1,12 @@ // FIXME: Update this file to be null safe and then delete the line below #nullable disable +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.OrganizationConfirmation; 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; @@ -11,7 +14,6 @@ using Bit.Core.Billing.Enums; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; -using Bit.Core.Models.Data; using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; @@ -33,7 +35,8 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand private readonly IPolicyRequirementQuery _policyRequirementQuery; private readonly IFeatureService _featureService; private readonly ICollectionRepository _collectionRepository; - + private readonly IAutomaticUserConfirmationPolicyEnforcementValidator _automaticUserConfirmationPolicyEnforcementValidator; + private readonly ISendOrganizationConfirmationCommand _sendOrganizationConfirmationCommand; public ConfirmOrganizationUserCommand( IOrganizationRepository organizationRepository, IOrganizationUserRepository organizationUserRepository, @@ -47,7 +50,8 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand IDeviceRepository deviceRepository, IPolicyRequirementQuery policyRequirementQuery, IFeatureService featureService, - ICollectionRepository collectionRepository) + ICollectionRepository collectionRepository, + IAutomaticUserConfirmationPolicyEnforcementValidator automaticUserConfirmationPolicyEnforcementValidator, ISendOrganizationConfirmationCommand sendOrganizationConfirmationCommand) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -62,8 +66,9 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand _policyRequirementQuery = policyRequirementQuery; _featureService = featureService; _collectionRepository = collectionRepository; + _automaticUserConfirmationPolicyEnforcementValidator = automaticUserConfirmationPolicyEnforcementValidator; + _sendOrganizationConfirmationCommand = sendOrganizationConfirmationCommand; } - public async Task ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key, Guid confirmingUserId, string defaultUserCollectionName = null) { @@ -127,6 +132,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); @@ -165,7 +171,7 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand orgUser.Email = null; await _eventService.LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_Confirmed); - await _mailService.SendOrganizationConfirmedEmailAsync(organization.DisplayName(), user.Email, orgUser.AccessSecretsManager); + await SendOrganizationConfirmedEmailAsync(organization, user.Email, orgUser.AccessSecretsManager); succeededUsers.Add(orgUser); result.Add(Tuple.Create(orgUser, "")); } @@ -188,6 +194,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); @@ -256,39 +281,22 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand /// The encrypted default user collection name. private async Task CreateDefaultCollectionAsync(OrganizationUser organizationUser, string defaultUserCollectionName) { - if (!_featureService.IsEnabled(FeatureFlagKeys.CreateDefaultLocation)) - { - return; - } - // Skip if no collection name provided (backwards compatibility) if (string.IsNullOrWhiteSpace(defaultUserCollectionName)) { return; } - var organizationDataOwnershipPolicy = - await _policyRequirementQuery.GetAsync(organizationUser.UserId!.Value); + var organizationDataOwnershipPolicy = await _policyRequirementQuery.GetAsync(organizationUser.UserId!.Value); if (!organizationDataOwnershipPolicy.RequiresDefaultCollectionOnConfirm(organizationUser.OrganizationId)) { return; } - var defaultCollection = new Collection - { - OrganizationId = organizationUser.OrganizationId, - Name = defaultUserCollectionName, - Type = CollectionType.DefaultUserCollection - }; - var collectionUser = new CollectionAccessSelection - { - Id = organizationUser.Id, - ReadOnly = false, - HidePasswords = false, - Manage = true - }; - - await _collectionRepository.CreateAsync(defaultCollection, groups: null, users: [collectionUser]); + await _collectionRepository.CreateDefaultCollectionsAsync( + organizationUser.OrganizationId, + [organizationUser.Id], + defaultUserCollectionName); } /// @@ -300,19 +308,14 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand private async Task CreateManyDefaultCollectionsAsync(Guid organizationId, IEnumerable confirmedOrganizationUsers, string defaultUserCollectionName) { - if (!_featureService.IsEnabled(FeatureFlagKeys.CreateDefaultLocation)) - { - return; - } - // Skip if no collection name provided (backwards compatibility) if (string.IsNullOrWhiteSpace(defaultUserCollectionName)) { return; } - var policyEligibleOrganizationUserIds = - await _policyRequirementQuery.GetManyByOrganizationIdAsync(organizationId); + var policyEligibleOrganizationUserIds = await _policyRequirementQuery + .GetManyByOrganizationIdAsync(organizationId); var eligibleOrganizationUserIds = confirmedOrganizationUsers .Where(ou => policyEligibleOrganizationUserIds.Contains(ou.Id)) @@ -324,6 +327,25 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand return; } - await _collectionRepository.UpsertDefaultCollectionsAsync(organizationId, eligibleOrganizationUserIds, defaultUserCollectionName); + await _collectionRepository.CreateDefaultCollectionsAsync(organizationId, eligibleOrganizationUserIds, defaultUserCollectionName); + } + + /// + /// Sends the organization confirmed email using either the new mailer pattern or the legacy mail service, + /// depending on the feature flag. + /// + /// The organization the user was confirmed to. + /// The email address of the confirmed user. + /// Whether the user has access to Secrets Manager. + internal async Task SendOrganizationConfirmedEmailAsync(Organization organization, string userEmail, bool accessSecretsManager) + { + if (_featureService.IsEnabled(FeatureFlagKeys.OrganizationConfirmationEmail)) + { + await _sendOrganizationConfirmationCommand.SendConfirmationAsync(organization, userEmail, accessSecretsManager); + } + else + { + await _mailService.SendOrganizationConfirmedEmailAsync(organization.DisplayName(), userEmail, accessSecretsManager); + } } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/BulkResendOrganizationInvitesCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/BulkResendOrganizationInvitesCommand.cs new file mode 100644 index 0000000000..c7c80bd937 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/BulkResendOrganizationInvitesCommand.cs @@ -0,0 +1,69 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; +using Bit.Core.AdminConsole.Utilities.DebuggingInstruments; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; + +public class BulkResendOrganizationInvitesCommand : IBulkResendOrganizationInvitesCommand +{ + private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly IOrganizationRepository _organizationRepository; + private readonly ISendOrganizationInvitesCommand _sendOrganizationInvitesCommand; + private readonly ILogger _logger; + + public BulkResendOrganizationInvitesCommand( + IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository, + ISendOrganizationInvitesCommand sendOrganizationInvitesCommand, + ILogger logger) + { + _organizationUserRepository = organizationUserRepository; + _organizationRepository = organizationRepository; + _sendOrganizationInvitesCommand = sendOrganizationInvitesCommand; + _logger = logger; + } + + public async Task>> BulkResendInvitesAsync( + Guid organizationId, + Guid? invitingUserId, + IEnumerable organizationUsersId) + { + var orgUsers = await _organizationUserRepository.GetManyAsync(organizationUsersId); + _logger.LogUserInviteStateDiagnostics(orgUsers); + + var org = await _organizationRepository.GetByIdAsync(organizationId); + if (org == null) + { + throw new NotFoundException(); + } + + var validUsers = new List(); + var result = new List>(); + + foreach (var orgUser in orgUsers) + { + if (orgUser.Status != OrganizationUserStatusType.Invited || orgUser.OrganizationId != organizationId) + { + result.Add(Tuple.Create(orgUser, "User invalid.")); + } + else + { + validUsers.Add(orgUser); + } + } + + if (validUsers.Any()) + { + await _sendOrganizationInvitesCommand.SendInvitesAsync( + new SendInvitesRequest(validUsers, org)); + + result.AddRange(validUsers.Select(u => Tuple.Create(u, ""))); + } + + return result; + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/IBulkResendOrganizationInvitesCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/IBulkResendOrganizationInvitesCommand.cs new file mode 100644 index 0000000000..342a06fcf9 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/IBulkResendOrganizationInvitesCommand.cs @@ -0,0 +1,20 @@ +using Bit.Core.Entities; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; + +public interface IBulkResendOrganizationInvitesCommand +{ + /// + /// Resend invites to multiple organization users in bulk. + /// + /// The ID of the organization. + /// The ID of the user who is resending the invites. + /// The IDs of the organization users to resend invites to. + /// A tuple containing the OrganizationUser and an error message (empty string if successful) + Task>> BulkResendInvitesAsync( + Guid organizationId, + Guid? invitingUserId, + IEnumerable organizationUsersId); +} + + diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteOrganizationUserValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteOrganizationUserValidator.cs index f8bd988cab..2648a2e429 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteOrganizationUserValidator.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteOrganizationUserValidator.cs @@ -2,10 +2,10 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager; using Bit.Core.AdminConsole.Utilities.Errors; using Bit.Core.AdminConsole.Utilities.Validation; +using Bit.Core.Billing.Services; using Bit.Core.Models.Business; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.Repositories; -using Bit.Core.Services; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation; @@ -15,7 +15,7 @@ public class InviteOrganizationUsersValidator( IOrganizationRepository organizationRepository, IInviteUsersPasswordManagerValidator inviteUsersPasswordManagerValidator, IUpdateSecretsManagerSubscriptionCommand secretsManagerSubscriptionCommand, - IPaymentService paymentService) : IInviteUsersValidator + IStripePaymentService paymentService) : IInviteUsersValidator { public async Task> ValidateAsync( InviteOrganizationUsersValidationRequest request) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManager/InviteUsersPasswordManagerValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManager/InviteUsersPasswordManagerValidator.cs index 67155fe91a..9ba2fd1596 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManager/InviteUsersPasswordManagerValidator.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManager/InviteUsersPasswordManagerValidator.cs @@ -9,8 +9,8 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.V using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Provider; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Utilities.Validation; +using Bit.Core.Billing.Services; using Bit.Core.Repositories; -using Bit.Core.Services; using Bit.Core.Settings; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager; @@ -22,7 +22,7 @@ public class InviteUsersPasswordManagerValidator( IInviteUsersEnvironmentValidator inviteUsersEnvironmentValidator, IInviteUsersOrganizationValidator inviteUsersOrganizationValidator, IProviderRepository providerRepository, - IPaymentService paymentService, + IStripePaymentService paymentService, IOrganizationRepository organizationRepository ) : IInviteUsersPasswordManagerValidator { diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationConfirmation/ISendOrganizationConfirmationCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationConfirmation/ISendOrganizationConfirmationCommand.cs new file mode 100644 index 0000000000..ae4d7acda5 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationConfirmation/ISendOrganizationConfirmationCommand.cs @@ -0,0 +1,22 @@ +using Bit.Core.AdminConsole.Entities; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.OrganizationConfirmation; + +public interface ISendOrganizationConfirmationCommand +{ + /// + /// Sends an organization confirmation email to the specified user. + /// + /// The organization to send the confirmation email for. + /// The email address of the user to send the confirmation to. + /// Whether the user has access to Secrets Manager. + Task SendConfirmationAsync(Organization organization, string userEmail, bool accessSecretsManager); + + /// + /// Sends organization confirmation emails to multiple users. + /// + /// The organization to send the confirmation emails for. + /// The email addresses of the users to send confirmations to. + /// Whether the users have access to Secrets Manager. + Task SendConfirmationsAsync(Organization organization, IEnumerable userEmails, bool accessSecretsManager); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationConfirmation/SendOrganizationConfirmationCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationConfirmation/SendOrganizationConfirmationCommand.cs new file mode 100644 index 0000000000..952478ce36 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationConfirmation/SendOrganizationConfirmationCommand.cs @@ -0,0 +1,110 @@ +using System.Net; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Models.Mail.Mailer.OrganizationConfirmation; +using Bit.Core.Billing.Enums; +using Bit.Core.Platform.Mail.Mailer; +using Bit.Core.Settings; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.OrganizationConfirmation; + +public class SendOrganizationConfirmationCommand(IMailer mailer, GlobalSettings globalSettings) : ISendOrganizationConfirmationCommand +{ + private const string _titleFirst = "You're confirmed as a member of "; + private const string _titleThird = "!"; + + private static string GetConfirmationSubject(string organizationName) => + $"You can now access items from {organizationName}"; + private string GetWebVaultUrl(bool accessSecretsManager) => accessSecretsManager + ? globalSettings.BaseServiceUri.VaultWithHashAndSecretManagerProduct + : globalSettings.BaseServiceUri.VaultWithHash; + + public async Task SendConfirmationAsync(Organization organization, string userEmail, bool accessSecretsManager = false) + { + await SendConfirmationsAsync(organization, [userEmail], accessSecretsManager); + } + + public async Task SendConfirmationsAsync(Organization organization, IEnumerable userEmails, bool accessSecretsManager = false) + { + var userEmailsList = userEmails.ToList(); + + if (userEmailsList.Count == 0) + { + return; + } + + var organizationName = WebUtility.HtmlDecode(organization.Name); + + if (IsEnterpriseOrTeamsPlan(organization.PlanType)) + { + await SendEnterpriseTeamsEmailsAsync(userEmailsList, organizationName, accessSecretsManager); + return; + } + + await SendFamilyFreeConfirmEmailsAsync(userEmailsList, organizationName, accessSecretsManager); + } + + private async Task SendEnterpriseTeamsEmailsAsync(List userEmailsList, string organizationName, bool accessSecretsManager) + { + var mail = new OrganizationConfirmationEnterpriseTeams + { + ToEmails = userEmailsList, + Subject = GetConfirmationSubject(organizationName), + View = new OrganizationConfirmationEnterpriseTeamsView + { + OrganizationName = organizationName, + TitleFirst = _titleFirst, + TitleSecondBold = organizationName, + TitleThird = _titleThird, + WebVaultUrl = GetWebVaultUrl(accessSecretsManager) + } + }; + + await mailer.SendEmail(mail); + } + + private async Task SendFamilyFreeConfirmEmailsAsync(List userEmailsList, string organizationName, bool accessSecretsManager) + { + var mail = new OrganizationConfirmationFamilyFree + { + ToEmails = userEmailsList, + Subject = GetConfirmationSubject(organizationName), + View = new OrganizationConfirmationFamilyFreeView + { + OrganizationName = organizationName, + TitleFirst = _titleFirst, + TitleSecondBold = organizationName, + TitleThird = _titleThird, + WebVaultUrl = GetWebVaultUrl(accessSecretsManager) + } + }; + + await mailer.SendEmail(mail); + } + + + private static bool IsEnterpriseOrTeamsPlan(PlanType planType) + { + return planType switch + { + PlanType.TeamsMonthly2019 or + PlanType.TeamsAnnually2019 or + PlanType.TeamsMonthly2020 or + PlanType.TeamsAnnually2020 or + PlanType.TeamsMonthly2023 or + PlanType.TeamsAnnually2023 or + PlanType.TeamsStarter2023 or + PlanType.TeamsMonthly or + PlanType.TeamsAnnually or + PlanType.TeamsStarter or + PlanType.EnterpriseMonthly2019 or + PlanType.EnterpriseAnnually2019 or + PlanType.EnterpriseMonthly2020 or + PlanType.EnterpriseAnnually2020 or + PlanType.EnterpriseMonthly2023 or + PlanType.EnterpriseAnnually2023 or + PlanType.EnterpriseMonthly or + PlanType.EnterpriseAnnually => true, + _ => false + }; + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs index 651a9225b4..c5b7314730 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs @@ -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) { @@ -91,7 +93,7 @@ public class RestoreOrganizationUserCommand( .twoFactorIsEnabled; } - if (organization.PlanType == PlanType.Free) + if (organization.PlanType == PlanType.Free && organizationUser.UserId.HasValue) { await CheckUserForOtherFreeOrganizationOwnershipAsync(organizationUser); } @@ -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 IsTwoFactorRequiredForOrganizationAsync(Guid userId, Guid organizationId) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IRevokeOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v1/IRevokeOrganizationUserCommand.cs similarity index 66% rename from src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IRevokeOrganizationUserCommand.cs rename to src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v1/IRevokeOrganizationUserCommand.cs index 01ad2f05d2..313c01af7c 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IRevokeOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v1/IRevokeOrganizationUserCommand.cs @@ -1,12 +1,10 @@ using Bit.Core.Entities; using Bit.Core.Enums; -namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v1; public interface IRevokeOrganizationUserCommand { Task RevokeUserAsync(OrganizationUser organizationUser, Guid? revokingUserId); Task RevokeUserAsync(OrganizationUser organizationUser, EventSystemUser systemUser); - Task>> RevokeUsersAsync(Guid organizationId, - IEnumerable organizationUserIds, Guid? revokingUserId); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v1/RevokeOrganizationUserCommand.cs similarity index 52% rename from src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeOrganizationUserCommand.cs rename to src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v1/RevokeOrganizationUserCommand.cs index f24e0ae265..750ebf2518 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v1/RevokeOrganizationUserCommand.cs @@ -7,7 +7,7 @@ using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; -namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v1; public class RevokeOrganizationUserCommand( IEventService eventService, @@ -68,68 +68,4 @@ public class RevokeOrganizationUserCommand( await organizationUserRepository.RevokeAsync(organizationUser.Id); organizationUser.Status = OrganizationUserStatusType.Revoked; } - - public async Task>> RevokeUsersAsync(Guid organizationId, - IEnumerable organizationUserIds, Guid? revokingUserId) - { - var orgUsers = await organizationUserRepository.GetManyAsync(organizationUserIds); - var filteredUsers = orgUsers.Where(u => u.OrganizationId == organizationId) - .ToList(); - - if (!filteredUsers.Any()) - { - throw new BadRequestException("Users invalid."); - } - - if (!await hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync(organizationId, organizationUserIds)) - { - throw new BadRequestException("Organization must have at least one confirmed owner."); - } - - var deletingUserIsOwner = false; - if (revokingUserId.HasValue) - { - deletingUserIsOwner = await currentContext.OrganizationOwner(organizationId); - } - - var result = new List>(); - - foreach (var organizationUser in filteredUsers) - { - try - { - if (organizationUser.Status == OrganizationUserStatusType.Revoked) - { - throw new BadRequestException("Already revoked."); - } - - if (revokingUserId.HasValue && organizationUser.UserId == revokingUserId) - { - throw new BadRequestException("You cannot revoke yourself."); - } - - if (organizationUser.Type == OrganizationUserType.Owner && revokingUserId.HasValue && - !deletingUserIsOwner) - { - throw new BadRequestException("Only owners can revoke other owners."); - } - - await organizationUserRepository.RevokeAsync(organizationUser.Id); - organizationUser.Status = OrganizationUserStatusType.Revoked; - await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Revoked); - if (organizationUser.UserId.HasValue) - { - await pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value); - } - - result.Add(Tuple.Create(organizationUser, "")); - } - catch (BadRequestException e) - { - result.Add(Tuple.Create(organizationUser, e.Message)); - } - } - - return result; - } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/Errors.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/Errors.cs new file mode 100644 index 0000000000..a30894c7d5 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/Errors.cs @@ -0,0 +1,8 @@ +using Bit.Core.AdminConsole.Utilities.v2; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2; + +public record UserAlreadyRevoked() : BadRequestError("Already revoked."); +public record CannotRevokeYourself() : BadRequestError("You cannot revoke yourself."); +public record OnlyOwnersCanRevokeOwners() : BadRequestError("Only owners can revoke other owners."); +public record MustHaveConfirmedOwner() : BadRequestError("Organization must have at least one confirmed owner."); diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/IRevokeOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/IRevokeOrganizationUserCommand.cs new file mode 100644 index 0000000000..e6471ad891 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/IRevokeOrganizationUserCommand.cs @@ -0,0 +1,8 @@ +using Bit.Core.AdminConsole.Utilities.v2.Results; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2; + +public interface IRevokeOrganizationUserCommand +{ + Task> RevokeUsersAsync(RevokeOrganizationUsersRequest request); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/IRevokeOrganizationUserValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/IRevokeOrganizationUserValidator.cs new file mode 100644 index 0000000000..1a5cfd2c46 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/IRevokeOrganizationUserValidator.cs @@ -0,0 +1,9 @@ +using Bit.Core.AdminConsole.Utilities.v2.Validation; +using Bit.Core.Entities; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2; + +public interface IRevokeOrganizationUserValidator +{ + Task>> ValidateAsync(RevokeOrganizationUsersValidationRequest request); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/RevokeOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/RevokeOrganizationUserCommand.cs new file mode 100644 index 0000000000..ca501277a7 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/RevokeOrganizationUserCommand.cs @@ -0,0 +1,114 @@ +using Bit.Core.AdminConsole.Models.Data; +using Bit.Core.AdminConsole.Utilities.v2.Results; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Platform.Push; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Microsoft.Extensions.Logging; +using OneOf.Types; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2; + +public class RevokeOrganizationUserCommand( + IOrganizationUserRepository organizationUserRepository, + IEventService eventService, + IPushNotificationService pushNotificationService, + IRevokeOrganizationUserValidator validator, + TimeProvider timeProvider, + ILogger logger) + : IRevokeOrganizationUserCommand +{ + public async Task> RevokeUsersAsync(RevokeOrganizationUsersRequest request) + { + var validationRequest = await CreateValidationRequestsAsync(request); + + var results = await validator.ValidateAsync(validationRequest); + + var validUsers = results.Where(r => r.IsValid).Select(r => r.Request).ToList(); + + await RevokeValidUsersAsync(validUsers); + + await Task.WhenAll( + LogRevokedOrganizationUsersAsync(validUsers, request.PerformedBy), + SendPushNotificationsAsync(validUsers) + ); + + return results.Select(r => r.Match( + error => new BulkCommandResult(r.Request.Id, error), + _ => new BulkCommandResult(r.Request.Id, new None()) + )); + } + + private async Task CreateValidationRequestsAsync( + RevokeOrganizationUsersRequest request) + { + var organizationUserToRevoke = await organizationUserRepository + .GetManyAsync(request.OrganizationUserIdsToRevoke); + + return new RevokeOrganizationUsersValidationRequest( + request.OrganizationId, + request.OrganizationUserIdsToRevoke, + request.PerformedBy, + organizationUserToRevoke); + } + + private async Task RevokeValidUsersAsync(ICollection validUsers) + { + if (validUsers.Count == 0) + { + return; + } + + await organizationUserRepository.RevokeManyByIdAsync(validUsers.Select(u => u.Id)); + } + + private async Task LogRevokedOrganizationUsersAsync( + ICollection revokedUsers, + IActingUser actingUser) + { + if (revokedUsers.Count == 0) + { + return; + } + + var eventDate = timeProvider.GetUtcNow().UtcDateTime; + + if (actingUser is SystemUser { SystemUserType: not null }) + { + var revokeEventsWithSystem = revokedUsers + .Select(user => (user, EventType.OrganizationUser_Revoked, actingUser.SystemUserType!.Value, + (DateTime?)eventDate)) + .ToList(); + await eventService.LogOrganizationUserEventsAsync(revokeEventsWithSystem); + } + else + { + var revokeEvents = revokedUsers + .Select(user => (user, EventType.OrganizationUser_Revoked, (DateTime?)eventDate)) + .ToList(); + await eventService.LogOrganizationUserEventsAsync(revokeEvents); + } + } + + private async Task SendPushNotificationsAsync(ICollection revokedUsers) + { + var userIdsToNotify = revokedUsers + .Where(user => user.UserId.HasValue) + .Select(user => user.UserId!.Value) + .Distinct() + .ToList(); + + foreach (var userId in userIdsToNotify) + { + try + { + await pushNotificationService.PushSyncOrgKeysAsync(userId); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to send push notification for user {UserId}.", userId); + } + } + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/RevokeOrganizationUsersRequest.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/RevokeOrganizationUsersRequest.cs new file mode 100644 index 0000000000..56996ffb53 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/RevokeOrganizationUsersRequest.cs @@ -0,0 +1,17 @@ +using Bit.Core.AdminConsole.Models.Data; +using Bit.Core.Entities; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2; + +public record RevokeOrganizationUsersRequest( + Guid OrganizationId, + ICollection OrganizationUserIdsToRevoke, + IActingUser PerformedBy +); + +public record RevokeOrganizationUsersValidationRequest( + Guid OrganizationId, + ICollection OrganizationUserIdsToRevoke, + IActingUser PerformedBy, + ICollection OrganizationUsersToRevoke +) : RevokeOrganizationUsersRequest(OrganizationId, OrganizationUserIdsToRevoke, PerformedBy); diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/RevokeOrganizationUsersValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/RevokeOrganizationUsersValidator.cs new file mode 100644 index 0000000000..d2f47ed713 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/RevokeOrganizationUsersValidator.cs @@ -0,0 +1,39 @@ +using Bit.Core.AdminConsole.Models.Data; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.Utilities.v2.Validation; +using Bit.Core.Entities; +using Bit.Core.Enums; +using static Bit.Core.AdminConsole.Utilities.v2.Validation.ValidationResultHelpers; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2; + +public class RevokeOrganizationUsersValidator(IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery) + : IRevokeOrganizationUserValidator +{ + public async Task>> ValidateAsync( + RevokeOrganizationUsersValidationRequest request) + { + var hasRemainingOwner = await hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync(request.OrganizationId, + request.OrganizationUsersToRevoke.Select(x => x.Id) // users excluded because they are going to be revoked + ); + + return request.OrganizationUsersToRevoke.Select(x => + { + return x switch + { + _ when request.PerformedBy is not SystemUser + && x.UserId is not null + && x.UserId == request.PerformedBy.UserId => + Invalid(x, new CannotRevokeYourself()), + { Status: OrganizationUserStatusType.Revoked } => + Invalid(x, new UserAlreadyRevoked()), + { Type: OrganizationUserType.Owner } when !hasRemainingOwner => + Invalid(x, new MustHaveConfirmedOwner()), + { Type: OrganizationUserType.Owner } when !request.PerformedBy.IsOrganizationOwnerOrProvider => + Invalid(x, new OnlyOwnersCanRevokeOwners()), + + _ => Valid(x) + }; + }).ToList(); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/SelfRevokeUser/Errors.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/SelfRevokeUser/Errors.cs new file mode 100644 index 0000000000..8c19544aa9 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/SelfRevokeUser/Errors.cs @@ -0,0 +1,7 @@ +using Bit.Core.AdminConsole.Utilities.v2; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.SelfRevokeUser; + +public record OrganizationUserNotFound() : NotFoundError("Organization user not found."); +public record NotEligibleForSelfRevoke() : BadRequestError("User is not eligible for self-revocation. The organization data ownership policy must be enabled and the user must be a confirmed member."); +public record LastOwnerCannotSelfRevoke() : BadRequestError("The last owner cannot revoke themselves."); diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/SelfRevokeUser/ISelfRevokeOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/SelfRevokeUser/ISelfRevokeOrganizationUserCommand.cs new file mode 100644 index 0000000000..3153465a38 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/SelfRevokeUser/ISelfRevokeOrganizationUserCommand.cs @@ -0,0 +1,22 @@ +using Bit.Core.AdminConsole.Utilities.v2.Results; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.SelfRevokeUser; + +/// +/// Allows users to revoke themselves from an organization when declining to migrate personal items +/// under the OrganizationDataOwnership policy. +/// +public interface ISelfRevokeOrganizationUserCommand +{ + /// + /// Revokes a user from an organization. + /// + /// The organization ID. + /// The user ID to revoke. + /// A indicating success or containing an error. + /// + /// Validates the OrganizationDataOwnership policy is enabled and applies to the user (currently Owners/Admins are exempt), + /// the user is a confirmed member, and prevents the last owner from revoking themselves. + /// + Task SelfRevokeUserAsync(Guid organizationId, Guid userId); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/SelfRevokeUser/SelfRevokeOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/SelfRevokeUser/SelfRevokeOrganizationUserCommand.cs new file mode 100644 index 0000000000..afc0236af4 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/SelfRevokeUser/SelfRevokeOrganizationUserCommand.cs @@ -0,0 +1,56 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; +using Bit.Core.AdminConsole.Utilities.v2.Results; +using Bit.Core.Enums; +using Bit.Core.Platform.Push; +using Bit.Core.Repositories; +using Bit.Core.Services; +using OneOf.Types; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.SelfRevokeUser; + +public class SelfRevokeOrganizationUserCommand( + IOrganizationUserRepository organizationUserRepository, + IPolicyRequirementQuery policyRequirementQuery, + IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery, + IEventService eventService, + IPushNotificationService pushNotificationService) + : ISelfRevokeOrganizationUserCommand +{ + public async Task SelfRevokeUserAsync(Guid organizationId, Guid userId) + { + var organizationUser = await organizationUserRepository.GetByOrganizationAsync(organizationId, userId); + if (organizationUser == null) + { + return new OrganizationUserNotFound(); + } + + var policyRequirement = await policyRequirementQuery.GetAsync(userId); + + if (!policyRequirement.EligibleForSelfRevoke(organizationId)) + { + return new NotEligibleForSelfRevoke(); + } + + // Prevent the last owner from revoking themselves, which would brick the organization + if (organizationUser.Type == OrganizationUserType.Owner) + { + var hasOtherOwner = await hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync( + organizationId, + [organizationUser.Id], + includeProvider: true); + + if (!hasOtherOwner) + { + return new LastOwnerCannotSelfRevoke(); + } + } + + await organizationUserRepository.RevokeAsync(organizationUser.Id); + await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_SelfRevoked); + await pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId!.Value); + + return new None(); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/CloudOrganizationSignUpCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/CloudOrganizationSignUpCommand.cs index 0cae0fcc81..2aa09a5250 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Organizations/CloudOrganizationSignUpCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/CloudOrganizationSignUpCommand.cs @@ -3,11 +3,14 @@ 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; using Bit.Core.Billing.Organizations.Services; using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Services; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -33,7 +36,7 @@ public interface ICloudOrganizationSignUpCommand public class CloudOrganizationSignUpCommand( IOrganizationUserRepository organizationUserRepository, IOrganizationBillingService organizationBillingService, - IPaymentService paymentService, + IStripePaymentService paymentService, IPolicyService policyService, IOrganizationRepository organizationRepository, IOrganizationApiKeyRepository organizationApiKeyRepository, @@ -42,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 SignUpOrganizationAsync(OrganizationSignup signup) { @@ -94,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, @@ -236,6 +241,17 @@ public class CloudOrganizationSignUpCommand( private async Task ValidateSignUpPoliciesAsync(Guid ownerId) { + if (featureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)) + { + var requirement = await policyRequirementQuery.GetAsync(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) { diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/InitPendingOrganizationCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/InitPendingOrganizationCommand.cs index 6474914b48..da678ece71 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Organizations/InitPendingOrganizationCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/InitPendingOrganizationCommand.cs @@ -2,6 +2,8 @@ #nullable disable 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.Auth.Models.Business.Tokenables; using Bit.Core.Entities; @@ -28,6 +30,8 @@ public class InitPendingOrganizationCommand : IInitPendingOrganizationCommand private readonly IGlobalSettings _globalSettings; private readonly IPolicyService _policyService; private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly IFeatureService _featureService; + private readonly IPolicyRequirementQuery _policyRequirementQuery; public InitPendingOrganizationCommand( IOrganizationService organizationService, @@ -37,7 +41,9 @@ public class InitPendingOrganizationCommand : IInitPendingOrganizationCommand IDataProtectionProvider dataProtectionProvider, IGlobalSettings globalSettings, IPolicyService policyService, - IOrganizationUserRepository organizationUserRepository + IOrganizationUserRepository organizationUserRepository, + IFeatureService featureService, + IPolicyRequirementQuery policyRequirementQuery ) { _organizationService = organizationService; @@ -48,6 +54,8 @@ public class InitPendingOrganizationCommand : IInitPendingOrganizationCommand _globalSettings = globalSettings; _policyService = policyService; _organizationUserRepository = organizationUserRepository; + _featureService = featureService; + _policyRequirementQuery = policyRequirementQuery; } public async Task InitPendingOrganizationAsync(User user, Guid organizationId, Guid organizationUserId, string publicKey, string privateKey, string collectionName, string emailToken) @@ -113,6 +121,17 @@ public class InitPendingOrganizationCommand : IInitPendingOrganizationCommand private async Task ValidateSignUpPoliciesAsync(Guid ownerId) { + if (_featureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)) + { + var requirement = await _policyRequirementQuery.GetAsync(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) { diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/IOrganizationUpdateCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/IOrganizationUpdateCommand.cs new file mode 100644 index 0000000000..85fbcd2740 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/IOrganizationUpdateCommand.cs @@ -0,0 +1,15 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Update; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; + +public interface IOrganizationUpdateCommand +{ + /// + /// Updates an organization's information in the Bitwarden database and Stripe (if required). + /// Also optionally updates an organization's public-private keypair if it was not created with one. + /// On self-host, only the public-private keys will be updated because all other properties are fixed by the license file. + /// + /// The update request containing the details to be updated. + Task UpdateAsync(OrganizationUpdateRequest request); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/OrganizationDeleteCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/OrganizationDeleteCommand.cs index 6a81130402..f73c49c811 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Organizations/OrganizationDeleteCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/OrganizationDeleteCommand.cs @@ -2,6 +2,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Repositories; +using Bit.Core.Billing.Services; using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.Services; @@ -12,13 +13,13 @@ public class OrganizationDeleteCommand : IOrganizationDeleteCommand { private readonly IApplicationCacheService _applicationCacheService; private readonly IOrganizationRepository _organizationRepository; - private readonly IPaymentService _paymentService; + private readonly IStripePaymentService _paymentService; private readonly ISsoConfigRepository _ssoConfigRepository; public OrganizationDeleteCommand( IApplicationCacheService applicationCacheService, IOrganizationRepository organizationRepository, - IPaymentService paymentService, + IStripePaymentService paymentService, ISsoConfigRepository ssoConfigRepository) { _applicationCacheService = applicationCacheService; diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/OrganizationExtensions.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/OrganizationExtensions.cs new file mode 100644 index 0000000000..bb8f985495 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/OrganizationExtensions.cs @@ -0,0 +1,28 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.KeyManagement.Models.Data; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations; + +public static class OrganizationExtensions +{ + /// + /// Updates the organization public and private keys if provided and not already set. + /// This is legacy code for old organizations that were not created with a public/private keypair. + /// It is a soft migration that will silently migrate organizations when they perform certain actions, + /// e.g. change their details or upgrade their plan. + /// + public static void BackfillPublicPrivateKeys(this Organization organization, PublicKeyEncryptionKeyPairData? keyPair) + { + // Only backfill if both new keys are provided and both old keys are missing. + if (string.IsNullOrWhiteSpace(keyPair?.PublicKey) || + string.IsNullOrWhiteSpace(keyPair.WrappedPrivateKey) || + !string.IsNullOrWhiteSpace(organization.PublicKey) || + !string.IsNullOrWhiteSpace(organization.PrivateKey)) + { + return; + } + + organization.PublicKey = keyPair.PublicKey; + organization.PrivateKey = keyPair.WrappedPrivateKey; + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/ProviderClientOrganizationSignUpCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/ProviderClientOrganizationSignUpCommand.cs index 4a8f08a4f7..c51ab2a5e0 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Organizations/ProviderClientOrganizationSignUpCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/ProviderClientOrganizationSignUpCommand.cs @@ -93,8 +93,8 @@ public class ProviderClientOrganizationSignUpCommand : IProviderClientOrganizati 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, diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/ResellerClientOrganizationSignUpCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/ResellerClientOrganizationSignUpCommand.cs index 446d7339ca..82260aa6a7 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Organizations/ResellerClientOrganizationSignUpCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/ResellerClientOrganizationSignUpCommand.cs @@ -1,6 +1,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; +using Bit.Core.Billing.Services; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Repositories; @@ -39,7 +40,7 @@ public class ResellerClientOrganizationSignUpCommand : IResellerClientOrganizati private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IEventService _eventService; private readonly ISendOrganizationInvitesCommand _sendOrganizationInvitesCommand; - private readonly IPaymentService _paymentService; + private readonly IStripePaymentService _paymentService; public ResellerClientOrganizationSignUpCommand( IOrganizationRepository organizationRepository, @@ -48,7 +49,7 @@ public class ResellerClientOrganizationSignUpCommand : IResellerClientOrganizati IOrganizationUserRepository organizationUserRepository, IEventService eventService, ISendOrganizationInvitesCommand sendOrganizationInvitesCommand, - IPaymentService paymentService) + IStripePaymentService paymentService) { _organizationRepository = organizationRepository; _organizationApiKeyRepository = organizationApiKeyRepository; diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/SelfHostedOrganizationSignUpCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/SelfHostedOrganizationSignUpCommand.cs index c52b7c10c9..9abce991c3 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Organizations/SelfHostedOrganizationSignUpCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/SelfHostedOrganizationSignUpCommand.cs @@ -2,6 +2,8 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Services; using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Services; @@ -30,7 +32,9 @@ public class SelfHostedOrganizationSignUpCommand : ISelfHostedOrganizationSignUp private readonly ILicensingService _licensingService; private readonly IPolicyService _policyService; private readonly IGlobalSettings _globalSettings; - private readonly IPaymentService _paymentService; + private readonly IStripePaymentService _paymentService; + private readonly IFeatureService _featureService; + private readonly IPolicyRequirementQuery _policyRequirementQuery; public SelfHostedOrganizationSignUpCommand( IOrganizationRepository organizationRepository, @@ -44,7 +48,9 @@ public class SelfHostedOrganizationSignUpCommand : ISelfHostedOrganizationSignUp ILicensingService licensingService, IPolicyService policyService, IGlobalSettings globalSettings, - IPaymentService paymentService) + IStripePaymentService paymentService, + IFeatureService featureService, + IPolicyRequirementQuery policyRequirementQuery) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -58,6 +64,8 @@ public class SelfHostedOrganizationSignUpCommand : ISelfHostedOrganizationSignUp _policyService = policyService; _globalSettings = globalSettings; _paymentService = paymentService; + _featureService = featureService; + _policyRequirementQuery = policyRequirementQuery; } public async Task<(Organization organization, OrganizationUser? organizationUser)> SignUpAsync( @@ -103,6 +111,17 @@ public class SelfHostedOrganizationSignUpCommand : ISelfHostedOrganizationSignUp private async Task ValidateSignUpPoliciesAsync(Guid ownerId) { + if (_featureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)) + { + var requirement = await _policyRequirementQuery.GetAsync(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) { diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/Update/OrganizationUpdateCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Update/OrganizationUpdateCommand.cs new file mode 100644 index 0000000000..5cfd2191b3 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Update/OrganizationUpdateCommand.cs @@ -0,0 +1,89 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; +using Bit.Core.Billing.Organizations.Services; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Settings; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Update; + +public class OrganizationUpdateCommand( + IOrganizationService organizationService, + IOrganizationRepository organizationRepository, + IGlobalSettings globalSettings, + IOrganizationBillingService organizationBillingService +) : IOrganizationUpdateCommand +{ + public async Task UpdateAsync(OrganizationUpdateRequest request) + { + var organization = await organizationRepository.GetByIdAsync(request.OrganizationId); + if (organization == null) + { + throw new NotFoundException(); + } + + if (globalSettings.SelfHosted) + { + return await UpdateSelfHostedAsync(organization, request); + } + + return await UpdateCloudAsync(organization, request); + } + + private async Task UpdateCloudAsync(Organization organization, OrganizationUpdateRequest request) + { + // Store original values for comparison + var originalName = organization.Name; + var originalBillingEmail = organization.BillingEmail; + + // Apply updates to organization + // These values may or may not be sent by the client depending on the operation being performed. + // Skip any values not provided. + if (request.Name is not null) + { + organization.Name = request.Name; + } + + if (request.BillingEmail is not null) + { + organization.BillingEmail = request.BillingEmail.ToLowerInvariant().Trim(); + } + + organization.BackfillPublicPrivateKeys(request.Keys); + + await organizationService.ReplaceAndUpdateCacheAsync(organization, EventType.Organization_Updated); + + // Update billing information in Stripe if required + await UpdateBillingAsync(organization, originalName, originalBillingEmail); + + return organization; + } + + /// + /// Self-host cannot update the organization details because they are set by the license file. + /// However, this command does offer a soft migration pathway for organizations without public and private keys. + /// If we remove this migration code in the future, this command and endpoint can become cloud only. + /// + private async Task UpdateSelfHostedAsync(Organization organization, OrganizationUpdateRequest request) + { + organization.BackfillPublicPrivateKeys(request.Keys); + await organizationService.ReplaceAndUpdateCacheAsync(organization, EventType.Organization_Updated); + return organization; + } + + private async Task UpdateBillingAsync(Organization organization, string originalName, string? originalBillingEmail) + { + // Update Stripe if name or billing email changed + var shouldUpdateBilling = originalName != organization.Name || + originalBillingEmail != organization.BillingEmail; + + if (!shouldUpdateBilling) + { + return; + } + + await organizationBillingService.UpdateOrganizationNameAndEmail(organization); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/Update/OrganizationUpdateRequest.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Update/OrganizationUpdateRequest.cs new file mode 100644 index 0000000000..4695ee0ba7 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Update/OrganizationUpdateRequest.cs @@ -0,0 +1,30 @@ +using Bit.Core.KeyManagement.Models.Data; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Update; + +/// +/// Request model for updating the name, billing email, and/or public-private keys for an organization (legacy migration code). +/// Any combination of these properties can be updated, so they are optional. If none are specified it will not update anything. +/// +public record OrganizationUpdateRequest +{ + /// + /// The ID of the organization to update. + /// + public required Guid OrganizationId { get; init; } + + /// + /// The new organization name to apply (optional, this is skipped if not provided). + /// + public string? Name { get; init; } + + /// + /// The new billing email address to apply (optional, this is skipped if not provided). + /// + public string? BillingEmail { get; init; } + + /// + /// The organization's public/private key pair to set (optional, only set if not already present on the organization). + /// + public PublicKeyEncryptionKeyPairData? Keys { get; init; } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/UpdateOrganizationSubscriptionCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/UpdateOrganizationSubscriptionCommand.cs index 450f425bdf..e4d5a94c4c 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Organizations/UpdateOrganizationSubscriptionCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/UpdateOrganizationSubscriptionCommand.cs @@ -1,12 +1,12 @@ using Bit.Core.AdminConsole.Models.Data.Organizations; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; +using Bit.Core.Billing.Services; using Bit.Core.Repositories; -using Bit.Core.Services; using Microsoft.Extensions.Logging; namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations; -public class UpdateOrganizationSubscriptionCommand(IPaymentService paymentService, +public class UpdateOrganizationSubscriptionCommand(IStripePaymentService paymentService, IOrganizationRepository repository, TimeProvider timeProvider, ILogger logger) : IUpdateOrganizationSubscriptionCommand diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/AutomaticUserConfirmationPolicyEnforcementRequest.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/AutomaticUserConfirmationPolicyEnforcementRequest.cs new file mode 100644 index 0000000000..962da4bef7 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/AutomaticUserConfirmationPolicyEnforcementRequest.cs @@ -0,0 +1,44 @@ +using Bit.Core.Entities; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; + +/// +/// Request object for +/// +public record AutomaticUserConfirmationPolicyEnforcementRequest +{ + /// + /// Organization to be validated + /// + public Guid OrganizationId { get; } + + /// + /// All organization users that match the provided user. + /// + public ICollection AllOrganizationUsers { get; } + + /// + /// User associated with the organization user to be confirmed + /// + public User User { get; } + + /// + /// Request object for . + /// + /// + /// This record is used to encapsulate the data required for handling the automatic confirmation policy enforcement. + /// + /// The organization to be validated. + /// All organization users that match the provided user. + /// The user entity connecting all org users provided. + public AutomaticUserConfirmationPolicyEnforcementRequest( + Guid organizationId, + IEnumerable organizationUsers, + User user) + { + OrganizationId = organizationId; + AllOrganizationUsers = organizationUsers.ToArray(); + User = user; + } +} + diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/AutomaticUserConfirmationPolicyEnforcementValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/AutomaticUserConfirmationPolicyEnforcementValidator.cs new file mode 100644 index 0000000000..e5c980ea24 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/AutomaticUserConfirmationPolicyEnforcementValidator.cs @@ -0,0 +1,50 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.AdminConsole.Utilities.v2.Validation; +using static Bit.Core.AdminConsole.Utilities.v2.Validation.ValidationResultHelpers; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; + +public class AutomaticUserConfirmationPolicyEnforcementValidator( + IPolicyRequirementQuery policyRequirementQuery, + IProviderUserRepository providerUserRepository) + : IAutomaticUserConfirmationPolicyEnforcementValidator +{ + public async Task> IsCompliantAsync( + AutomaticUserConfirmationPolicyEnforcementRequest request) + { + var automaticUserConfirmationPolicyRequirement = await policyRequirementQuery + .GetAsync(request.User.Id); + + var currentOrganizationUser = request.AllOrganizationUsers + .FirstOrDefault(x => x.OrganizationId == request.OrganizationId + // invited users do not have a userId but will have email + && (x.UserId == request.User.Id || x.Email == request.User.Email)); + + if (currentOrganizationUser is null) + { + return Invalid(request, new CurrentOrganizationUserIsNotPresentInRequest()); + } + + if (automaticUserConfirmationPolicyRequirement.IsEnabled(request.OrganizationId)) + { + if ((await providerUserRepository.GetManyByUserAsync(request.User.Id)).Count != 0) + { + return Invalid(request, new ProviderUsersCannotJoin()); + } + + if (request.AllOrganizationUsers.Count > 1) + { + return Invalid(request, new UserCannotBelongToAnotherOrganization()); + } + } + + if (automaticUserConfirmationPolicyRequirement.IsEnabledForOrganizationsOtherThan(currentOrganizationUser.OrganizationId)) + { + return Invalid(request, new OtherOrganizationDoesNotAllowOtherMembership()); + } + + return Valid(request); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/IAutomaticUserConfirmationPolicyEnforcementValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/IAutomaticUserConfirmationPolicyEnforcementValidator.cs new file mode 100644 index 0000000000..7bc1664140 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/IAutomaticUserConfirmationPolicyEnforcementValidator.cs @@ -0,0 +1,28 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; +using Bit.Core.AdminConsole.Utilities.v2.Validation; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; + +/// +/// Used to enforce the Automatic User Confirmation policy. It uses the to retrieve +/// the . It is used to check to make sure the given user is +/// valid for the Automatic User Confirmation policy. It also validates that the given user is not a provider +/// or a member of another organization regardless of status or type. +/// +public interface IAutomaticUserConfirmationPolicyEnforcementValidator +{ + + /// + /// Checks if the given user is compliant with the Automatic User Confirmation policy. + /// + /// To be compliant, a user must + /// - not be a member of a provider + /// - not be a member of another organization + /// + /// + /// + /// This uses the validation result pattern to avoid throwing exceptions. + /// + /// A validation result with the error message if applicable. + Task> IsCompliantAsync(AutomaticUserConfirmationPolicyEnforcementRequest request); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/SavePolicyCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/SavePolicyCommand.cs index e2bca930d1..57140317e3 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/SavePolicyCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/SavePolicyCommand.cs @@ -4,6 +4,8 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.Models; +using Bit.Core.Platform.Push; using Bit.Core.Services; namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations; @@ -16,19 +18,22 @@ public class SavePolicyCommand : ISavePolicyCommand private readonly IReadOnlyDictionary _policyValidators; private readonly TimeProvider _timeProvider; private readonly IPostSavePolicySideEffect _postSavePolicySideEffect; + private readonly IPushNotificationService _pushNotificationService; public SavePolicyCommand(IApplicationCacheService applicationCacheService, IEventService eventService, IPolicyRepository policyRepository, IEnumerable policyValidators, TimeProvider timeProvider, - IPostSavePolicySideEffect postSavePolicySideEffect) + IPostSavePolicySideEffect postSavePolicySideEffect, + IPushNotificationService pushNotificationService) { _applicationCacheService = applicationCacheService; _eventService = eventService; _policyRepository = policyRepository; _timeProvider = timeProvider; _postSavePolicySideEffect = postSavePolicySideEffect; + _pushNotificationService = pushNotificationService; var policyValidatorsDict = new Dictionary(); foreach (var policyValidator in policyValidators) @@ -75,6 +80,8 @@ public class SavePolicyCommand : ISavePolicyCommand await _policyRepository.UpsertAsync(policy); await _eventService.LogPolicyEventAsync(policy, EventType.Policy_Updated); + await PushPolicyUpdateToClients(policy.OrganizationId, policy); + return policy; } @@ -152,4 +159,17 @@ public class SavePolicyCommand : ISavePolicyCommand var currentPolicy = savedPoliciesDict.GetValueOrDefault(policyUpdate.Type); return (savedPoliciesDict, currentPolicy); } + + Task PushPolicyUpdateToClients(Guid organizationId, Policy policy) => this._pushNotificationService.PushAsync(new PushNotification + { + Type = PushType.PolicyChanged, + Target = NotificationTarget.Organization, + TargetId = organizationId, + ExcludeCurrentContext = false, + Payload = new SyncPolicyPushNotification + { + Policy = policy, + OrganizationId = organizationId + } + }); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/VNextSavePolicyCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/VNextSavePolicyCommand.cs index 5d40cb211f..38e417d085 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/VNextSavePolicyCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/VNextSavePolicyCommand.cs @@ -5,6 +5,8 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Int using Bit.Core.AdminConsole.Repositories; using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.Models; +using Bit.Core.Platform.Push; using Bit.Core.Services; namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations; @@ -15,7 +17,8 @@ public class VNextSavePolicyCommand( IPolicyRepository policyRepository, IEnumerable policyUpdateEventHandlers, TimeProvider timeProvider, - IPolicyEventHandlerFactory policyEventHandlerFactory) + IPolicyEventHandlerFactory policyEventHandlerFactory, + IPushNotificationService pushNotificationService) : IVNextSavePolicyCommand { @@ -74,7 +77,7 @@ public class VNextSavePolicyCommand( policy.RevisionDate = timeProvider.GetUtcNow().UtcDateTime; await policyRepository.UpsertAsync(policy); - + await PushPolicyUpdateToClients(policyUpdateRequest.OrganizationId, policy); return policy; } @@ -192,4 +195,17 @@ public class VNextSavePolicyCommand( var savedPoliciesDict = savedPolicies.ToDictionary(p => p.Type); return savedPoliciesDict; } + + Task PushPolicyUpdateToClients(Guid organizationId, Policy policy) => pushNotificationService.PushAsync(new PushNotification + { + Type = PushType.PolicyChanged, + Target = NotificationTarget.Organization, + TargetId = organizationId, + ExcludeCurrentContext = false, + Payload = new SyncPolicyPushNotification + { + Policy = policy, + OrganizationId = organizationId + } + }); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/AutomaticUserConfirmationPolicyRequirement.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/AutomaticUserConfirmationPolicyRequirement.cs new file mode 100644 index 0000000000..3430f33a77 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/AutomaticUserConfirmationPolicyRequirement.cs @@ -0,0 +1,48 @@ +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.Enums; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; + +/// +/// Represents the enforcement status of the Automatic User Confirmation policy. +/// +/// +/// The Automatic User Confirmation policy is enforced against all types of users regardless of status or type. +/// +/// Users cannot: +///
      +///
    • Be a member of another organization (similar to Single Organization Policy)
    • +///
    • Cannot be a provider
    • +///
    +///
    +/// Collection of policy details that apply to this user id +public class AutomaticUserConfirmationPolicyRequirement(IEnumerable policyDetails) : IPolicyRequirement +{ + public bool CannotBeGrantedEmergencyAccess() => policyDetails.Any(); + + public bool CannotJoinProvider() => policyDetails.Any(); + + public bool CannotCreateProvider() => policyDetails.Any(); + + public bool CannotCreateNewOrganization() => policyDetails.Any(); + + public bool IsEnabled(Guid organizationId) => policyDetails.Any(p => p.OrganizationId == organizationId); + + public bool IsEnabledForOrganizationsOtherThan(Guid organizationId) => + policyDetails.Any(p => p.OrganizationId != organizationId); +} + +public class AutomaticUserConfirmationPolicyRequirementFactory : BasePolicyRequirementFactory +{ + public override PolicyType PolicyType => PolicyType.AutomaticUserConfirmation; + + protected override IEnumerable ExemptRoles => []; + + protected override IEnumerable ExemptStatuses => []; + + protected override bool ExemptProviders => false; + + public override AutomaticUserConfirmationPolicyRequirement Create(IEnumerable policyDetails) => + new(policyDetails); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirement.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirement.cs index 28d6614dcb..d30ba5c39f 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirement.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirement.cs @@ -72,6 +72,35 @@ public class OrganizationDataOwnershipPolicyRequirement : IPolicyRequirement { return _policyDetails.Any(p => p.OrganizationId == organizationId); } + + /// + /// Ignore storage limits if the organization has data ownership policy enabled. + /// Allows users to seamlessly migrate their data into the organization without being blocked by storage limits. + /// Organization admins will need to manage storage after migration should overages occur. + /// + public bool IgnoreStorageLimitsOnMigration(Guid organizationId) + { + return _policyDetails.Any(p => p.OrganizationId == organizationId && + p.OrganizationUserStatus == OrganizationUserStatusType.Confirmed); + } + + /// + /// Determines if a user is eligible for self-revocation under the Organization Data Ownership policy. + /// A user is eligible if they are a confirmed member of the organization and the policy is enabled. + /// This also handles exempt roles (Owner/Admin) and policy disabled state via the factory's Enforce predicate. + /// + /// The organization ID to check. + /// True if the user is eligible for self-revocation (policy applies to them), false otherwise. + /// + /// Self-revoke is used to opt out of migrating the user's personal vault to the organization as required by this policy. + /// + public bool EligibleForSelfRevoke(Guid organizationId) + { + var policyDetail = _policyDetails + .FirstOrDefault(p => p.OrganizationId == organizationId); + + return policyDetail?.HasStatus([OrganizationUserStatusType.Confirmed]) ?? false; + } } public record DefaultCollectionRequest(Guid OrganizationUserId, bool ShouldCreateDefaultCollection) diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs index e0ca8d6f90..f69935715d 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ -using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; @@ -23,6 +24,8 @@ public static class PolicyServiceCollectionExtensions services.AddPolicyRequirements(); services.AddPolicySideEffects(); services.AddPolicyUpdateEvents(); + + services.AddScoped(); } [Obsolete("Use AddPolicyUpdateEvents instead.")] @@ -35,6 +38,8 @@ public static class PolicyServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); } [Obsolete("Use AddPolicyUpdateEvents instead.")] @@ -53,6 +58,7 @@ public static class PolicyServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); } @@ -66,5 +72,6 @@ public static class PolicyServiceCollectionExtensions services.AddScoped, RequireTwoFactorPolicyRequirementFactory>(); services.AddScoped, MasterPasswordPolicyRequirementFactory>(); services.AddScoped, SingleOrganizationPolicyRequirementFactory>(); + services.AddScoped, AutomaticUserConfirmationPolicyRequirementFactory>(); } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandler.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandler.cs index c0d302df02..213d18c27d 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandler.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandler.cs @@ -4,6 +4,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Enums; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Repositories; namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; @@ -17,26 +18,13 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; ///
  • All organization users are compliant with the Single organization policy
  • ///
  • No provider users exist
  • /// -/// -/// This class also performs side effects when the policy is being enabled or disabled. They are: -///
      -///
    • Sets the UseAutomaticUserConfirmation organization feature to match the policy update
    • -///
    ///
    public class AutomaticUserConfirmationPolicyEventHandler( IOrganizationUserRepository organizationUserRepository, - IProviderUserRepository providerUserRepository, - IPolicyRepository policyRepository, - IOrganizationRepository organizationRepository, - TimeProvider timeProvider) - : IPolicyValidator, IPolicyValidationEvent, IOnPolicyPreUpdateEvent, IEnforceDependentPoliciesEvent + IProviderUserRepository providerUserRepository) + : IPolicyValidator, IPolicyValidationEvent, IEnforceDependentPoliciesEvent { public PolicyType Type => PolicyType.AutomaticUserConfirmation; - public async Task ExecutePreUpsertSideEffectAsync(SavePolicyModel policyRequest, Policy? currentPolicy) => - await OnSaveSideEffectsAsync(policyRequest.PolicyUpdate, currentPolicy); - - private const string _singleOrgPolicyNotEnabledErrorMessage = - "The Single organization policy must be enabled before enabling the Automatically confirm invited users policy."; private const string _usersNotCompliantWithSingleOrgErrorMessage = "All organization users must be compliant with the Single organization policy before enabling the Automatically confirm invited users policy. Please remove users who are members of multiple organizations."; @@ -61,27 +49,20 @@ public class AutomaticUserConfirmationPolicyEventHandler( public async Task ValidateAsync(SavePolicyModel savePolicyModel, Policy? currentPolicy) => await ValidateAsync(savePolicyModel.PolicyUpdate, currentPolicy); - public async Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) - { - var organization = await organizationRepository.GetByIdAsync(policyUpdate.OrganizationId); - - if (organization is not null) - { - organization.UseAutomaticUserConfirmation = policyUpdate.Enabled; - organization.RevisionDate = timeProvider.GetUtcNow().UtcDateTime; - await organizationRepository.UpsertAsync(organization); - } - } + public Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) => + Task.CompletedTask; private async Task ValidateEnablingPolicyAsync(Guid organizationId) { - var singleOrgValidationError = await ValidateSingleOrgPolicyComplianceAsync(organizationId); + var organizationUsers = await organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId); + + var singleOrgValidationError = await ValidateUserComplianceWithSingleOrgAsync(organizationId, organizationUsers); if (!string.IsNullOrWhiteSpace(singleOrgValidationError)) { return singleOrgValidationError; } - var providerValidationError = await ValidateNoProviderUsersAsync(organizationId); + var providerValidationError = await ValidateNoProviderUsersAsync(organizationUsers); if (!string.IsNullOrWhiteSpace(providerValidationError)) { return providerValidationError; @@ -90,42 +71,28 @@ public class AutomaticUserConfirmationPolicyEventHandler( return string.Empty; } - private async Task ValidateSingleOrgPolicyComplianceAsync(Guid organizationId) + private async Task ValidateUserComplianceWithSingleOrgAsync(Guid organizationId, + ICollection organizationUsers) { - var singleOrgPolicy = await policyRepository.GetByOrganizationIdTypeAsync(organizationId, PolicyType.SingleOrg); - if (singleOrgPolicy is not { Enabled: true }) - { - return _singleOrgPolicyNotEnabledErrorMessage; - } + var userIds = organizationUsers.Where( + u => u.UserId is not null && + u.Status != OrganizationUserStatusType.Invited) + .Select(u => u.UserId!.Value); - return await ValidateUserComplianceWithSingleOrgAsync(organizationId); - } - - private async Task ValidateUserComplianceWithSingleOrgAsync(Guid organizationId) - { - var organizationUsers = (await organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId)) - .Where(ou => ou.Status != OrganizationUserStatusType.Invited && - ou.Status != OrganizationUserStatusType.Revoked && - ou.UserId.HasValue) - .ToList(); - - if (organizationUsers.Count == 0) - { - return string.Empty; - } - - var hasNonCompliantUser = (await organizationUserRepository.GetManyByManyUsersAsync( - organizationUsers.Select(ou => ou.UserId!.Value))) - .Any(uo => uo.OrganizationId != organizationId && - uo.Status != OrganizationUserStatusType.Invited); + var hasNonCompliantUser = (await organizationUserRepository.GetManyByManyUsersAsync(userIds)) + .Any(uo => uo.OrganizationId != organizationId + && uo.Status != OrganizationUserStatusType.Invited); return hasNonCompliantUser ? _usersNotCompliantWithSingleOrgErrorMessage : string.Empty; } - private async Task ValidateNoProviderUsersAsync(Guid organizationId) + private async Task ValidateNoProviderUsersAsync(ICollection organizationUsers) { - var providerUsers = await providerUserRepository.GetManyByOrganizationAsync(organizationId); + var userIds = organizationUsers.Where(x => x.UserId is not null) + .Select(x => x.UserId!.Value); - return providerUsers.Count > 0 ? _providerUsersExistErrorMessage : string.Empty; + return (await providerUserRepository.GetManyByManyUsersAsync(userIds)).Count != 0 + ? _providerUsersExistErrorMessage + : string.Empty; } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/BlockClaimedDomainAccountCreationPolicyValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/BlockClaimedDomainAccountCreationPolicyValidator.cs new file mode 100644 index 0000000000..92ba11f5a6 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/BlockClaimedDomainAccountCreationPolicyValidator.cs @@ -0,0 +1,59 @@ +#nullable enable + +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; +using Bit.Core.Services; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; + +public class BlockClaimedDomainAccountCreationPolicyValidator : IPolicyValidator, IPolicyValidationEvent +{ + private readonly IOrganizationHasVerifiedDomainsQuery _organizationHasVerifiedDomainsQuery; + private readonly IFeatureService _featureService; + + public BlockClaimedDomainAccountCreationPolicyValidator( + IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery, + IFeatureService featureService) + { + _organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery; + _featureService = featureService; + } + + public PolicyType Type => PolicyType.BlockClaimedDomainAccountCreation; + + // No prerequisites - this policy stands alone + public IEnumerable RequiredPolicies => []; + + public async Task ValidateAsync(SavePolicyModel policyRequest, Policy? currentPolicy) + { + return await ValidateAsync(policyRequest.PolicyUpdate, currentPolicy); + } + + public async Task ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) + { + // Check if feature is enabled + if (!_featureService.IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation)) + { + return "This feature is not enabled"; + } + + // Only validate when trying to ENABLE the policy + if (policyUpdate is { Enabled: true }) + { + // Check if organization has at least one verified domain + if (!await _organizationHasVerifiedDomainsQuery.HasVerifiedDomainsAsync(policyUpdate.OrganizationId)) + { + return "You must claim at least one domain to turn on this policy"; + } + } + + // Disabling the policy is always allowed + return string.Empty; + } + + public Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) + => Task.CompletedTask; +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidator.cs index 0bee2a55af..104a5751ff 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidator.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidator.cs @@ -6,15 +6,13 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Repositories; -using Bit.Core.Services; namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; public class OrganizationDataOwnershipPolicyValidator( IPolicyRepository policyRepository, ICollectionRepository collectionRepository, - IEnumerable> factories, - IFeatureService featureService) + IEnumerable> factories) : OrganizationPolicyValidator(policyRepository, factories), IPostSavePolicySideEffect, IOnPolicyPostUpdateEvent { public PolicyType Type => PolicyType.OrganizationDataOwnership; @@ -32,11 +30,6 @@ public class OrganizationDataOwnershipPolicyValidator( Policy postUpdatedPolicy, Policy? previousPolicyState) { - if (!featureService.IsEnabled(FeatureFlagKeys.CreateDefaultLocation)) - { - return; - } - if (policyRequest.Metadata is not OrganizationModelOwnershipPolicyModel metadata) { return; @@ -64,14 +57,15 @@ public class OrganizationDataOwnershipPolicyValidator( var userOrgIds = requirements .Select(requirement => requirement.GetDefaultCollectionRequestOnPolicyEnable(policyUpdate.OrganizationId)) .Where(request => request.ShouldCreateDefaultCollection) - .Select(request => request.OrganizationUserId); + .Select(request => request.OrganizationUserId) + .ToList(); if (!userOrgIds.Any()) { return; } - await collectionRepository.UpsertDefaultCollectionsAsync( + await collectionRepository.CreateDefaultCollectionsBulkAsync( policyUpdate.OrganizationId, userOrgIds, defaultCollectionName); diff --git a/src/Core/AdminConsole/Repositories/IOrganizationIntegrationConfigurationRepository.cs b/src/Core/AdminConsole/Repositories/IOrganizationIntegrationConfigurationRepository.cs deleted file mode 100644 index 0a774cf395..0000000000 --- a/src/Core/AdminConsole/Repositories/IOrganizationIntegrationConfigurationRepository.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Bit.Core.AdminConsole.Entities; -using Bit.Core.Enums; -using Bit.Core.Models.Data.Organizations; - -namespace Bit.Core.Repositories; - -public interface IOrganizationIntegrationConfigurationRepository : IRepository -{ - Task> GetConfigurationDetailsAsync( - Guid organizationId, - IntegrationType integrationType, - EventType eventType); - - Task> GetAllConfigurationDetailsAsync(); - - Task> GetManyByIntegrationAsync(Guid organizationIntegrationId); -} diff --git a/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs b/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs index da7a77000b..d79923fdd1 100644 --- a/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs +++ b/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs @@ -21,7 +21,9 @@ public interface IOrganizationRepository : IRepository Task> GetOwnerEmailAddressesById(Guid organizationId); /// - /// Gets the organizations that have a verified domain matching the user's email domain. + /// Gets the organizations that have claimed the user's account. Currently, only one organization may claim a user. + /// This requires that the organization has claimed the user's domain and the user is an organization member. + /// It excludes invited members. /// Task> GetByVerifiedUserEmailDomainAsync(Guid userId); diff --git a/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs b/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs index bedb9d49ee..41622c24b7 100644 --- a/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs +++ b/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs @@ -97,4 +97,15 @@ public interface IOrganizationUserRepository : IRepositoryAccepted OrganizationUser to confirm /// True, if the user was updated. False, if not performed. Task ConfirmOrganizationUserAsync(AcceptedOrganizationUserToConfirm organizationUserToConfirm); + + /// + /// Returns the OrganizationUserUserDetails if found. + /// + /// The id of the organization + /// The id of the User to fetch + /// OrganizationUserUserDetails of the specified user or null if not found + /// + /// Similar to GetByOrganizationAsync, but returns the user details. + /// + Task GetDetailsByOrganizationIdUserIdAsync(Guid organizationId, Guid userId); } diff --git a/src/Core/AdminConsole/Repositories/IProviderUserRepository.cs b/src/Core/AdminConsole/Repositories/IProviderUserRepository.cs index 7bc4125778..0a640b7530 100644 --- a/src/Core/AdminConsole/Repositories/IProviderUserRepository.cs +++ b/src/Core/AdminConsole/Repositories/IProviderUserRepository.cs @@ -12,6 +12,7 @@ public interface IProviderUserRepository : IRepository Task GetCountByProviderAsync(Guid providerId, string email, bool onlyRegisteredUsers); Task> GetManyAsync(IEnumerable ids); Task> GetManyByUserAsync(Guid userId); + Task> GetManyByManyUsersAsync(IEnumerable userIds); Task GetByProviderUserAsync(Guid providerId, Guid userId); Task> GetManyByProviderAsync(Guid providerId, ProviderUserType? type = null); Task> GetManyDetailsByProviderAsync(Guid providerId, ProviderUserStatusType? status = null); diff --git a/src/Core/AdminConsole/Services/IIntegrationConfigurationDetailsCache.cs b/src/Core/AdminConsole/Services/IIntegrationConfigurationDetailsCache.cs deleted file mode 100644 index ad27429112..0000000000 --- a/src/Core/AdminConsole/Services/IIntegrationConfigurationDetailsCache.cs +++ /dev/null @@ -1,14 +0,0 @@ -#nullable enable - -using Bit.Core.Enums; -using Bit.Core.Models.Data.Organizations; - -namespace Bit.Core.Services; - -public interface IIntegrationConfigurationDetailsCache -{ - List GetConfigurationDetails( - Guid organizationId, - IntegrationType integrationType, - EventType eventType); -} diff --git a/src/Core/AdminConsole/Services/IIntegrationHandler.cs b/src/Core/AdminConsole/Services/IIntegrationHandler.cs deleted file mode 100644 index bb10dc01b9..0000000000 --- a/src/Core/AdminConsole/Services/IIntegrationHandler.cs +++ /dev/null @@ -1,74 +0,0 @@ -using System.Globalization; -using System.Net; -using Bit.Core.AdminConsole.Models.Data.EventIntegrations; - -namespace Bit.Core.Services; - -public interface IIntegrationHandler -{ - Task HandleAsync(string json); -} - -public interface IIntegrationHandler : IIntegrationHandler -{ - Task HandleAsync(IntegrationMessage message); -} - -public abstract class IntegrationHandlerBase : IIntegrationHandler -{ - public async Task HandleAsync(string json) - { - var message = IntegrationMessage.FromJson(json); - return await HandleAsync(message ?? throw new ArgumentException("IntegrationMessage was null when created from the provided JSON")); - } - - public abstract Task HandleAsync(IntegrationMessage message); - - protected IntegrationHandlerResult ResultFromHttpResponse( - HttpResponseMessage response, - IntegrationMessage message, - TimeProvider timeProvider) - { - var result = new IntegrationHandlerResult(success: response.IsSuccessStatusCode, message); - - if (response.IsSuccessStatusCode) return result; - - switch (response.StatusCode) - { - case HttpStatusCode.TooManyRequests: - case HttpStatusCode.RequestTimeout: - case HttpStatusCode.InternalServerError: - case HttpStatusCode.BadGateway: - case HttpStatusCode.ServiceUnavailable: - case HttpStatusCode.GatewayTimeout: - result.Retryable = true; - result.FailureReason = response.ReasonPhrase ?? $"Failure with status code: {(int)response.StatusCode}"; - - if (response.Headers.TryGetValues("Retry-After", out var values)) - { - var value = values.FirstOrDefault(); - if (int.TryParse(value, out var seconds)) - { - // Retry-after was specified in seconds. Adjust DelayUntilDate by the requested number of seconds. - result.DelayUntilDate = timeProvider.GetUtcNow().AddSeconds(seconds).UtcDateTime; - } - else if (DateTimeOffset.TryParseExact(value, - "r", // "r" is the round-trip format: RFC1123 - CultureInfo.InvariantCulture, - DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, - out var retryDate)) - { - // Retry-after was specified as a date. Adjust DelayUntilDate to the specified date. - result.DelayUntilDate = retryDate.UtcDateTime; - } - } - break; - default: - result.Retryable = false; - result.FailureReason = response.ReasonPhrase ?? $"Failure with status code {(int)response.StatusCode}"; - break; - } - - return result; - } -} diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationHandler.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationHandler.cs deleted file mode 100644 index 8423652eb8..0000000000 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationHandler.cs +++ /dev/null @@ -1,109 +0,0 @@ -using System.Text.Json; -using Bit.Core.AdminConsole.Models.Data.EventIntegrations; -using Bit.Core.AdminConsole.Utilities; -using Bit.Core.Enums; -using Bit.Core.Models.Data; -using Bit.Core.Repositories; -using Microsoft.Extensions.Logging; - -namespace Bit.Core.Services; - -public class EventIntegrationHandler( - IntegrationType integrationType, - IEventIntegrationPublisher eventIntegrationPublisher, - IIntegrationFilterService integrationFilterService, - IIntegrationConfigurationDetailsCache configurationCache, - IUserRepository userRepository, - IOrganizationRepository organizationRepository, - ILogger> logger) - : IEventMessageHandler -{ - public async Task HandleEventAsync(EventMessage eventMessage) - { - if (eventMessage.OrganizationId is not Guid organizationId) - { - return; - } - - var configurations = configurationCache.GetConfigurationDetails( - organizationId, - integrationType, - eventMessage.Type); - - foreach (var configuration in configurations) - { - try - { - if (configuration.Filters is string filterJson) - { - // Evaluate filters - if false, then discard and do not process - var filters = JsonSerializer.Deserialize(filterJson) - ?? throw new InvalidOperationException($"Failed to deserialize Filters to FilterGroup"); - if (!integrationFilterService.EvaluateFilterGroup(filters, eventMessage)) - { - continue; - } - } - - // Valid filter - assemble message and publish to Integration topic/exchange - var template = configuration.Template ?? string.Empty; - var context = await BuildContextAsync(eventMessage, template); - var renderedTemplate = IntegrationTemplateProcessor.ReplaceTokens(template, context); - var messageId = eventMessage.IdempotencyId ?? Guid.NewGuid(); - var config = configuration.MergedConfiguration.Deserialize() - ?? throw new InvalidOperationException($"Failed to deserialize to {typeof(T).Name} - bad Configuration"); - - var message = new IntegrationMessage - { - IntegrationType = integrationType, - MessageId = messageId.ToString(), - OrganizationId = organizationId.ToString(), - Configuration = config, - RenderedTemplate = renderedTemplate, - RetryCount = 0, - DelayUntilDate = null - }; - - await eventIntegrationPublisher.PublishAsync(message); - } - catch (Exception exception) - { - logger.LogError( - exception, - "Failed to publish Integration Message for {Type}, check Id {RecordId} for error in Configuration or Filters", - typeof(T).Name, - configuration.Id); - } - } - } - - public async Task HandleManyEventsAsync(IEnumerable eventMessages) - { - foreach (var eventMessage in eventMessages) - { - await HandleEventAsync(eventMessage); - } - } - - private async Task BuildContextAsync(EventMessage eventMessage, string template) - { - var context = new IntegrationTemplateContext(eventMessage); - - if (IntegrationTemplateProcessor.TemplateRequiresUser(template) && eventMessage.UserId.HasValue) - { - context.User = await userRepository.GetByIdAsync(eventMessage.UserId.Value); - } - - if (IntegrationTemplateProcessor.TemplateRequiresActingUser(template) && eventMessage.ActingUserId.HasValue) - { - context.ActingUser = await userRepository.GetByIdAsync(eventMessage.ActingUserId.Value); - } - - if (IntegrationTemplateProcessor.TemplateRequiresOrganization(template) && eventMessage.OrganizationId.HasValue) - { - context.Organization = await organizationRepository.GetByIdAsync(eventMessage.OrganizationId.Value); - } - - return context; - } -} diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/IntegrationConfigurationDetailsCacheService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/IntegrationConfigurationDetailsCacheService.cs deleted file mode 100644 index a63efac62f..0000000000 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/IntegrationConfigurationDetailsCacheService.cs +++ /dev/null @@ -1,83 +0,0 @@ -using System.Diagnostics; -using Bit.Core.Enums; -using Bit.Core.Models.Data.Organizations; -using Bit.Core.Repositories; -using Bit.Core.Settings; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; - -namespace Bit.Core.Services; - -public class IntegrationConfigurationDetailsCacheService : BackgroundService, IIntegrationConfigurationDetailsCache -{ - private readonly record struct IntegrationCacheKey(Guid OrganizationId, IntegrationType IntegrationType, EventType? EventType); - private readonly IOrganizationIntegrationConfigurationRepository _repository; - private readonly ILogger _logger; - private readonly TimeSpan _refreshInterval; - private Dictionary> _cache = new(); - - public IntegrationConfigurationDetailsCacheService( - IOrganizationIntegrationConfigurationRepository repository, - GlobalSettings globalSettings, - ILogger logger) - { - _repository = repository; - _logger = logger; - _refreshInterval = TimeSpan.FromMinutes(globalSettings.EventLogging.IntegrationCacheRefreshIntervalMinutes); - } - - public List GetConfigurationDetails( - Guid organizationId, - IntegrationType integrationType, - EventType eventType) - { - var specificKey = new IntegrationCacheKey(organizationId, integrationType, eventType); - var allEventsKey = new IntegrationCacheKey(organizationId, integrationType, null); - - var results = new List(); - - if (_cache.TryGetValue(specificKey, out var specificConfigs)) - { - results.AddRange(specificConfigs); - } - if (_cache.TryGetValue(allEventsKey, out var fallbackConfigs)) - { - results.AddRange(fallbackConfigs); - } - - return results; - } - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - await RefreshAsync(); - - var timer = new PeriodicTimer(_refreshInterval); - while (await timer.WaitForNextTickAsync(stoppingToken)) - { - await RefreshAsync(); - } - } - - internal async Task RefreshAsync() - { - var stopwatch = Stopwatch.StartNew(); - try - { - var newCache = (await _repository.GetAllConfigurationDetailsAsync()) - .GroupBy(x => new IntegrationCacheKey(x.OrganizationId, x.IntegrationType, x.EventType)) - .ToDictionary(g => g.Key, g => g.ToList()); - _cache = newCache; - - stopwatch.Stop(); - _logger.LogInformation( - "[IntegrationConfigurationDetailsCacheService] Refreshed successfully: {Count} entries in {Duration}ms", - newCache.Count, - stopwatch.Elapsed.TotalMilliseconds); - } - catch (Exception ex) - { - _logger.LogError("[IntegrationConfigurationDetailsCacheService] Refresh failed: {ex}", ex); - } - } -} diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackIntegrationHandler.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackIntegrationHandler.cs deleted file mode 100644 index 16c756c8c4..0000000000 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackIntegrationHandler.cs +++ /dev/null @@ -1,48 +0,0 @@ -using Bit.Core.AdminConsole.Models.Data.EventIntegrations; - -namespace Bit.Core.Services; - -public class SlackIntegrationHandler( - ISlackService slackService) - : IntegrationHandlerBase -{ - private static readonly HashSet _retryableErrors = new(StringComparer.Ordinal) - { - "internal_error", - "message_limit_exceeded", - "rate_limited", - "ratelimited", - "service_unavailable" - }; - - public override async Task HandleAsync(IntegrationMessage message) - { - var slackResponse = await slackService.SendSlackMessageByChannelIdAsync( - message.Configuration.Token, - message.RenderedTemplate, - message.Configuration.ChannelId - ); - - if (slackResponse is null) - { - return new IntegrationHandlerResult(success: false, message: message) - { - FailureReason = "Slack response was null" - }; - } - - if (slackResponse.Ok) - { - return new IntegrationHandlerResult(success: true, message: message); - } - - var result = new IntegrationHandlerResult(success: false, message: message) { FailureReason = slackResponse.Error }; - - if (_retryableErrors.Contains(slackResponse.Error)) - { - result.Retryable = true; - } - - return result; - } -} diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/TeamsIntegrationHandler.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/TeamsIntegrationHandler.cs deleted file mode 100644 index 41d60bd69c..0000000000 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/TeamsIntegrationHandler.cs +++ /dev/null @@ -1,41 +0,0 @@ -using Bit.Core.AdminConsole.Models.Data.EventIntegrations; -using Microsoft.Rest; - -namespace Bit.Core.Services; - -public class TeamsIntegrationHandler( - ITeamsService teamsService) - : IntegrationHandlerBase -{ - public override async Task HandleAsync( - IntegrationMessage message) - { - try - { - await teamsService.SendMessageToChannelAsync( - serviceUri: message.Configuration.ServiceUrl, - message: message.RenderedTemplate, - channelId: message.Configuration.ChannelId - ); - - return new IntegrationHandlerResult(success: true, message: message); - } - catch (HttpOperationException ex) - { - var result = new IntegrationHandlerResult(success: false, message: message); - var statusCode = (int)ex.Response.StatusCode; - result.Retryable = statusCode is 429 or >= 500 and < 600; - result.FailureReason = ex.Message; - - return result; - } - catch (Exception ex) - { - var result = new IntegrationHandlerResult(success: false, message: message); - result.Retryable = false; - result.FailureReason = ex.Message; - - return result; - } - } -} diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index f18ecf341b..e1fcbb970d 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -21,6 +21,7 @@ using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Services; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; @@ -47,7 +48,7 @@ public class OrganizationService : IOrganizationService private readonly IPushNotificationService _pushNotificationService; private readonly IEventService _eventService; private readonly IApplicationCacheService _applicationCacheService; - private readonly IPaymentService _paymentService; + private readonly IStripePaymentService _paymentService; private readonly IPolicyRepository _policyRepository; private readonly IPolicyService _policyService; private readonly ISsoUserRepository _ssoUserRepository; @@ -74,7 +75,7 @@ public class OrganizationService : IOrganizationService IPushNotificationService pushNotificationService, IEventService eventService, IApplicationCacheService applicationCacheService, - IPaymentService paymentService, + IStripePaymentService paymentService, IPolicyRepository policyRepository, IPolicyService policyService, ISsoUserRepository ssoUserRepository, @@ -358,7 +359,7 @@ public class OrganizationService : IOrganizationService { var newDisplayName = organization.DisplayName(); - await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, + await _stripeAdapter.UpdateCustomerAsync(organization.GatewayCustomerId, new CustomerUpdateOptions { Email = organization.BillingEmail, diff --git a/src/Core/AdminConsole/Services/OrganizationFactory.cs b/src/Core/AdminConsole/Services/OrganizationFactory.cs index f5df3327b1..fadafd179c 100644 --- a/src/Core/AdminConsole/Services/OrganizationFactory.cs +++ b/src/Core/AdminConsole/Services/OrganizationFactory.cs @@ -62,6 +62,9 @@ public static class OrganizationFactory UseAdminSponsoredFamilies = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseAdminSponsoredFamilies), UseAutomaticUserConfirmation = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseAutomaticUserConfirmation), + UseDisableSmAdsForUsers = + claimsPrincipal.GetValue(OrganizationLicenseConstants.UseDisableSmAdsForUsers), + UsePhishingBlocker = claimsPrincipal.GetValue(OrganizationLicenseConstants.UsePhishingBlocker), }; public static Organization Create( @@ -111,6 +114,8 @@ public static class OrganizationFactory UseRiskInsights = license.UseRiskInsights, UseOrganizationDomains = license.UseOrganizationDomains, UseAdminSponsoredFamilies = license.UseAdminSponsoredFamilies, - UseAutomaticUserConfirmation = license.UseAutomaticUserConfirmation + UseAutomaticUserConfirmation = license.UseAutomaticUserConfirmation, + UseDisableSmAdsForUsers = license.UseDisableSmAdsForUsers, + UsePhishingBlocker = license.UsePhishingBlocker, }; } diff --git a/src/Core/AdminConsole/Utilities/IntegrationTemplateProcessor.cs b/src/Core/AdminConsole/Utilities/IntegrationTemplateProcessor.cs index b561e58a86..7fc8013c15 100644 --- a/src/Core/AdminConsole/Utilities/IntegrationTemplateProcessor.cs +++ b/src/Core/AdminConsole/Utilities/IntegrationTemplateProcessor.cs @@ -1,6 +1,4 @@ -#nullable enable - -using System.Text.RegularExpressions; +using System.Text.RegularExpressions; namespace Bit.Core.AdminConsole.Utilities; @@ -26,7 +24,7 @@ public static partial class IntegrationTemplateProcessor return match.Value; // Return unknown keys as keys - i.e. #Key# } - return property?.GetValue(values)?.ToString() ?? ""; + return property.GetValue(values)?.ToString() ?? string.Empty; }); } @@ -38,7 +36,8 @@ public static partial class IntegrationTemplateProcessor } return template.Contains("#UserName#", StringComparison.Ordinal) - || template.Contains("#UserEmail#", StringComparison.Ordinal); + || template.Contains("#UserEmail#", StringComparison.Ordinal) + || template.Contains("#UserType#", StringComparison.Ordinal); } public static bool TemplateRequiresActingUser(string template) @@ -49,7 +48,18 @@ public static partial class IntegrationTemplateProcessor } return template.Contains("#ActingUserName#", StringComparison.Ordinal) - || template.Contains("#ActingUserEmail#", StringComparison.Ordinal); + || template.Contains("#ActingUserEmail#", StringComparison.Ordinal) + || template.Contains("#ActingUserType#", StringComparison.Ordinal); + } + + public static bool TemplateRequiresGroup(string template) + { + if (string.IsNullOrEmpty(template)) + { + return false; + } + + return template.Contains("#GroupName#", StringComparison.Ordinal); } public static bool TemplateRequiresOrganization(string template) diff --git a/src/Core/Auth/Attributes/MarketingInitiativeValidationAttribute.cs b/src/Core/Auth/Attributes/MarketingInitiativeValidationAttribute.cs new file mode 100644 index 0000000000..bcc4b851c0 --- /dev/null +++ b/src/Core/Auth/Attributes/MarketingInitiativeValidationAttribute.cs @@ -0,0 +1,29 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.Auth.Models.Api.Request.Accounts; + +namespace Bit.Core.Auth.Attributes; + +public class MarketingInitiativeValidationAttribute : ValidationAttribute +{ + private static readonly string[] _acceptedValues = [MarketingInitiativeConstants.Premium]; + + public MarketingInitiativeValidationAttribute() + { + ErrorMessage = $"Marketing initiative type must be one of: {string.Join(", ", _acceptedValues)}"; + } + + public override bool IsValid(object? value) + { + if (value == null) + { + return true; + } + + if (value is not string str) + { + return false; + } + + return _acceptedValues.Contains(str); + } +} diff --git a/src/Core/Auth/Entities/AuthRequest.cs b/src/Core/Auth/Entities/AuthRequest.cs index 2117c575c0..38dc0534c1 100644 --- a/src/Core/Auth/Entities/AuthRequest.cs +++ b/src/Core/Auth/Entities/AuthRequest.cs @@ -49,11 +49,9 @@ public class AuthRequest : ITableObject public bool IsExpired() { - // TODO: PM-24252 - consider using TimeProvider for better mocking in tests return GetExpirationDate() < DateTime.UtcNow; } - // TODO: PM-24252 - this probably belongs in a service. public bool IsValidForAuthentication(Guid userId, string password) { diff --git a/src/Core/Auth/Entities/EmergencyAccess.cs b/src/Core/Auth/Entities/EmergencyAccess.cs index d855126468..36aaf46a8c 100644 --- a/src/Core/Auth/Entities/EmergencyAccess.cs +++ b/src/Core/Auth/Entities/EmergencyAccess.cs @@ -18,7 +18,7 @@ public class EmergencyAccess : ITableObject public string KeyEncrypted { get; set; } public EmergencyAccessType Type { get; set; } public EmergencyAccessStatusType Status { get; set; } - public int WaitTimeDays { get; set; } + public short WaitTimeDays { get; set; } public DateTime? RecoveryInitiatedDate { get; set; } public DateTime? LastNotificationDate { get; set; } public DateTime CreationDate { get; set; } = DateTime.UtcNow; diff --git a/src/Core/Auth/Identity/Policies.cs b/src/Core/Auth/Identity/Policies.cs index b2d94b0a6e..698a890006 100644 --- a/src/Core/Auth/Identity/Policies.cs +++ b/src/Core/Auth/Identity/Policies.cs @@ -5,12 +5,94 @@ public static class Policies /// /// Policy for managing access to the Send feature. /// - public const string Send = "Send"; // [Authorize(Policy = Policies.Send)] - public const string Application = "Application"; // [Authorize(Policy = Policies.Application)] - public const string Web = "Web"; // [Authorize(Policy = Policies.Web)] - public const string Push = "Push"; // [Authorize(Policy = Policies.Push)] + /// + /// + /// Can be used with the Authorize attribute, for example: + /// + /// [Authorize(Policy = Policies.Send)] + /// + /// + /// + public const string Send = "Send"; + + /// + /// Policy to manage access to general API endpoints. + /// + /// + /// + /// Can be used with the Authorize attribute, for example: + /// + /// [Authorize(Policy = Policies.Application)] + /// + /// + /// + public const string Application = "Application"; + + /// + /// Policy to manage access to API endpoints intended for use by the Web Vault and browser extension only. + /// + /// + /// + /// Can be used with the Authorize attribute, for example: + /// + /// [Authorize(Policy = Policies.Web)] + /// + /// + /// + public const string Web = "Web"; + + /// + /// Policy to restrict access to API endpoints for the Push feature. + /// + /// + /// + /// Can be used with the Authorize attribute, for example: + /// + /// [Authorize(Policy = Policies.Push)] + /// + /// + /// + public const string Push = "Push"; + + // TODO: This is unused public const string Licensing = "Licensing"; // [Authorize(Policy = Policies.Licensing)] - public const string Organization = "Organization"; // [Authorize(Policy = Policies.Organization)] - public const string Installation = "Installation"; // [Authorize(Policy = Policies.Installation)] - public const string Secrets = "Secrets"; // [Authorize(Policy = Policies.Secrets)] + + /// + /// Policy to restrict access to API endpoints related to the Organization features. + /// + /// + /// + /// Can be used with the Authorize attribute, for example: + /// + /// [Authorize(Policy = Policies.Licensing)] + /// + /// + /// + public const string Organization = "Organization"; + + /// + /// Policy to restrict access to API endpoints related to the setting up new installations. + /// + /// + /// + /// Can be used with the Authorize attribute, for example: + /// + /// [Authorize(Policy = Policies.Installation)] + /// + /// + /// + public const string Installation = "Installation"; + + /// + /// Policy to restrict access to API endpoints for Secrets Manager features. + /// + /// + /// + /// Can be used with the Authorize attribute, for example: + /// + /// [Authorize(Policy = Policies.Secrets)] + /// + /// + /// + public const string Secrets = "Secrets"; } diff --git a/src/Core/Auth/LoginFeatures/LoginServiceCollectionExtensions.cs b/src/Core/Auth/LoginFeatures/LoginServiceCollectionExtensions.cs deleted file mode 100644 index f8caad448b..0000000000 --- a/src/Core/Auth/LoginFeatures/LoginServiceCollectionExtensions.cs +++ /dev/null @@ -1,14 +0,0 @@ -using Bit.Core.Auth.LoginFeatures.PasswordlessLogin; -using Bit.Core.Auth.LoginFeatures.PasswordlessLogin.Interfaces; -using Microsoft.Extensions.DependencyInjection; - -namespace Bit.Core.Auth.LoginFeatures; - -public static class LoginServiceCollectionExtensions -{ - public static void AddLoginServices(this IServiceCollection services) - { - services.AddScoped(); - } -} - diff --git a/src/Core/Auth/LoginFeatures/PasswordlessLogin/Interfaces/IVerifyAuthRequest.cs b/src/Core/Auth/LoginFeatures/PasswordlessLogin/Interfaces/IVerifyAuthRequest.cs deleted file mode 100644 index e5da1b06d8..0000000000 --- a/src/Core/Auth/LoginFeatures/PasswordlessLogin/Interfaces/IVerifyAuthRequest.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Bit.Core.Auth.LoginFeatures.PasswordlessLogin.Interfaces; - -public interface IVerifyAuthRequestCommand -{ - Task VerifyAuthRequestAsync(Guid authRequestId, string accessCode); -} diff --git a/src/Core/Auth/LoginFeatures/PasswordlessLogin/VerifyAuthRequest.cs b/src/Core/Auth/LoginFeatures/PasswordlessLogin/VerifyAuthRequest.cs deleted file mode 100644 index 7def7fea76..0000000000 --- a/src/Core/Auth/LoginFeatures/PasswordlessLogin/VerifyAuthRequest.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Bit.Core.Auth.LoginFeatures.PasswordlessLogin.Interfaces; -using Bit.Core.Repositories; -using Bit.Core.Utilities; - -namespace Bit.Core.Auth.LoginFeatures.PasswordlessLogin; - -public class VerifyAuthRequestCommand : IVerifyAuthRequestCommand -{ - private readonly IAuthRequestRepository _authRequestRepository; - - public VerifyAuthRequestCommand(IAuthRequestRepository authRequestRepository) - { - _authRequestRepository = authRequestRepository; - } - - public async Task VerifyAuthRequestAsync(Guid authRequestId, string accessCode) - { - var authRequest = await _authRequestRepository.GetByIdAsync(authRequestId); - if (authRequest == null || !CoreHelpers.FixedTimeEquals(authRequest.AccessCode, accessCode)) - { - return false; - } - return true; - } -} diff --git a/src/Core/Auth/Models/Api/Request/Accounts/KeysRequestModel.cs b/src/Core/Auth/Models/Api/Request/Accounts/KeysRequestModel.cs index f89b67f3c5..85ddef44ce 100644 --- a/src/Core/Auth/Models/Api/Request/Accounts/KeysRequestModel.cs +++ b/src/Core/Auth/Models/Api/Request/Accounts/KeysRequestModel.cs @@ -3,17 +3,22 @@ using System.ComponentModel.DataAnnotations; using Bit.Core.Entities; +using Bit.Core.KeyManagement.Models.Api.Request; using Bit.Core.Utilities; namespace Bit.Core.Auth.Models.Api.Request.Accounts; public class KeysRequestModel { + [Obsolete("Use AccountKeys.AccountPublicKey instead")] [Required] public string PublicKey { get; set; } + [Obsolete("Use AccountKeys.UserKeyEncryptedAccountPrivateKey instead")] [Required] public string EncryptedPrivateKey { get; set; } + public AccountKeysRequestModel AccountKeys { get; set; } + [Obsolete("Use SetAccountKeysForUserCommand instead")] public User ToUser(User existingUser) { if (string.IsNullOrWhiteSpace(PublicKey) || string.IsNullOrWhiteSpace(EncryptedPrivateKey)) diff --git a/src/Core/Auth/Models/Api/Request/Accounts/MarketingInitiativeConstants.cs b/src/Core/Auth/Models/Api/Request/Accounts/MarketingInitiativeConstants.cs new file mode 100644 index 0000000000..ab2d252dc8 --- /dev/null +++ b/src/Core/Auth/Models/Api/Request/Accounts/MarketingInitiativeConstants.cs @@ -0,0 +1,10 @@ +namespace Bit.Core.Auth.Models.Api.Request.Accounts; + +public static class MarketingInitiativeConstants +{ + /// + /// Indicates that the user began the registration process on a marketing page designed + /// to streamline users who intend to setup a premium subscription after registration. + /// + public const string Premium = "premium"; +} diff --git a/src/Core/Auth/Models/Api/Request/Accounts/RegisterSendVerificationEmailRequestModel.cs b/src/Core/Auth/Models/Api/Request/Accounts/RegisterSendVerificationEmailRequestModel.cs index 75a4da081a..638565ecfe 100644 --- a/src/Core/Auth/Models/Api/Request/Accounts/RegisterSendVerificationEmailRequestModel.cs +++ b/src/Core/Auth/Models/Api/Request/Accounts/RegisterSendVerificationEmailRequestModel.cs @@ -1,5 +1,6 @@ #nullable enable using System.ComponentModel.DataAnnotations; +using Bit.Core.Auth.Attributes; using Bit.Core.Utilities; namespace Bit.Core.Auth.Models.Api.Request.Accounts; @@ -11,4 +12,6 @@ public class RegisterSendVerificationEmailRequestModel [StringLength(256)] public required string Email { get; set; } public bool ReceiveMarketingEmails { get; set; } + [MarketingInitiativeValidation] + public string? FromMarketing { get; set; } } diff --git a/src/Core/Auth/Models/Data/SetInitialMasterPasswordDataModel.cs b/src/Core/Auth/Models/Data/SetInitialMasterPasswordDataModel.cs new file mode 100644 index 0000000000..82bcb3da5e --- /dev/null +++ b/src/Core/Auth/Models/Data/SetInitialMasterPasswordDataModel.cs @@ -0,0 +1,23 @@ +using Bit.Core.KeyManagement.Models.Data; + +namespace Bit.Core.Auth.Models.Data; + +/// +/// Data model for setting an initial master password for a user. +/// +public class SetInitialMasterPasswordDataModel +{ + public required MasterPasswordAuthenticationData MasterPasswordAuthentication { get; set; } + public required MasterPasswordUnlockData MasterPasswordUnlock { get; set; } + + /// + /// Organization SSO identifier. + /// + public required string OrgSsoIdentifier { get; set; } + + /// + /// User account keys. Required for Master Password decryption user. + /// + public required UserAccountKeysData? AccountKeys { get; set; } + public string? MasterPasswordHint { get; set; } +} diff --git a/src/Core/Auth/Models/ITwoFactorProvidersUser.cs b/src/Core/Auth/Models/ITwoFactorProvidersUser.cs index 5cf137b76f..816d460572 100644 --- a/src/Core/Auth/Models/ITwoFactorProvidersUser.cs +++ b/src/Core/Auth/Models/ITwoFactorProvidersUser.cs @@ -1,14 +1,14 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Enums; using Bit.Core.Services; namespace Bit.Core.Auth.Models; +/// +/// An interface representing a user entity that supports two-factor providers +/// public interface ITwoFactorProvidersUser { - string TwoFactorProviders { get; } + string? TwoFactorProviders { get; } /// /// Get the two factor providers for the user. Currently it can be assumed providers are enabled /// if they exists in the dictionary. When two factor providers are disabled they are removed @@ -16,7 +16,10 @@ public interface ITwoFactorProvidersUser /// /// /// Dictionary of providers with the type enum as the key - Dictionary GetTwoFactorProviders(); + Dictionary? GetTwoFactorProviders(); + /// + /// The unique `UserId` of the user entity for which there are two-factor providers configured. + /// + /// The unique identifier for the user Guid? GetUserId(); - bool GetPremium(); } diff --git a/src/Core/Auth/Models/Mail/RegisterVerifyEmail.cs b/src/Core/Auth/Models/Mail/RegisterVerifyEmail.cs index fe42093111..5c0efeb73f 100644 --- a/src/Core/Auth/Models/Mail/RegisterVerifyEmail.cs +++ b/src/Core/Auth/Models/Mail/RegisterVerifyEmail.cs @@ -15,11 +15,13 @@ public class RegisterVerifyEmail : BaseMailModel // so we must land on a redirect connector which will redirect to the finish signup page. // Note 3: The use of a fragment to indicate the redirect url is to prevent the query string from being logged by // proxies and servers. It also helps reduce open redirect vulnerabilities. - public string Url => string.Format("{0}/redirect-connector.html#finish-signup?token={1}&email={2}&fromEmail=true", + public string Url => string.Format("{0}/redirect-connector.html#finish-signup?token={1}&email={2}&fromEmail=true{3}", WebVaultUrl, Token, - Email); + Email, + !string.IsNullOrEmpty(FromMarketing) ? $"&fromMarketing={FromMarketing}" : string.Empty); public string Token { get; set; } public string Email { get; set; } + public string FromMarketing { get; set; } } diff --git a/src/Core/Auth/Services/Implementations/SsoConfigService.cs b/src/Core/Auth/Services/Implementations/SsoConfigService.cs index 1a35585b2c..0cb8b68042 100644 --- a/src/Core/Auth/Services/Implementations/SsoConfigService.cs +++ b/src/Core/Auth/Services/Implementations/SsoConfigService.cs @@ -5,7 +5,6 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; -using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; using Bit.Core.AdminConsole.Repositories; @@ -26,8 +25,6 @@ public class SsoConfigService : ISsoConfigService private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IEventService _eventService; - private readonly IFeatureService _featureService; - private readonly ISavePolicyCommand _savePolicyCommand; private readonly IVNextSavePolicyCommand _vNextSavePolicyCommand; public SsoConfigService( @@ -36,8 +33,6 @@ public class SsoConfigService : ISsoConfigService IOrganizationRepository organizationRepository, IOrganizationUserRepository organizationUserRepository, IEventService eventService, - IFeatureService featureService, - ISavePolicyCommand savePolicyCommand, IVNextSavePolicyCommand vNextSavePolicyCommand) { _ssoConfigRepository = ssoConfigRepository; @@ -45,8 +40,6 @@ public class SsoConfigService : ISsoConfigService _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; _eventService = eventService; - _featureService = featureService; - _savePolicyCommand = savePolicyCommand; _vNextSavePolicyCommand = vNextSavePolicyCommand; } @@ -97,19 +90,10 @@ public class SsoConfigService : ISsoConfigService Enabled = true }; - if (_featureService.IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor)) - { - var performedBy = new SystemUser(EventSystemUser.Unknown); - await _vNextSavePolicyCommand.SaveAsync(new SavePolicyModel(singleOrgPolicy, performedBy)); - await _vNextSavePolicyCommand.SaveAsync(new SavePolicyModel(resetPasswordPolicy, performedBy)); - await _vNextSavePolicyCommand.SaveAsync(new SavePolicyModel(requireSsoPolicy, performedBy)); - } - else - { - await _savePolicyCommand.SaveAsync(singleOrgPolicy); - await _savePolicyCommand.SaveAsync(resetPasswordPolicy); - await _savePolicyCommand.SaveAsync(requireSsoPolicy); - } + var performedBy = new SystemUser(EventSystemUser.Unknown); + await _vNextSavePolicyCommand.SaveAsync(new SavePolicyModel(singleOrgPolicy, performedBy)); + await _vNextSavePolicyCommand.SaveAsync(new SavePolicyModel(resetPasswordPolicy, performedBy)); + await _vNextSavePolicyCommand.SaveAsync(new SavePolicyModel(requireSsoPolicy, performedBy)); } await LogEventsAsync(config, oldConfig); diff --git a/src/Core/Auth/Sso/IUserSsoOrganizationIdentifierQuery.cs b/src/Core/Auth/Sso/IUserSsoOrganizationIdentifierQuery.cs new file mode 100644 index 0000000000..c932eb0c34 --- /dev/null +++ b/src/Core/Auth/Sso/IUserSsoOrganizationIdentifierQuery.cs @@ -0,0 +1,23 @@ +using Bit.Core.Entities; + +namespace Bit.Core.Auth.Sso; + +/// +/// Query to retrieve the SSO organization identifier that a user is a confirmed member of. +/// +public interface IUserSsoOrganizationIdentifierQuery +{ + /// + /// Retrieves the SSO organization identifier for a confirmed organization user. + /// If there is more than one organization a User is associated with, we return null. If there are more than one + /// organization there is no way to know which organization the user wishes to authenticate with. + /// Owners and Admins who are not subject to the SSO required policy cannot utilize this flow, since they may have + /// multiple organizations with different SSO configurations. + /// + /// The ID of the to retrieve the SSO organization for. _Not_ an . + /// + /// The organization identifier if the user is a confirmed member of an organization with SSO configured, + /// otherwise null + /// + Task GetSsoOrganizationIdentifierAsync(Guid userId); +} diff --git a/src/Core/Auth/Sso/UserSsoOrganizationIdentifierQuery.cs b/src/Core/Auth/Sso/UserSsoOrganizationIdentifierQuery.cs new file mode 100644 index 0000000000..c0751e1f1a --- /dev/null +++ b/src/Core/Auth/Sso/UserSsoOrganizationIdentifierQuery.cs @@ -0,0 +1,38 @@ +using Bit.Core.Enums; +using Bit.Core.Repositories; + +namespace Bit.Core.Auth.Sso; + +/// +/// TODO : PM-28846 review data structures as they relate to this query +/// Query to retrieve the SSO organization identifier that a user is a confirmed member of. +/// +public class UserSsoOrganizationIdentifierQuery( + IOrganizationUserRepository _organizationUserRepository, + IOrganizationRepository _organizationRepository) : IUserSsoOrganizationIdentifierQuery +{ + /// + public async Task GetSsoOrganizationIdentifierAsync(Guid userId) + { + // Get all confirmed organization memberships for the user + var organizationUsers = await _organizationUserRepository.GetManyByUserAsync(userId); + + // we can only confidently return the correct SsoOrganizationIdentifier if there is exactly one Organization. + // The user must also be in the Confirmed status. + var confirmedOrgUsers = organizationUsers.Where(ou => ou.Status == OrganizationUserStatusType.Confirmed); + if (confirmedOrgUsers.Count() != 1) + { + return null; + } + + var confirmedOrgUser = confirmedOrgUsers.Single(); + var organization = await _organizationRepository.GetByIdAsync(confirmedOrgUser.OrganizationId); + + if (organization == null) + { + return null; + } + + return organization.Identifier; + } +} diff --git a/src/Core/Auth/Services/EmergencyAccess/EmergencyAccessService.cs b/src/Core/Auth/UserFeatures/EmergencyAccess/EmergencyAccessService.cs similarity index 95% rename from src/Core/Auth/Services/EmergencyAccess/EmergencyAccessService.cs rename to src/Core/Auth/UserFeatures/EmergencyAccess/EmergencyAccessService.cs index 4331179554..6552f4bc69 100644 --- a/src/Core/Auth/Services/EmergencyAccess/EmergencyAccessService.cs +++ b/src/Core/Auth/UserFeatures/EmergencyAccess/EmergencyAccessService.cs @@ -4,7 +4,6 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.Repositories; -using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Models.Data; @@ -19,7 +18,7 @@ using Bit.Core.Vault.Models.Data; using Bit.Core.Vault.Repositories; using Bit.Core.Vault.Services; -namespace Bit.Core.Auth.Services; +namespace Bit.Core.Auth.UserFeatures.EmergencyAccess; public class EmergencyAccessService : IEmergencyAccessService { @@ -61,7 +60,7 @@ public class EmergencyAccessService : IEmergencyAccessService _removeOrganizationUserCommand = removeOrganizationUserCommand; } - public async Task InviteAsync(User grantorUser, string emergencyContactEmail, EmergencyAccessType accessType, int waitTime) + public async Task InviteAsync(User grantorUser, string emergencyContactEmail, EmergencyAccessType accessType, int waitTime) { if (!await _userService.CanAccessPremium(grantorUser)) { @@ -73,13 +72,13 @@ public class EmergencyAccessService : IEmergencyAccessService throw new BadRequestException("You cannot use Emergency Access Takeover because you are using Key Connector."); } - var emergencyAccess = new EmergencyAccess + var emergencyAccess = new Entities.EmergencyAccess { GrantorId = grantorUser.Id, Email = emergencyContactEmail.ToLowerInvariant(), Status = EmergencyAccessStatusType.Invited, Type = accessType, - WaitTimeDays = waitTime, + WaitTimeDays = (short)waitTime, CreationDate = DateTime.UtcNow, RevisionDate = DateTime.UtcNow, }; @@ -113,7 +112,7 @@ public class EmergencyAccessService : IEmergencyAccessService await SendInviteAsync(emergencyAccess, NameOrEmail(grantorUser)); } - public async Task AcceptUserAsync(Guid emergencyAccessId, User granteeUser, string token, IUserService userService) + public async Task AcceptUserAsync(Guid emergencyAccessId, User granteeUser, string token, IUserService userService) { var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId); if (emergencyAccess == null) @@ -175,7 +174,7 @@ public class EmergencyAccessService : IEmergencyAccessService await _emergencyAccessRepository.DeleteAsync(emergencyAccess); } - public async Task ConfirmUserAsync(Guid emergencyAccessId, string key, Guid grantorId) + public async Task ConfirmUserAsync(Guid emergencyAccessId, string key, Guid grantorId) { var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId); if (emergencyAccess == null || emergencyAccess.Status != EmergencyAccessStatusType.Accepted || @@ -201,7 +200,7 @@ public class EmergencyAccessService : IEmergencyAccessService return emergencyAccess; } - public async Task SaveAsync(EmergencyAccess emergencyAccess, User grantorUser) + public async Task SaveAsync(Entities.EmergencyAccess emergencyAccess, User grantorUser) { if (!await _userService.CanAccessPremium(grantorUser)) { @@ -311,7 +310,7 @@ public class EmergencyAccessService : IEmergencyAccessService } // TODO PM-21687: rename this to something like InitiateRecoveryTakeoverAsync - public async Task<(EmergencyAccess, User)> TakeoverAsync(Guid emergencyAccessId, User granteeUser) + public async Task<(Entities.EmergencyAccess, User)> TakeoverAsync(Guid emergencyAccessId, User granteeUser) { var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId); @@ -429,7 +428,7 @@ public class EmergencyAccessService : IEmergencyAccessService return await _cipherService.GetAttachmentDownloadDataAsync(cipher, attachmentId); } - private async Task SendInviteAsync(EmergencyAccess emergencyAccess, string invitingUsersName) + private async Task SendInviteAsync(Entities.EmergencyAccess emergencyAccess, string invitingUsersName) { var token = _dataProtectorTokenizer.Protect(new EmergencyAccessInviteTokenable(emergencyAccess, _globalSettings.OrganizationInviteExpirationHours)); await _mailService.SendEmergencyAccessInviteEmailAsync(emergencyAccess, invitingUsersName, token); @@ -449,7 +448,7 @@ public class EmergencyAccessService : IEmergencyAccessService */ //TODO PM-21687: this IsValidRequest() checks the validity based on the granteeUser. There should be a complementary method for the grantorUser private static bool IsValidRequest( - EmergencyAccess availableAccess, + Entities.EmergencyAccess availableAccess, User requestingUser, EmergencyAccessType requestedAccessType) { diff --git a/src/Core/Auth/Services/EmergencyAccess/IEmergencyAccessService.cs b/src/Core/Auth/UserFeatures/EmergencyAccess/IEmergencyAccessService.cs similarity index 93% rename from src/Core/Auth/Services/EmergencyAccess/IEmergencyAccessService.cs rename to src/Core/Auth/UserFeatures/EmergencyAccess/IEmergencyAccessService.cs index de695bbd7d..860ae8bfb6 100644 --- a/src/Core/Auth/Services/EmergencyAccess/IEmergencyAccessService.cs +++ b/src/Core/Auth/UserFeatures/EmergencyAccess/IEmergencyAccessService.cs @@ -1,5 +1,4 @@ using Bit.Core.AdminConsole.Entities; -using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models.Data; using Bit.Core.Entities; @@ -7,7 +6,7 @@ using Bit.Core.Enums; using Bit.Core.Services; using Bit.Core.Vault.Models.Data; -namespace Bit.Core.Auth.Services; +namespace Bit.Core.Auth.UserFeatures.EmergencyAccess; public interface IEmergencyAccessService { @@ -20,7 +19,7 @@ public interface IEmergencyAccessService /// Type of emergency access allowed to the emergency contact /// The amount of time to pass before the invite is auto confirmed /// a new Emergency Access object - Task InviteAsync(User grantorUser, string emergencyContactEmail, EmergencyAccessType accessType, int waitTime); + Task InviteAsync(User grantorUser, string emergencyContactEmail, EmergencyAccessType accessType, int waitTime); /// /// Sends an invite to the emergency contact associated with the emergency access id. /// @@ -37,7 +36,7 @@ public interface IEmergencyAccessService /// the tokenable that was sent via email /// service dependency /// void - Task AcceptUserAsync(Guid emergencyAccessId, User granteeUser, string token, IUserService userService); + Task AcceptUserAsync(Guid emergencyAccessId, User granteeUser, string token, IUserService userService); /// /// The creator of the emergency access request can delete the request. /// @@ -53,7 +52,7 @@ public interface IEmergencyAccessService /// The grantor user key encrypted by the grantee public key; grantee.PubicKey(grantor.User.Key) /// Id of grantor user /// emergency access object associated with the Id passed in - Task ConfirmUserAsync(Guid emergencyAccessId, string key, Guid grantorId); + Task ConfirmUserAsync(Guid emergencyAccessId, string key, Guid grantorId); /// /// Fetches an emergency access object. The grantor user must own the object being fetched. /// @@ -67,7 +66,7 @@ public interface IEmergencyAccessService /// emergency access entity being updated /// grantor user /// void - Task SaveAsync(EmergencyAccess emergencyAccess, User grantorUser); + Task SaveAsync(Entities.EmergencyAccess emergencyAccess, User grantorUser); /// /// Initiates the recovery process. For either Takeover or view. Will send an email to the Grantor User notifying of the initiation. /// @@ -107,7 +106,7 @@ public interface IEmergencyAccessService /// Id of entity being accessed /// grantee user of the emergency access entity /// emergency access entity and the grantorUser - Task<(EmergencyAccess, User)> TakeoverAsync(Guid emergencyAccessId, User granteeUser); + Task<(Entities.EmergencyAccess, User)> TakeoverAsync(Guid emergencyAccessId, User granteeUser); /// /// Updates the grantor's password hash and updates the key for the EmergencyAccess entity. /// diff --git a/src/Core/Auth/UserFeatures/EmergencyAccess/Mail/EmergencyAccessRemoveGranteesMailView.cs b/src/Core/Auth/UserFeatures/EmergencyAccess/Mail/EmergencyAccessRemoveGranteesMailView.cs new file mode 100644 index 0000000000..4d60556785 --- /dev/null +++ b/src/Core/Auth/UserFeatures/EmergencyAccess/Mail/EmergencyAccessRemoveGranteesMailView.cs @@ -0,0 +1,14 @@ +using Bit.Core.Platform.Mail.Mailer; + +namespace Bit.Core.Auth.UserFeatures.EmergencyAccess.Mail; + +public class EmergencyAccessRemoveGranteesMailView : BaseMailView +{ + public required IEnumerable RemovedGranteeNames { get; set; } + public string EmergencyAccessHelpPageUrl => "https://bitwarden.com/help/emergency-access/"; +} + +public class EmergencyAccessRemoveGranteesMail : BaseMail +{ + public override string Subject { get; set; } = "Emergency contacts removed"; +} diff --git a/src/Core/Auth/UserFeatures/EmergencyAccess/Mail/EmergencyAccessRemoveGranteesMailView.html.hbs b/src/Core/Auth/UserFeatures/EmergencyAccess/Mail/EmergencyAccessRemoveGranteesMailView.html.hbs new file mode 100644 index 0000000000..405f2744bd --- /dev/null +++ b/src/Core/Auth/UserFeatures/EmergencyAccess/Mail/EmergencyAccessRemoveGranteesMailView.html.hbs @@ -0,0 +1,499 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    + + + + + + + +
    + + + + + + + + +
    + + + + + +
    + + + + + + + +
    + + +
    + + + + + + + + + + + + + +
    + + + + + + + +
    + + + +
    + +
    + +

    + +

    + +
    + +
    + + + +
    + + + + + + + + + +
    + + + + + + + +
    + + + +
    + +
    + +
    + + +
    + +
    + + + + + +
    + + +
    + +
    + + + + + + + + + +
    + + + + + + + +
    + + + +
    + + + + + + + +
    + + +
    + + + + + + + + + +
    + +
    The following emergency contacts have been removed from your account: +
      + {{#each RemovedGranteeNames}} +
    • {{this}}
    • + {{/each}} +
    + Learn more about emergency access.
    + +
    + +
    + + +
    + +
    + + + +
    + +
    + + + + + + + + + +
    + + + + + + + +
    + + +
    + + + + + + + + + + + + + +
    + + + + + + + + + + + + +
    + + + + + + +
    + + + +
    +
    + + + + + + + + + + +
    + + + + + + +
    + + + +
    +
    + + + + + + + + + + +
    + + + + + + +
    + + + +
    +
    + + + + + + + + + + +
    + + + + + + +
    + + + +
    +
    + + + + + + + + + + +
    + + + + + + +
    + + + +
    +
    + + + + + + + + + + +
    + + + + + + +
    + + + +
    +
    + + + + + + + + + + +
    + + + + + + +
    + + + +
    +
    + + + +
    + +

    + © 2025 Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa + Barbara, CA, USA +

    +

    + Always confirm you are on a trusted Bitwarden domain before logging + in:
    + bitwarden.com | + Learn why we include this +

    + +
    + +
    + + +
    + +
    + + + + + +
    + + + + \ No newline at end of file diff --git a/src/Core/Auth/UserFeatures/EmergencyAccess/Mail/EmergencyAccessRemoveGranteesMailView.text.hbs b/src/Core/Auth/UserFeatures/EmergencyAccess/Mail/EmergencyAccessRemoveGranteesMailView.text.hbs new file mode 100644 index 0000000000..3c17274f35 --- /dev/null +++ b/src/Core/Auth/UserFeatures/EmergencyAccess/Mail/EmergencyAccessRemoveGranteesMailView.text.hbs @@ -0,0 +1,7 @@ +The following emergency contacts have been removed from your account: + +{{#each RemovedGranteeNames}} + {{this}} +{{/each}} + +Learn more about emergency access at {{EmergencyAccessHelpPageUrl}} diff --git a/src/Core/Auth/Services/EmergencyAccess/readme.md b/src/Core/Auth/UserFeatures/EmergencyAccess/readme.md similarity index 100% rename from src/Core/Auth/Services/EmergencyAccess/readme.md rename to src/Core/Auth/UserFeatures/EmergencyAccess/readme.md diff --git a/src/Core/Auth/UserFeatures/Registration/ISendVerificationEmailForRegistrationCommand.cs b/src/Core/Auth/UserFeatures/Registration/ISendVerificationEmailForRegistrationCommand.cs index b623b8cab3..2a224b9eb9 100644 --- a/src/Core/Auth/UserFeatures/Registration/ISendVerificationEmailForRegistrationCommand.cs +++ b/src/Core/Auth/UserFeatures/Registration/ISendVerificationEmailForRegistrationCommand.cs @@ -3,5 +3,5 @@ namespace Bit.Core.Auth.UserFeatures.Registration; public interface ISendVerificationEmailForRegistrationCommand { - public Task Run(string email, string? name, bool receiveMarketingEmails); + public Task Run(string email, string? name, bool receiveMarketingEmails, string? fromMarketing); } diff --git a/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs b/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs index 4aaa9360a0..4a0e9c2cf5 100644 --- a/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs +++ b/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs @@ -5,6 +5,7 @@ using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Extensions; using Bit.Core.Entities; using Bit.Core.Exceptions; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; @@ -15,16 +16,20 @@ using Bit.Core.Tokens; using Bit.Core.Utilities; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; using Newtonsoft.Json; namespace Bit.Core.Auth.UserFeatures.Registration.Implementations; public class RegisterUserCommand : IRegisterUserCommand { + private readonly ILogger _logger; private readonly IGlobalSettings _globalSettings; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IOrganizationRepository _organizationRepository; private readonly IPolicyRepository _policyRepository; + private readonly IOrganizationDomainRepository _organizationDomainRepository; + private readonly IFeatureService _featureService; private readonly IDataProtectorTokenFactory _orgUserInviteTokenDataFactory; private readonly IDataProtectorTokenFactory _registrationEmailVerificationTokenDataFactory; @@ -37,28 +42,32 @@ public class RegisterUserCommand : IRegisterUserCommand private readonly IValidateRedemptionTokenCommand _validateRedemptionTokenCommand; private readonly IDataProtectorTokenFactory _emergencyAccessInviteTokenDataFactory; - private readonly IFeatureService _featureService; private readonly string _disabledUserRegistrationExceptionMsg = "Open registration has been disabled by the system administrator."; public RegisterUserCommand( + ILogger logger, IGlobalSettings globalSettings, IOrganizationUserRepository organizationUserRepository, IOrganizationRepository organizationRepository, IPolicyRepository policyRepository, + IOrganizationDomainRepository organizationDomainRepository, + IFeatureService featureService, IDataProtectionProvider dataProtectionProvider, IDataProtectorTokenFactory orgUserInviteTokenDataFactory, IDataProtectorTokenFactory registrationEmailVerificationTokenDataFactory, IUserService userService, IMailService mailService, IValidateRedemptionTokenCommand validateRedemptionTokenCommand, - IDataProtectorTokenFactory emergencyAccessInviteTokenDataFactory, - IFeatureService featureService) + IDataProtectorTokenFactory emergencyAccessInviteTokenDataFactory) { + _logger = logger; _globalSettings = globalSettings; _organizationUserRepository = organizationUserRepository; _organizationRepository = organizationRepository; _policyRepository = policyRepository; + _organizationDomainRepository = organizationDomainRepository; + _featureService = featureService; _organizationServiceDataProtector = dataProtectionProvider.CreateProtector( "OrganizationServiceDataProtector"); @@ -77,6 +86,8 @@ public class RegisterUserCommand : IRegisterUserCommand public async Task RegisterUser(User user) { + await ValidateEmailDomainNotBlockedAsync(user.Email); + var result = await _userService.CreateUserAsync(user); if (result == IdentityResult.Success) { @@ -88,6 +99,9 @@ public class RegisterUserCommand : IRegisterUserCommand public async Task RegisterSSOAutoProvisionedUserAsync(User user, Organization organization) { + // Validate that the email domain is not blocked by another organization's policy + await ValidateEmailDomainNotBlockedAsync(user.Email, organization.Id); + var result = await _userService.CreateUserAsync(user); if (result == IdentityResult.Success) { @@ -102,6 +116,11 @@ public class RegisterUserCommand : IRegisterUserCommand { TryValidateOrgInviteToken(orgInviteToken, orgUserId, user); var orgUser = await SetUserEmail2FaIfOrgPolicyEnabledAsync(orgUserId, user); + if (orgUser == null && orgUserId.HasValue) + { + throw new BadRequestException("Invalid organization user invitation."); + } + await ValidateEmailDomainNotBlockedAsync(user.Email, orgUser?.OrganizationId); user.ApiKey = CoreHelpers.SecureRandomString(30); @@ -265,6 +284,8 @@ public class RegisterUserCommand : IRegisterUserCommand string emailVerificationToken) { ValidateOpenRegistrationAllowed(); + await ValidateEmailDomainNotBlockedAsync(user.Email); + var tokenable = ValidateRegistrationEmailVerificationTokenable(emailVerificationToken, user.Email); user.EmailVerified = true; @@ -284,6 +305,7 @@ public class RegisterUserCommand : IRegisterUserCommand string orgSponsoredFreeFamilyPlanInviteToken) { ValidateOpenRegistrationAllowed(); + await ValidateEmailDomainNotBlockedAsync(user.Email); await ValidateOrgSponsoredFreeFamilyPlanInviteToken(orgSponsoredFreeFamilyPlanInviteToken, user.Email); user.EmailVerified = true; @@ -304,6 +326,7 @@ public class RegisterUserCommand : IRegisterUserCommand string acceptEmergencyAccessInviteToken, Guid acceptEmergencyAccessId) { ValidateOpenRegistrationAllowed(); + await ValidateEmailDomainNotBlockedAsync(user.Email); ValidateAcceptEmergencyAccessInviteToken(acceptEmergencyAccessInviteToken, acceptEmergencyAccessId, user.Email); user.EmailVerified = true; @@ -322,6 +345,7 @@ public class RegisterUserCommand : IRegisterUserCommand string providerInviteToken, Guid providerUserId) { ValidateOpenRegistrationAllowed(); + await ValidateEmailDomainNotBlockedAsync(user.Email); ValidateProviderInviteToken(providerInviteToken, providerUserId, user.Email); user.EmailVerified = true; @@ -387,6 +411,28 @@ public class RegisterUserCommand : IRegisterUserCommand return tokenable; } + private async Task ValidateEmailDomainNotBlockedAsync(string email, Guid? excludeOrganizationId = null) + { + // Only check if feature flag is enabled + if (!_featureService.IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation)) + { + return; + } + + var emailDomain = EmailValidation.GetDomain(email); + + var isDomainBlocked = await _organizationDomainRepository.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync( + emailDomain, excludeOrganizationId); + if (isDomainBlocked) + { + _logger.LogInformation( + "User registration blocked by domain claim policy. Domain: {Domain}, ExcludedOrgId: {ExcludedOrgId}", + emailDomain, + excludeOrganizationId); + throw new BadRequestException("This email address is claimed by an organization using Bitwarden."); + } + } + /// /// We send different welcome emails depending on whether the user is joining a free/family or an enterprise organization. If information to populate the /// email isn't present we send the standard individual welcome email. @@ -413,9 +459,7 @@ public class RegisterUserCommand : IRegisterUserCommand else if (!string.IsNullOrEmpty(organization.DisplayName())) { // If the organization is Free or Families plan, send families welcome email - if (organization.PlanType is PlanType.FamiliesAnnually - or PlanType.FamiliesAnnually2019 - or PlanType.Free) + if (organization.PlanType.GetProductTier() is ProductTierType.Free or ProductTierType.Families) { await _mailService.SendFreeOrgOrFamilyOrgUserWelcomeEmailAsync(user, organization.DisplayName()); } diff --git a/src/Core/Auth/UserFeatures/Registration/Implementations/SendVerificationEmailForRegistrationCommand.cs b/src/Core/Auth/UserFeatures/Registration/Implementations/SendVerificationEmailForRegistrationCommand.cs index 3f89e9ad0e..2e8587eee6 100644 --- a/src/Core/Auth/UserFeatures/Registration/Implementations/SendVerificationEmailForRegistrationCommand.cs +++ b/src/Core/Auth/UserFeatures/Registration/Implementations/SendVerificationEmailForRegistrationCommand.cs @@ -5,6 +5,8 @@ using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Tokens; +using Bit.Core.Utilities; +using Microsoft.Extensions.Logging; namespace Bit.Core.Auth.UserFeatures.Registration.Implementations; @@ -15,29 +17,34 @@ namespace Bit.Core.Auth.UserFeatures.Registration.Implementations; /// public class SendVerificationEmailForRegistrationCommand : ISendVerificationEmailForRegistrationCommand { - + private readonly ILogger _logger; private readonly IUserRepository _userRepository; private readonly GlobalSettings _globalSettings; private readonly IMailService _mailService; private readonly IDataProtectorTokenFactory _tokenDataFactory; private readonly IFeatureService _featureService; + private readonly IOrganizationDomainRepository _organizationDomainRepository; public SendVerificationEmailForRegistrationCommand( + ILogger logger, IUserRepository userRepository, GlobalSettings globalSettings, IMailService mailService, IDataProtectorTokenFactory tokenDataFactory, - IFeatureService featureService) + IFeatureService featureService, + IOrganizationDomainRepository organizationDomainRepository) { + _logger = logger; _userRepository = userRepository; _globalSettings = globalSettings; _mailService = mailService; _tokenDataFactory = tokenDataFactory; _featureService = featureService; + _organizationDomainRepository = organizationDomainRepository; } - public async Task Run(string email, string? name, bool receiveMarketingEmails) + public async Task Run(string email, string? name, bool receiveMarketingEmails, string? fromMarketing) { if (_globalSettings.DisableUserRegistration) { @@ -49,6 +56,20 @@ public class SendVerificationEmailForRegistrationCommand : ISendVerificationEmai throw new ArgumentNullException(nameof(email)); } + // Check if the email domain is blocked by an organization policy + if (_featureService.IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation)) + { + var emailDomain = EmailValidation.GetDomain(email); + + if (await _organizationDomainRepository.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(emailDomain)) + { + _logger.LogInformation( + "User registration email verification blocked by domain claim policy. Domain: {Domain}", + emailDomain); + throw new BadRequestException("This email address is claimed by an organization using Bitwarden."); + } + } + // Check to see if the user already exists var user = await _userRepository.GetByEmailAsync(email); var userExists = user != null; @@ -71,7 +92,7 @@ public class SendVerificationEmailForRegistrationCommand : ISendVerificationEmai // If the user doesn't exist, create a new EmailVerificationTokenable and send the user // an email with a link to verify their email address var token = GenerateToken(email, name, receiveMarketingEmails); - await _mailService.SendRegistrationVerificationEmailAsync(email, token); + await _mailService.SendRegistrationVerificationEmailAsync(email, token, fromMarketing); } // User exists but we will return a 200 regardless of whether the email was sent or not; so return null diff --git a/src/Core/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs b/src/Core/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs index cc86d3d71d..e6c0c1444a 100644 --- a/src/Core/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs +++ b/src/Core/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs @@ -4,16 +4,37 @@ using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; +using Bit.Core.Billing.Premium.Queries; +using Bit.Core.Entities; +using Bit.Core.Exceptions; using Bit.Core.Repositories; +using Bit.Core.Services; namespace Bit.Core.Auth.UserFeatures.TwoFactorAuth; -public class TwoFactorIsEnabledQuery(IUserRepository userRepository) : ITwoFactorIsEnabledQuery +public class TwoFactorIsEnabledQuery : ITwoFactorIsEnabledQuery { - private readonly IUserRepository _userRepository = userRepository; + private readonly IUserRepository _userRepository; + private readonly IHasPremiumAccessQuery _hasPremiumAccessQuery; + private readonly IFeatureService _featureService; + + public TwoFactorIsEnabledQuery( + IUserRepository userRepository, + IHasPremiumAccessQuery hasPremiumAccessQuery, + IFeatureService featureService) + { + _userRepository = userRepository; + _hasPremiumAccessQuery = hasPremiumAccessQuery; + _featureService = featureService; + } public async Task> TwoFactorIsEnabledAsync(IEnumerable userIds) { + if (_featureService.IsEnabled(FeatureFlagKeys.PremiumAccessQuery)) + { + return await TwoFactorIsEnabledVNextAsync(userIds); + } + var result = new List<(Guid userId, bool hasTwoFactor)>(); if (userIds == null || !userIds.Any()) { @@ -36,6 +57,11 @@ public class TwoFactorIsEnabledQuery(IUserRepository userRepository) : ITwoFacto public async Task> TwoFactorIsEnabledAsync(IEnumerable users) where T : ITwoFactorProvidersUser { + if (_featureService.IsEnabled(FeatureFlagKeys.PremiumAccessQuery)) + { + return await TwoFactorIsEnabledVNextAsync(users); + } + var userIds = users .Select(u => u.GetUserId()) .Where(u => u.HasValue) @@ -71,13 +97,134 @@ public class TwoFactorIsEnabledQuery(IUserRepository userRepository) : ITwoFacto return false; } + if (_featureService.IsEnabled(FeatureFlagKeys.PremiumAccessQuery)) + { + var userEntity = user as User ?? await _userRepository.GetByIdAsync(userId.Value); + if (userEntity == null) + { + throw new NotFoundException(); + } + + return await TwoFactorIsEnabledVNextAsync(userEntity); + } + return await TwoFactorEnabledAsync( - user.GetTwoFactorProviders(), - async () => - { - var calcUser = await _userRepository.GetCalculatedPremiumAsync(userId.Value); - return calcUser?.HasPremiumAccess ?? false; - }); + user.GetTwoFactorProviders(), + async () => + { + var calcUser = await _userRepository.GetCalculatedPremiumAsync(userId.Value); + return calcUser?.HasPremiumAccess ?? false; + }); + } + + private async Task> TwoFactorIsEnabledVNextAsync(IEnumerable userIds) + { + var result = new List<(Guid userId, bool hasTwoFactor)>(); + if (userIds == null || !userIds.Any()) + { + return result; + } + + var users = await _userRepository.GetManyAsync([.. userIds]); + + // Get enabled providers for each user + var usersTwoFactorProvidersMap = users.ToDictionary(u => u.Id, GetEnabledTwoFactorProviders); + + // Bulk fetch premium status only for users who need it (those with only premium providers) + var userIdsNeedingPremium = usersTwoFactorProvidersMap + .Where(kvp => kvp.Value.Any() && kvp.Value.All(TwoFactorProvider.RequiresPremium)) + .Select(kvp => kvp.Key) + .ToList(); + + var premiumStatusMap = userIdsNeedingPremium.Count > 0 + ? await _hasPremiumAccessQuery.HasPremiumAccessAsync(userIdsNeedingPremium) + : new Dictionary(); + + foreach (var user in users) + { + var userTwoFactorProviders = usersTwoFactorProvidersMap[user.Id]; + + if (!userTwoFactorProviders.Any()) + { + result.Add((user.Id, false)); + continue; + } + + // User has providers. If they're in the premium check map, verify premium status + var twoFactorIsEnabled = !premiumStatusMap.TryGetValue(user.Id, out var hasPremium) || hasPremium; + result.Add((user.Id, twoFactorIsEnabled)); + } + + return result; + } + + private async Task> TwoFactorIsEnabledVNextAsync(IEnumerable users) + where T : ITwoFactorProvidersUser + { + var userIds = users + .Select(u => u.GetUserId()) + .Where(u => u.HasValue) + .Select(u => u.Value) + .ToList(); + + var twoFactorResults = await TwoFactorIsEnabledVNextAsync(userIds); + + var result = new List<(T user, bool twoFactorIsEnabled)>(); + + foreach (var user in users) + { + var userId = user.GetUserId(); + if (userId.HasValue) + { + var hasTwoFactor = twoFactorResults.FirstOrDefault(res => res.userId == userId.Value).twoFactorIsEnabled; + result.Add((user, hasTwoFactor)); + } + else + { + result.Add((user, false)); + } + } + + return result; + } + + private async Task TwoFactorIsEnabledVNextAsync(User user) + { + var enabledProviders = GetEnabledTwoFactorProviders(user); + + if (!enabledProviders.Any()) + { + return false; + } + + // If all providers require premium, check if user has premium access + if (enabledProviders.All(TwoFactorProvider.RequiresPremium)) + { + return await _hasPremiumAccessQuery.HasPremiumAccessAsync(user.Id); + } + + // User has at least one non-premium provider + return true; + } + + /// + /// Gets all enabled two-factor provider types for a user. + /// + /// user with two factor providers + /// list of enabled provider types + private static IList GetEnabledTwoFactorProviders(User user) + { + var providers = user.GetTwoFactorProviders(); + + if (providers == null || providers.Count == 0) + { + return Array.Empty(); + } + + // TODO: PM-21210: In practice we don't save disabled providers to the database, worth looking into. + return (from provider in providers + where provider.Value?.Enabled ?? false + select provider.Key).ToList(); } /// diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/ISetInitialMasterPasswordCommand.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/ISetInitialMasterPasswordCommand.cs index 31dd19d5bf..d695f2586c 100644 --- a/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/ISetInitialMasterPasswordCommand.cs +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/ISetInitialMasterPasswordCommand.cs @@ -1,19 +1,25 @@ -using Bit.Core.Entities; -using Microsoft.AspNetCore.Identity; +using Bit.Core.Auth.Models.Data; +using Bit.Core.Entities; +using Bit.Core.Exceptions; namespace Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; /// /// Manages the setting of the initial master password for a in an organization. -/// This class is primarily invoked in two scenarios: -/// 1) In organizations configured with Single Sign-On (SSO) and master password decryption: +/// In organizations configured with Single Sign-On (SSO) and master password decryption: /// just in time (JIT) provisioned users logging in via SSO are required to set a master password. -/// 2) In organizations configured with SSO and trusted devices decryption: -/// Users who are upgraded to have admin account recovery permissions must set a master password -/// to ensure their ability to reset other users' accounts. /// public interface ISetInitialMasterPasswordCommand { - public Task SetInitialMasterPasswordAsync(User user, string masterPassword, string key, - string orgSsoIdentifier); + /// + /// Sets the initial master password and account keys for the specified user. + /// + /// User to set the master password for + /// Initial master password setup data + /// A task that completes when the operation succeeds + /// + /// Thrown if the user's master password is already set, the organization is not found, + /// the user is not a member of the organization, or the account keys are missing. + /// + public Task SetInitialMasterPasswordAsync(User user, SetInitialMasterPasswordDataModel masterPasswordDataModel); } diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/ISetInitialMasterPasswordCommandV1.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/ISetInitialMasterPasswordCommandV1.cs new file mode 100644 index 0000000000..7f4818a535 --- /dev/null +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/ISetInitialMasterPasswordCommandV1.cs @@ -0,0 +1,21 @@ +using Bit.Core.Entities; +using Microsoft.AspNetCore.Identity; + +namespace Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; + +/// +/// Manages the setting of the initial master password for a in an organization. +/// This class is primarily invoked in two scenarios: +/// 1) In organizations configured with Single Sign-On (SSO) and master password decryption: +/// just in time (JIT) provisioned users logging in via SSO are required to set a master password. +/// 2) In organizations configured with SSO and trusted devices decryption: +/// Users who are upgraded to have admin account recovery permissions must set a master password +/// to ensure their ability to reset other users' accounts. +/// +// TODO removed with https://bitwarden.atlassian.net/browse/PM-27327 +[Obsolete("Use ISetInitialMasterPasswordCommand instead")] +public interface ISetInitialMasterPasswordCommandV1 +{ + public Task SetInitialMasterPasswordAsync(User user, string masterPassword, string key, + string orgSsoIdentifier); +} diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/ITdeSetPasswordCommand.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/ITdeSetPasswordCommand.cs new file mode 100644 index 0000000000..4815aea8a0 --- /dev/null +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/ITdeSetPasswordCommand.cs @@ -0,0 +1,26 @@ +using Bit.Core.Auth.Models.Data; +using Bit.Core.Entities; +using Bit.Core.Exceptions; + +namespace Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; + +/// +/// Manages the setting of the master password for a TDE in an organization. +/// In organizations configured with SSO and trusted devices decryption: +/// Users who are upgraded to have admin account recovery permissions must set a master password +/// to ensure their ability to reset other users' accounts. +/// +public interface ITdeSetPasswordCommand +{ + /// + /// Sets the master password for the specified TDE user. + /// + /// User to set the master password for + /// Master password setup data + /// A task that completes when the operation succeeds + /// + /// Thrown if the user's master password is already set, the organization is not found, + /// the user is not a member of the organization, or the user is a TDE user without account keys set. + /// + Task SetMasterPasswordAsync(User user, SetInitialMasterPasswordDataModel masterPasswordDataModel); +} diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordCommand.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordCommand.cs index 32966f5710..b91ac61f7f 100644 --- a/src/Core/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordCommand.cs +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordCommand.cs @@ -1,4 +1,5 @@ -using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; +using Bit.Core.Auth.Models.Data; +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -6,98 +7,74 @@ using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.Repositories; using Bit.Core.Services; using Microsoft.AspNetCore.Identity; -using Microsoft.Extensions.Logging; namespace Bit.Core.Auth.UserFeatures.UserMasterPassword; public class SetInitialMasterPasswordCommand : ISetInitialMasterPasswordCommand { - private readonly ILogger _logger; - private readonly IdentityErrorDescriber _identityErrorDescriber; private readonly IUserService _userService; private readonly IUserRepository _userRepository; - private readonly IEventService _eventService; private readonly IAcceptOrgUserCommand _acceptOrgUserCommand; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IOrganizationRepository _organizationRepository; + private readonly IPasswordHasher _passwordHasher; + private readonly IEventService _eventService; - - public SetInitialMasterPasswordCommand( - ILogger logger, - IdentityErrorDescriber identityErrorDescriber, - IUserService userService, - IUserRepository userRepository, - IEventService eventService, - IAcceptOrgUserCommand acceptOrgUserCommand, - IOrganizationUserRepository organizationUserRepository, - IOrganizationRepository organizationRepository) + public SetInitialMasterPasswordCommand(IUserService userService, IUserRepository userRepository, + IAcceptOrgUserCommand acceptOrgUserCommand, IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository, IPasswordHasher passwordHasher, + IEventService eventService) { - _logger = logger; - _identityErrorDescriber = identityErrorDescriber; _userService = userService; _userRepository = userRepository; - _eventService = eventService; _acceptOrgUserCommand = acceptOrgUserCommand; _organizationUserRepository = organizationUserRepository; _organizationRepository = organizationRepository; + _passwordHasher = passwordHasher; + _eventService = eventService; } - public async Task SetInitialMasterPasswordAsync(User user, string masterPassword, string key, - string orgSsoIdentifier) + public async Task SetInitialMasterPasswordAsync(User user, + SetInitialMasterPasswordDataModel masterPasswordDataModel) { - if (user == null) + if (user.Key != null) { - throw new ArgumentNullException(nameof(user)); + throw new BadRequestException("User already has a master password set."); } - if (!string.IsNullOrWhiteSpace(user.MasterPassword)) + if (masterPasswordDataModel.AccountKeys == null) { - _logger.LogWarning("Change password failed for user {userId} - already has password.", user.Id); - return IdentityResult.Failed(_identityErrorDescriber.UserAlreadyHasPassword()); + throw new BadRequestException("Account keys are required."); } - var result = await _userService.UpdatePasswordHash(user, masterPassword, validatePassword: true, refreshStamp: false); - if (!result.Succeeded) - { - return result; - } - - user.RevisionDate = user.AccountRevisionDate = DateTime.UtcNow; - user.Key = key; - - await _userRepository.ReplaceAsync(user); - await _eventService.LogUserEventAsync(user.Id, EventType.User_ChangedPassword); - - - if (string.IsNullOrWhiteSpace(orgSsoIdentifier)) - { - throw new BadRequestException("Organization SSO Identifier required."); - } - - var org = await _organizationRepository.GetByIdentifierAsync(orgSsoIdentifier); + // Prevent a de-synced salt value from creating an un-decryptable unlock method + masterPasswordDataModel.MasterPasswordAuthentication.ValidateSaltUnchangedForUser(user); + masterPasswordDataModel.MasterPasswordUnlock.ValidateSaltUnchangedForUser(user); + var org = await _organizationRepository.GetByIdentifierAsync(masterPasswordDataModel.OrgSsoIdentifier); if (org == null) { - throw new BadRequestException("Organization invalid."); + throw new BadRequestException("Organization SSO identifier is invalid."); } var orgUser = await _organizationUserRepository.GetByOrganizationAsync(org.Id, user.Id); - if (orgUser == null) { throw new BadRequestException("User not found within organization."); } - // TDE users who go from a user without admin acct recovery permission to having it will be - // required to set a MP for the first time and we don't want to re-execute the accept logic - // as they are already confirmed. - // TLDR: only accept post SSO user if they are invited - if (orgUser.Status == OrganizationUserStatusType.Invited) - { - await _acceptOrgUserCommand.AcceptOrgUserAsync(orgUser, user, _userService); - } + // Hash the provided user master password authentication hash on the server side + var serverSideHashedMasterPasswordAuthenticationHash = _passwordHasher.HashPassword(user, + masterPasswordDataModel.MasterPasswordAuthentication.MasterPasswordAuthenticationHash); - return IdentityResult.Success; + var setMasterPasswordTask = _userRepository.SetMasterPassword(user.Id, + masterPasswordDataModel.MasterPasswordUnlock, serverSideHashedMasterPasswordAuthenticationHash, + masterPasswordDataModel.MasterPasswordHint); + await _userRepository.SetV2AccountCryptographicStateAsync(user.Id, masterPasswordDataModel.AccountKeys, + [setMasterPasswordTask]); + + await _eventService.LogUserEventAsync(user.Id, EventType.User_ChangedPassword); + + await _acceptOrgUserCommand.AcceptOrgUserAsync(orgUser, user, _userService); } - } diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordCommandV1.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordCommandV1.cs new file mode 100644 index 0000000000..df5f0d02f7 --- /dev/null +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordCommandV1.cs @@ -0,0 +1,103 @@ +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.Auth.UserFeatures.UserMasterPassword; + +public class SetInitialMasterPasswordCommandV1 : ISetInitialMasterPasswordCommandV1 +{ + private readonly ILogger _logger; + private readonly IdentityErrorDescriber _identityErrorDescriber; + private readonly IUserService _userService; + private readonly IUserRepository _userRepository; + private readonly IEventService _eventService; + private readonly IAcceptOrgUserCommand _acceptOrgUserCommand; + private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly IOrganizationRepository _organizationRepository; + + + public SetInitialMasterPasswordCommandV1( + ILogger logger, + IdentityErrorDescriber identityErrorDescriber, + IUserService userService, + IUserRepository userRepository, + IEventService eventService, + IAcceptOrgUserCommand acceptOrgUserCommand, + IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository) + { + _logger = logger; + _identityErrorDescriber = identityErrorDescriber; + _userService = userService; + _userRepository = userRepository; + _eventService = eventService; + _acceptOrgUserCommand = acceptOrgUserCommand; + _organizationUserRepository = organizationUserRepository; + _organizationRepository = organizationRepository; + } + + public async Task SetInitialMasterPasswordAsync(User user, string masterPassword, string key, + string orgSsoIdentifier) + { + if (user == null) + { + throw new ArgumentNullException(nameof(user)); + } + + if (!string.IsNullOrWhiteSpace(user.MasterPassword)) + { + _logger.LogWarning("Change password failed for user {userId} - already has password.", user.Id); + return IdentityResult.Failed(_identityErrorDescriber.UserAlreadyHasPassword()); + } + + var result = await _userService.UpdatePasswordHash(user, masterPassword, validatePassword: true, refreshStamp: false); + if (!result.Succeeded) + { + return result; + } + + user.RevisionDate = user.AccountRevisionDate = DateTime.UtcNow; + user.Key = key; + + await _userRepository.ReplaceAsync(user); + await _eventService.LogUserEventAsync(user.Id, EventType.User_ChangedPassword); + + + if (string.IsNullOrWhiteSpace(orgSsoIdentifier)) + { + throw new BadRequestException("Organization SSO Identifier required."); + } + + var org = await _organizationRepository.GetByIdentifierAsync(orgSsoIdentifier); + + if (org == null) + { + throw new BadRequestException("Organization invalid."); + } + + var orgUser = await _organizationUserRepository.GetByOrganizationAsync(org.Id, user.Id); + + if (orgUser == null) + { + throw new BadRequestException("User not found within organization."); + } + + // TDE users who go from a user without admin acct recovery permission to having it will be + // required to set a MP for the first time and we don't want to re-execute the accept logic + // as they are already confirmed. + // TLDR: only accept post SSO user if they are invited + if (orgUser.Status == OrganizationUserStatusType.Invited) + { + await _acceptOrgUserCommand.AcceptOrgUserAsync(orgUser, user, _userService); + } + + return IdentityResult.Success; + } + +} diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/TdeSetPasswordCommand.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/TdeSetPasswordCommand.cs new file mode 100644 index 0000000000..afd28e95d9 --- /dev/null +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/TdeSetPasswordCommand.cs @@ -0,0 +1,70 @@ +using Bit.Core.Auth.Models.Data; +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Microsoft.AspNetCore.Identity; + +namespace Bit.Core.Auth.UserFeatures.UserMasterPassword; + +public class TdeSetPasswordCommand : ITdeSetPasswordCommand +{ + private readonly IUserRepository _userRepository; + private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly IOrganizationRepository _organizationRepository; + private readonly IPasswordHasher _passwordHasher; + private readonly IEventService _eventService; + + public TdeSetPasswordCommand(IUserRepository userRepository, + IOrganizationUserRepository organizationUserRepository, IOrganizationRepository organizationRepository, + IPasswordHasher passwordHasher, IEventService eventService) + { + _userRepository = userRepository; + _organizationUserRepository = organizationUserRepository; + _organizationRepository = organizationRepository; + _passwordHasher = passwordHasher; + _eventService = eventService; + } + + public async Task SetMasterPasswordAsync(User user, SetInitialMasterPasswordDataModel masterPasswordDataModel) + { + if (user.Key != null) + { + throw new BadRequestException("User already has a master password set."); + } + + if (user.PublicKey == null || user.PrivateKey == null) + { + throw new BadRequestException("TDE user account keys must be set before setting initial master password."); + } + + // Prevent a de-synced salt value from creating an un-decryptable unlock method + masterPasswordDataModel.MasterPasswordAuthentication.ValidateSaltUnchangedForUser(user); + masterPasswordDataModel.MasterPasswordUnlock.ValidateSaltUnchangedForUser(user); + + var org = await _organizationRepository.GetByIdentifierAsync(masterPasswordDataModel.OrgSsoIdentifier); + if (org == null) + { + throw new BadRequestException("Organization SSO identifier is invalid."); + } + + var orgUser = await _organizationUserRepository.GetByOrganizationAsync(org.Id, user.Id); + if (orgUser == null) + { + throw new BadRequestException("User not found within organization."); + } + + // Hash the provided user master password authentication hash on the server side + var serverSideHashedMasterPasswordAuthenticationHash = _passwordHasher.HashPassword(user, + masterPasswordDataModel.MasterPasswordAuthentication.MasterPasswordAuthenticationHash); + + var setMasterPasswordTask = _userRepository.SetMasterPassword(user.Id, + masterPasswordDataModel.MasterPasswordUnlock, serverSideHashedMasterPasswordAuthenticationHash, + masterPasswordDataModel.MasterPasswordHint); + await _userRepository.UpdateUserDataAsync([setMasterPasswordTask]); + + await _eventService.LogUserEventAsync(user.Id, EventType.User_ChangedPassword); + } +} diff --git a/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs b/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs index 53bd8bdba2..6249d1cb1c 100644 --- a/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs +++ b/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs @@ -1,5 +1,4 @@ - - +using Bit.Core.Auth.Sso; using Bit.Core.Auth.UserFeatures.DeviceTrust; using Bit.Core.Auth.UserFeatures.Registration; using Bit.Core.Auth.UserFeatures.Registration.Implementations; @@ -29,6 +28,7 @@ public static class UserServiceCollectionExtensions services.AddWebAuthnLoginCommands(); services.AddTdeOffboardingPasswordCommands(); services.AddTwoFactorQueries(); + services.AddSsoQueries(); } public static void AddDeviceTrustCommands(this IServiceCollection services) @@ -44,6 +44,8 @@ public static class UserServiceCollectionExtensions private static void AddUserPasswordCommands(this IServiceCollection services) { services.AddScoped(); + services.AddScoped(); + services.AddScoped(); } private static void AddTdeOffboardingPasswordCommands(this IServiceCollection services) @@ -69,4 +71,9 @@ public static class UserServiceCollectionExtensions { services.AddScoped(); } + + private static void AddSsoQueries(this IServiceCollection services) + { + services.AddScoped(); + } } diff --git a/src/Core/Billing/Constants/StripeConstants.cs b/src/Core/Billing/Constants/StripeConstants.cs index 11f043fc69..e9c34d7e06 100644 --- a/src/Core/Billing/Constants/StripeConstants.cs +++ b/src/Core/Billing/Constants/StripeConstants.cs @@ -12,6 +12,12 @@ public static class StripeConstants public const string UnrecognizedLocation = "unrecognized_location"; } + public static class BillingReasons + { + public const string SubscriptionCreate = "subscription_create"; + public const string SubscriptionCycle = "subscription_cycle"; + } + public static class CollectionMethod { public const string ChargeAutomatically = "charge_automatically"; @@ -36,6 +42,7 @@ public static class StripeConstants public static class ErrorCodes { public const string CustomerTaxLocationInvalid = "customer_tax_location_invalid"; + public const string InvoiceUpcomingNone = "invoice_upcoming_none"; public const string PaymentMethodMicroDepositVerificationAttemptsExceeded = "payment_method_microdeposit_verification_attempts_exceeded"; public const string PaymentMethodMicroDepositVerificationDescriptorCodeMismatch = "payment_method_microdeposit_verification_descriptor_code_mismatch"; public const string PaymentMethodMicroDepositVerificationTimeout = "payment_method_microdeposit_verification_timeout"; @@ -59,12 +66,19 @@ public static class StripeConstants public static class MetadataKeys { public const string BraintreeCustomerId = "btCustomerId"; + public const string BraintreeTransactionId = "btTransactionId"; public const string InvoiceApproved = "invoice_approved"; public const string OrganizationId = "organizationId"; + public const string PayPalTransactionId = "btPayPalTransactionId"; + public const string PreviousAdditionalStorage = "previous_additional_storage"; + public const string PreviousPeriodEndDate = "previous_period_end_date"; + public const string PreviousPremiumPriceId = "previous_premium_price_id"; + public const string PreviousPremiumUserId = "previous_premium_user_id"; public const string ProviderId = "providerId"; public const string Region = "region"; public const string RetiredBraintreeCustomerId = "btCustomerId_old"; public const string UserId = "userId"; + public const string StorageReconciled2025 = "storage_reconciled_2025"; } public static class PaymentBehavior diff --git a/src/Core/Billing/Enums/PlanCadenceType.cs b/src/Core/Billing/Enums/PlanCadenceType.cs index 9e6fa69832..20421bc2af 100644 --- a/src/Core/Billing/Enums/PlanCadenceType.cs +++ b/src/Core/Billing/Enums/PlanCadenceType.cs @@ -1,7 +1,11 @@ -namespace Bit.Core.Billing.Enums; +using System.Runtime.Serialization; + +namespace Bit.Core.Billing.Enums; public enum PlanCadenceType { + [EnumMember(Value = "annually")] Annually, + [EnumMember(Value = "monthly")] Monthly } diff --git a/src/Core/Billing/Extensions/DiscountExtensions.cs b/src/Core/Billing/Extensions/DiscountExtensions.cs new file mode 100644 index 0000000000..6d5b91bd89 --- /dev/null +++ b/src/Core/Billing/Extensions/DiscountExtensions.cs @@ -0,0 +1,12 @@ +using Stripe; + +namespace Bit.Core.Billing.Extensions; + +public static class DiscountExtensions +{ + public static bool AppliesTo(this Discount discount, SubscriptionItem subscriptionItem) + => discount.Coupon.AppliesTo.Products.Contains(subscriptionItem.Price.Product.Id); + + public static bool IsValid(this Discount? discount) + => discount?.Coupon?.Valid ?? false; +} diff --git a/src/Core/Billing/Extensions/InvoiceExtensions.cs b/src/Core/Billing/Extensions/InvoiceExtensions.cs index d62959c09a..774b6b93b2 100644 --- a/src/Core/Billing/Extensions/InvoiceExtensions.cs +++ b/src/Core/Billing/Extensions/InvoiceExtensions.cs @@ -1,4 +1,5 @@ -using System.Text.RegularExpressions; +using System.Globalization; +using System.Text.RegularExpressions; using Stripe; namespace Bit.Core.Billing.Extensions; @@ -51,7 +52,7 @@ public static class InvoiceExtensions if (string.IsNullOrEmpty(priceInfo) && line.Quantity > 0) { var pricePerItem = (line.Amount / 100m) / line.Quantity; - priceInfo = $"(at ${pricePerItem:F2} / month)"; + priceInfo = string.Format(CultureInfo.InvariantCulture, "(at ${0:F2} / month)", pricePerItem); } var taxDescription = $"{line.Quantity} × Tax {priceInfo}"; @@ -70,7 +71,7 @@ public static class InvoiceExtensions if (tax > 0) { var taxAmount = tax / 100m; - items.Add($"1 × Tax (at ${taxAmount:F2} / month)"); + items.Add(string.Format(CultureInfo.InvariantCulture, "1 × Tax (at ${0:F2} / month)", taxAmount)); } return items; diff --git a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs index d6593f5365..c61c4e6279 100644 --- a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs +++ b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs @@ -1,17 +1,22 @@ using Bit.Core.Billing.Caches; using Bit.Core.Billing.Caches.Implementations; +using Bit.Core.Billing.Licenses; using Bit.Core.Billing.Licenses.Extensions; using Bit.Core.Billing.Organizations.Commands; using Bit.Core.Billing.Organizations.Queries; using Bit.Core.Billing.Organizations.Services; using Bit.Core.Billing.Payment; using Bit.Core.Billing.Premium.Commands; +using Bit.Core.Billing.Premium.Queries; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Billing.Services.Implementations; using Bit.Core.Billing.Subscriptions.Commands; +using Bit.Core.Billing.Subscriptions.Queries; using Bit.Core.Billing.Tax.Services; using Bit.Core.Billing.Tax.Services.Implementations; +using Bit.Core.Services; +using Bit.Core.Services.Implementations; namespace Bit.Core.Billing.Extensions; @@ -27,14 +32,19 @@ public static class ServiceCollectionExtensions services.AddTransient(); services.AddTransient(); services.AddLicenseServices(); + services.AddLicenseOperations(); services.AddPricingClient(); services.AddPaymentOperations(); services.AddOrganizationLicenseCommandsQueries(); services.AddPremiumCommands(); + services.AddPremiumQueries(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); } private static void AddOrganizationLicenseCommandsQueries(this IServiceCollection services) @@ -49,5 +59,12 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddTransient(); + services.AddScoped(); + services.AddScoped(); + } + + private static void AddPremiumQueries(this IServiceCollection services) + { + services.AddScoped(); } } diff --git a/src/Core/Billing/Licenses/LicenseConstants.cs b/src/Core/Billing/Licenses/LicenseConstants.cs index 79ac94be62..e7188c5d6f 100644 --- a/src/Core/Billing/Licenses/LicenseConstants.cs +++ b/src/Core/Billing/Licenses/LicenseConstants.cs @@ -44,6 +44,8 @@ public static class OrganizationLicenseConstants public const string UseAdminSponsoredFamilies = nameof(UseAdminSponsoredFamilies); public const string UseOrganizationDomains = nameof(UseOrganizationDomains); public const string UseAutomaticUserConfirmation = nameof(UseAutomaticUserConfirmation); + public const string UseDisableSmAdsForUsers = nameof(UseDisableSmAdsForUsers); + public const string UsePhishingBlocker = nameof(UsePhishingBlocker); } public static class UserLicenseConstants diff --git a/src/Core/Billing/Licenses/Models/Api/Response/LicenseResponseModel.cs b/src/Core/Billing/Licenses/Models/Api/Response/LicenseResponseModel.cs new file mode 100644 index 0000000000..60f8f0e81a --- /dev/null +++ b/src/Core/Billing/Licenses/Models/Api/Response/LicenseResponseModel.cs @@ -0,0 +1,44 @@ +using System.Security.Claims; +using Bit.Core.Billing.Licenses.Extensions; +using Bit.Core.Billing.Models.Business; +using Bit.Core.Models.Api; + +namespace Bit.Core.Billing.Licenses.Models.Api.Response; + +/// +/// Response model containing user license information. +/// Separated from subscription data to maintain separation of concerns. +/// +public class LicenseResponseModel : ResponseModel +{ + public LicenseResponseModel(UserLicense license, ClaimsPrincipal? claimsPrincipal) + : base("license") + { + License = license; + + // CRITICAL: When a license has a Token (JWT), ALWAYS use the expiration from the token claim + // The token's expiration is cryptographically secured and cannot be tampered with + // The file's Expires property can be manually edited and should NOT be trusted for display + if (claimsPrincipal != null) + { + Expiration = claimsPrincipal.GetValue(UserLicenseConstants.Expires); + } + else + { + // No token - use the license file expiration (for older licenses without tokens) + Expiration = license.Expires; + } + } + + /// + /// The user's license containing feature entitlements and metadata. + /// + public UserLicense License { get; set; } + + /// + /// The license expiration date. + /// Extracted from the cryptographically secured JWT token when available, + /// otherwise falls back to the license file's expiration date. + /// + public DateTime? Expiration { get; set; } +} diff --git a/src/Core/Billing/Licenses/Queries/GetUserLicenseQuery.cs b/src/Core/Billing/Licenses/Queries/GetUserLicenseQuery.cs new file mode 100644 index 0000000000..16344116cb --- /dev/null +++ b/src/Core/Billing/Licenses/Queries/GetUserLicenseQuery.cs @@ -0,0 +1,23 @@ +using Bit.Core.Billing.Licenses.Models.Api.Response; +using Bit.Core.Billing.Services; +using Bit.Core.Entities; +using Bit.Core.Services; + +namespace Bit.Core.Billing.Licenses.Queries; + +public interface IGetUserLicenseQuery +{ + Task Run(User user); +} + +public class GetUserLicenseQuery( + IUserService userService, + ILicensingService licensingService) : IGetUserLicenseQuery +{ + public async Task Run(User user) + { + var license = await userService.GenerateLicenseAsync(user); + var claimsPrincipal = licensingService.GetClaimsPrincipalFromLicense(license); + return new LicenseResponseModel(license, claimsPrincipal); + } +} diff --git a/src/Core/Billing/Licenses/Registrations.cs b/src/Core/Billing/Licenses/Registrations.cs new file mode 100644 index 0000000000..74c449a355 --- /dev/null +++ b/src/Core/Billing/Licenses/Registrations.cs @@ -0,0 +1,13 @@ +using Bit.Core.Billing.Licenses.Queries; +using Microsoft.Extensions.DependencyInjection; + +namespace Bit.Core.Billing.Licenses; + +public static class Registrations +{ + public static void AddLicenseOperations(this IServiceCollection services) + { + // Queries + services.AddTransient(); + } +} diff --git a/src/Core/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactory.cs b/src/Core/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactory.cs index e9aadbe758..67e47a742a 100644 --- a/src/Core/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactory.cs +++ b/src/Core/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactory.cs @@ -26,7 +26,7 @@ public class OrganizationLicenseClaimsFactory : ILicenseClaimsFactory(OrganizationLicenseConstants.Name); + license.BillingEmail = claimsPrincipal.GetValue(OrganizationLicenseConstants.BillingEmail); + license.BusinessName = claimsPrincipal.GetValue(OrganizationLicenseConstants.BusinessName); + license.PlanType = claimsPrincipal.GetValue(OrganizationLicenseConstants.PlanType); + license.Seats = claimsPrincipal.GetValue(OrganizationLicenseConstants.Seats); + license.MaxCollections = claimsPrincipal.GetValue(OrganizationLicenseConstants.MaxCollections); + license.UsePolicies = claimsPrincipal.GetValue(OrganizationLicenseConstants.UsePolicies); + license.UseSso = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseSso); + license.UseKeyConnector = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseKeyConnector); + license.UseScim = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseScim); + license.UseGroups = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseGroups); + license.UseDirectory = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseDirectory); + license.UseEvents = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseEvents); + license.UseTotp = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseTotp); + license.Use2fa = claimsPrincipal.GetValue(OrganizationLicenseConstants.Use2fa); + license.UseApi = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseApi); + license.UseResetPassword = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseResetPassword); + license.Plan = claimsPrincipal.GetValue(OrganizationLicenseConstants.Plan); + license.SelfHost = claimsPrincipal.GetValue(OrganizationLicenseConstants.SelfHost); + license.UsersGetPremium = claimsPrincipal.GetValue(OrganizationLicenseConstants.UsersGetPremium); + license.UseCustomPermissions = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseCustomPermissions); + license.Enabled = claimsPrincipal.GetValue(OrganizationLicenseConstants.Enabled); + license.Expires = claimsPrincipal.GetValue(OrganizationLicenseConstants.Expires); + license.LicenseKey = claimsPrincipal.GetValue(OrganizationLicenseConstants.LicenseKey); + license.UsePasswordManager = claimsPrincipal.GetValue(OrganizationLicenseConstants.UsePasswordManager); + license.UseSecretsManager = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseSecretsManager); + license.SmSeats = claimsPrincipal.GetValue(OrganizationLicenseConstants.SmSeats); + license.SmServiceAccounts = claimsPrincipal.GetValue(OrganizationLicenseConstants.SmServiceAccounts); + license.UseRiskInsights = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseRiskInsights); + license.UseOrganizationDomains = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseOrganizationDomains); + license.UseAdminSponsoredFamilies = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseAdminSponsoredFamilies); + license.UseAutomaticUserConfirmation = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseAutomaticUserConfirmation); + license.UseDisableSmAdsForUsers = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseDisableSmAdsForUsers); + license.UsePhishingBlocker = claimsPrincipal.GetValue(OrganizationLicenseConstants.UsePhishingBlocker); + license.MaxStorageGb = claimsPrincipal.GetValue(OrganizationLicenseConstants.MaxStorageGb); + license.InstallationId = claimsPrincipal.GetValue(OrganizationLicenseConstants.InstallationId); + license.LicenseType = claimsPrincipal.GetValue(OrganizationLicenseConstants.LicenseType); + license.Issued = claimsPrincipal.GetValue(OrganizationLicenseConstants.Issued); + license.Refresh = claimsPrincipal.GetValue(OrganizationLicenseConstants.Refresh); + license.ExpirationWithoutGracePeriod = claimsPrincipal.GetValue(OrganizationLicenseConstants.ExpirationWithoutGracePeriod); + license.Trial = claimsPrincipal.GetValue(OrganizationLicenseConstants.Trial); + license.LimitCollectionCreationDeletion = claimsPrincipal.GetValue(OrganizationLicenseConstants.LimitCollectionCreationDeletion); + license.AllowAdminAccessToAllCollectionItems = claimsPrincipal.GetValue(OrganizationLicenseConstants.AllowAdminAccessToAllCollectionItems); + } + var canUse = license.CanUse(_globalSettings, _licensingService, claimsPrincipal, out var exception) && selfHostedOrganization.CanUseLicense(license, out exception); @@ -54,12 +107,6 @@ public class UpdateOrganizationLicenseCommand : IUpdateOrganizationLicenseComman throw new BadRequestException(exception); } - var useAutomaticUserConfirmation = claimsPrincipal? - .GetValue(OrganizationLicenseConstants.UseAutomaticUserConfirmation) ?? false; - - selfHostedOrganization.UseAutomaticUserConfirmation = useAutomaticUserConfirmation; - license.UseAutomaticUserConfirmation = useAutomaticUserConfirmation; - await WriteLicenseFileAsync(selfHostedOrganization, license); await UpdateOrganizationAsync(selfHostedOrganization, license); } diff --git a/src/Core/Billing/Organizations/Models/OrganizationLicense.cs b/src/Core/Billing/Organizations/Models/OrganizationLicense.cs index 7ccbacc938..cfa20c418e 100644 --- a/src/Core/Billing/Organizations/Models/OrganizationLicense.cs +++ b/src/Core/Billing/Organizations/Models/OrganizationLicense.cs @@ -143,6 +143,7 @@ public class OrganizationLicense : ILicense public int? SmSeats { get; set; } public int? SmServiceAccounts { get; set; } public bool UseRiskInsights { get; set; } + public bool UsePhishingBlocker { get; set; } // Deprecated. Left for backwards compatibility with old license versions. public bool LimitCollectionCreationDeletion { get; set; } = true; @@ -154,6 +155,7 @@ public class OrganizationLicense : ILicense public bool UseOrganizationDomains { get; set; } public bool UseAdminSponsoredFamilies { get; set; } public bool UseAutomaticUserConfirmation { get; set; } + public bool UseDisableSmAdsForUsers { get; set; } public string Hash { get; set; } public string Signature { get; set; } public string Token { get; set; } @@ -228,7 +230,9 @@ public class OrganizationLicense : ILicense !p.Name.Equals(nameof(UseRiskInsights)) && !p.Name.Equals(nameof(UseAdminSponsoredFamilies)) && !p.Name.Equals(nameof(UseOrganizationDomains)) && - !p.Name.Equals(nameof(UseAutomaticUserConfirmation))) + !p.Name.Equals(nameof(UseAutomaticUserConfirmation)) && + !p.Name.Equals(nameof(UseDisableSmAdsForUsers)) && + !p.Name.Equals(nameof(UsePhishingBlocker))) .OrderBy(p => p.Name) .Select(p => $"{p.Name}:{Core.Utilities.CoreHelpers.FormatLicenseSignatureValue(p.GetValue(this, null))}") .Aggregate((c, n) => $"{c}|{n}"); @@ -399,7 +403,6 @@ public class OrganizationLicense : ILicense var installationId = claimsPrincipal.GetValue(nameof(InstallationId)); var licenseKey = claimsPrincipal.GetValue(nameof(LicenseKey)); var enabled = claimsPrincipal.GetValue(nameof(Enabled)); - var planType = claimsPrincipal.GetValue(nameof(PlanType)); var seats = claimsPrincipal.GetValue(nameof(Seats)); var maxCollections = claimsPrincipal.GetValue(nameof(MaxCollections)); var useGroups = claimsPrincipal.GetValue(nameof(UseGroups)); @@ -424,13 +427,20 @@ public class OrganizationLicense : ILicense var useAdminSponsoredFamilies = claimsPrincipal.GetValue(nameof(UseAdminSponsoredFamilies)); var useOrganizationDomains = claimsPrincipal.GetValue(nameof(UseOrganizationDomains)); var useAutomaticUserConfirmation = claimsPrincipal.GetValue(nameof(UseAutomaticUserConfirmation)); + var useDisableSmAdsForUsers = claimsPrincipal.GetValue(nameof(UseDisableSmAdsForUsers)); + + var claimedPlanType = claimsPrincipal.GetValue(nameof(PlanType)); + + var planTypesMatch = claimedPlanType == PlanType.FamiliesAnnually + ? organization.PlanType is PlanType.FamiliesAnnually or PlanType.FamiliesAnnually2025 + : organization.PlanType == claimedPlanType; return issued <= DateTime.UtcNow && expires >= DateTime.UtcNow && installationId == globalSettings.Installation.Id && licenseKey == organization.LicenseKey && enabled == organization.Enabled && - planType == organization.PlanType && + planTypesMatch && seats == organization.Seats && maxCollections == organization.MaxCollections && useGroups == organization.UseGroups && @@ -454,7 +464,8 @@ public class OrganizationLicense : ILicense smServiceAccounts == organization.SmServiceAccounts && useAdminSponsoredFamilies == organization.UseAdminSponsoredFamilies && useOrganizationDomains == organization.UseOrganizationDomains && - useAutomaticUserConfirmation == organization.UseAutomaticUserConfirmation; + useAutomaticUserConfirmation == organization.UseAutomaticUserConfirmation && + useDisableSmAdsForUsers == organization.UseDisableSmAdsForUsers; } diff --git a/src/Core/Billing/Organizations/Queries/GetCloudOrganizationLicenseQuery.cs b/src/Core/Billing/Organizations/Queries/GetCloudOrganizationLicenseQuery.cs index f00bc00356..a8a236decc 100644 --- a/src/Core/Billing/Organizations/Queries/GetCloudOrganizationLicenseQuery.cs +++ b/src/Core/Billing/Organizations/Queries/GetCloudOrganizationLicenseQuery.cs @@ -22,14 +22,14 @@ public interface IGetCloudOrganizationLicenseQuery public class GetCloudOrganizationLicenseQuery : IGetCloudOrganizationLicenseQuery { private readonly IInstallationRepository _installationRepository; - private readonly IPaymentService _paymentService; + private readonly IStripePaymentService _paymentService; private readonly ILicensingService _licensingService; private readonly IProviderRepository _providerRepository; private readonly IFeatureService _featureService; public GetCloudOrganizationLicenseQuery( IInstallationRepository installationRepository, - IPaymentService paymentService, + IStripePaymentService paymentService, ILicensingService licensingService, IProviderRepository providerRepository, IFeatureService featureService) diff --git a/src/Core/Billing/Organizations/Queries/GetOrganizationWarningsQuery.cs b/src/Core/Billing/Organizations/Queries/GetOrganizationWarningsQuery.cs index 01e520ea41..af8dfa7aec 100644 --- a/src/Core/Billing/Organizations/Queries/GetOrganizationWarningsQuery.cs +++ b/src/Core/Billing/Organizations/Queries/GetOrganizationWarningsQuery.cs @@ -9,7 +9,6 @@ using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Payment.Queries; using Bit.Core.Billing.Services; using Bit.Core.Context; -using Bit.Core.Services; using Stripe; using Stripe.Tax; @@ -201,7 +200,7 @@ public class GetOrganizationWarningsQuery( // ReSharper disable once InvertIf if (subscription.Status == SubscriptionStatus.PastDue) { - var openInvoices = await stripeAdapter.InvoiceSearchAsync(new InvoiceSearchOptions + var openInvoices = await stripeAdapter.SearchInvoiceAsync(new InvoiceSearchOptions { Query = $"subscription:'{subscription.Id}' status:'open'" }); @@ -257,8 +256,8 @@ public class GetOrganizationWarningsQuery( // Get active and scheduled registrations var registrations = (await Task.WhenAll( - stripeAdapter.TaxRegistrationsListAsync(new RegistrationListOptions { Status = TaxRegistrationStatus.Active }), - stripeAdapter.TaxRegistrationsListAsync(new RegistrationListOptions { Status = TaxRegistrationStatus.Scheduled }))) + stripeAdapter.ListTaxRegistrationsAsync(new RegistrationListOptions { Status = TaxRegistrationStatus.Active }), + stripeAdapter.ListTaxRegistrationsAsync(new RegistrationListOptions { Status = TaxRegistrationStatus.Scheduled }))) .SelectMany(registrations => registrations.Data); // Find the matching registration for the customer diff --git a/src/Core/Billing/Organizations/Services/IOrganizationBillingService.cs b/src/Core/Billing/Organizations/Services/IOrganizationBillingService.cs index d34bd86e7b..39d2a789e6 100644 --- a/src/Core/Billing/Organizations/Services/IOrganizationBillingService.cs +++ b/src/Core/Billing/Organizations/Services/IOrganizationBillingService.cs @@ -56,4 +56,11 @@ public interface IOrganizationBillingService /// Thrown when the is . /// Thrown when no payment method is found for the customer, no plan IDs are provided, or subscription update fails. Task UpdateSubscriptionPlanFrequency(Organization organization, PlanType newPlanType); + + /// + /// Updates the organization name and email on the Stripe customer entry. + /// This only updates Stripe, not the Bitwarden database. + /// + /// The organization to update in Stripe. + Task UpdateOrganizationNameAndEmail(Organization organization); } diff --git a/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs b/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs index b10f04d766..a1b57c2415 100644 --- a/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs +++ b/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs @@ -14,7 +14,6 @@ using Bit.Core.Billing.Tax.Services; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; -using Bit.Core.Services; using Bit.Core.Settings; using Braintree; using Microsoft.Extensions.Logging; @@ -161,7 +160,7 @@ public class OrganizationBillingService( try { // Update the subscription in Stripe - await stripeAdapter.SubscriptionUpdateAsync(subscription.Id, updateOptions); + await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, updateOptions); organization.PlanType = newPlan.Type; await organizationRepository.ReplaceAsync(organization); } @@ -176,6 +175,45 @@ public class OrganizationBillingService( } } + public async Task UpdateOrganizationNameAndEmail(Organization organization) + { + if (string.IsNullOrWhiteSpace(organization.GatewayCustomerId)) + { + logger.LogWarning( + "Organization ({OrganizationId}) has no Stripe customer to update", + organization.Id); + return; + } + + var newDisplayName = organization.DisplayName(); + + // Organization.DisplayName() can return null - handle gracefully + if (string.IsNullOrWhiteSpace(newDisplayName)) + { + logger.LogWarning( + "Organization ({OrganizationId}) has no name to update in Stripe", + organization.Id); + return; + } + + await stripeAdapter.UpdateCustomerAsync(organization.GatewayCustomerId, + new CustomerUpdateOptions + { + Email = organization.BillingEmail, + Description = newDisplayName, + InvoiceSettings = new CustomerInvoiceSettingsOptions + { + // This overwrites the existing custom fields for this organization + CustomFields = [ + new CustomerInvoiceSettingsCustomFieldOptions + { + Name = organization.SubscriberType(), + Value = newDisplayName + }] + }, + }); + } + #region Utilities private async Task CreateCustomerAsync( @@ -295,7 +333,7 @@ public class OrganizationBillingService( case PaymentMethodType.BankAccount: { var setupIntent = - (await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = paymentMethodToken })) + (await stripeAdapter.ListSetupIntentsAsync(new SetupIntentListOptions { PaymentMethod = paymentMethodToken })) .FirstOrDefault(); if (setupIntent == null) @@ -329,7 +367,7 @@ public class OrganizationBillingService( try { - var customer = await stripeAdapter.CustomerCreateAsync(customerCreateOptions); + var customer = await stripeAdapter.CreateCustomerAsync(customerCreateOptions); organization.Gateway = GatewayType.Stripe; organization.GatewayCustomerId = customer.Id; @@ -480,7 +518,7 @@ public class OrganizationBillingService( subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }; } - var subscription = await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions); + var subscription = await stripeAdapter.CreateSubscriptionAsync(subscriptionCreateOptions); organization.GatewaySubscriptionId = subscription.Id; await organizationRepository.ReplaceAsync(organization); @@ -508,14 +546,14 @@ public class OrganizationBillingService( customer = customer switch { { Address.Country: not Core.Constants.CountryAbbreviations.UnitedStates, TaxExempt: not StripeConstants.TaxExempt.Reverse } => await - stripeAdapter.CustomerUpdateAsync(customer.Id, + stripeAdapter.UpdateCustomerAsync(customer.Id, new CustomerUpdateOptions { Expand = expansions, TaxExempt = StripeConstants.TaxExempt.Reverse }), { Address.Country: Core.Constants.CountryAbbreviations.UnitedStates, TaxExempt: StripeConstants.TaxExempt.Reverse } => await - stripeAdapter.CustomerUpdateAsync(customer.Id, + stripeAdapter.UpdateCustomerAsync(customer.Id, new CustomerUpdateOptions { Expand = expansions, @@ -574,7 +612,7 @@ public class OrganizationBillingService( } } }; - await stripeAdapter.SubscriptionUpdateAsync(organization.GatewaySubscriptionId, options); + await stripeAdapter.UpdateSubscriptionAsync(organization.GatewaySubscriptionId, options); } } diff --git a/src/Core/Billing/Payment/Commands/UpdateBillingAddressCommand.cs b/src/Core/Billing/Payment/Commands/UpdateBillingAddressCommand.cs index f4eca40cae..daf39fb981 100644 --- a/src/Core/Billing/Payment/Commands/UpdateBillingAddressCommand.cs +++ b/src/Core/Billing/Payment/Commands/UpdateBillingAddressCommand.cs @@ -4,7 +4,6 @@ using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Services; using Bit.Core.Entities; -using Bit.Core.Services; using Microsoft.Extensions.Logging; using Stripe; @@ -46,7 +45,7 @@ public class UpdateBillingAddressCommand( BillingAddress billingAddress) { var customer = - await stripeAdapter.CustomerUpdateAsync(subscriber.GatewayCustomerId, + await stripeAdapter.UpdateCustomerAsync(subscriber.GatewayCustomerId, new CustomerUpdateOptions { Address = new AddressOptions @@ -71,7 +70,7 @@ public class UpdateBillingAddressCommand( BillingAddress billingAddress) { var customer = - await stripeAdapter.CustomerUpdateAsync(subscriber.GatewayCustomerId, + await stripeAdapter.UpdateCustomerAsync(subscriber.GatewayCustomerId, new CustomerUpdateOptions { Address = new AddressOptions @@ -92,7 +91,7 @@ public class UpdateBillingAddressCommand( await EnableAutomaticTaxAsync(subscriber, customer); var deleteExistingTaxIds = customer.TaxIds?.Any() ?? false - ? customer.TaxIds.Select(taxId => stripeAdapter.TaxIdDeleteAsync(customer.Id, taxId.Id)).ToList() + ? customer.TaxIds.Select(taxId => stripeAdapter.DeleteTaxIdAsync(customer.Id, taxId.Id)).ToList() : []; if (billingAddress.TaxId == null) @@ -101,12 +100,12 @@ public class UpdateBillingAddressCommand( return BillingAddress.From(customer.Address); } - var updatedTaxId = await stripeAdapter.TaxIdCreateAsync(customer.Id, + var updatedTaxId = await stripeAdapter.CreateTaxIdAsync(customer.Id, new TaxIdCreateOptions { Type = billingAddress.TaxId.Code, Value = billingAddress.TaxId.Value }); if (billingAddress.TaxId.Code == StripeConstants.TaxIdType.SpanishNIF) { - updatedTaxId = await stripeAdapter.TaxIdCreateAsync(customer.Id, + updatedTaxId = await stripeAdapter.CreateTaxIdAsync(customer.Id, new TaxIdCreateOptions { Type = StripeConstants.TaxIdType.EUVAT, @@ -130,7 +129,7 @@ public class UpdateBillingAddressCommand( if (subscription is { AutomaticTax.Enabled: false }) { - await stripeAdapter.SubscriptionUpdateAsync(subscriber.GatewaySubscriptionId, + await stripeAdapter.UpdateSubscriptionAsync(subscriber.GatewaySubscriptionId, new SubscriptionUpdateOptions { AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true } diff --git a/src/Core/Billing/Payment/Commands/UpdatePaymentMethodCommand.cs b/src/Core/Billing/Payment/Commands/UpdatePaymentMethodCommand.cs index 81206b8032..a5a9e3e9c9 100644 --- a/src/Core/Billing/Payment/Commands/UpdatePaymentMethodCommand.cs +++ b/src/Core/Billing/Payment/Commands/UpdatePaymentMethodCommand.cs @@ -4,7 +4,6 @@ using Bit.Core.Billing.Constants; using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Services; using Bit.Core.Entities; -using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Utilities; using Braintree; @@ -56,7 +55,7 @@ public class UpdatePaymentMethodCommand( if (billingAddress != null && customer.Address is not { Country: not null, PostalCode: not null }) { - await stripeAdapter.CustomerUpdateAsync(customer.Id, + await stripeAdapter.UpdateCustomerAsync(customer.Id, new CustomerUpdateOptions { Address = new AddressOptions @@ -75,7 +74,7 @@ public class UpdatePaymentMethodCommand( Customer customer, string token) { - var setupIntents = await stripeAdapter.SetupIntentList(new SetupIntentListOptions + var setupIntents = await stripeAdapter.ListSetupIntentsAsync(new SetupIntentListOptions { Expand = ["data.payment_method"], PaymentMethod = token @@ -104,9 +103,9 @@ public class UpdatePaymentMethodCommand( Customer customer, string token) { - var paymentMethod = await stripeAdapter.PaymentMethodAttachAsync(token, new PaymentMethodAttachOptions { Customer = customer.Id }); + var paymentMethod = await stripeAdapter.AttachPaymentMethodAsync(token, new PaymentMethodAttachOptions { Customer = customer.Id }); - await stripeAdapter.CustomerUpdateAsync(customer.Id, + await stripeAdapter.UpdateCustomerAsync(customer.Id, new CustomerUpdateOptions { InvoiceSettings = new CustomerInvoiceSettingsOptions { DefaultPaymentMethod = token } @@ -139,7 +138,7 @@ public class UpdatePaymentMethodCommand( [StripeConstants.MetadataKeys.BraintreeCustomerId] = braintreeCustomer.Id }; - await stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions { Metadata = metadata }); + await stripeAdapter.UpdateCustomerAsync(customer.Id, new CustomerUpdateOptions { Metadata = metadata }); } var payPalAccount = braintreeCustomer.DefaultPaymentMethod as PayPalAccount; @@ -204,7 +203,7 @@ public class UpdatePaymentMethodCommand( [StripeConstants.MetadataKeys.BraintreeCustomerId] = string.Empty }; - await stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions { Metadata = metadata }); + await stripeAdapter.UpdateCustomerAsync(customer.Id, new CustomerUpdateOptions { Metadata = metadata }); } } } diff --git a/src/Core/Billing/Payment/Queries/GetPaymentMethodQuery.cs b/src/Core/Billing/Payment/Queries/GetPaymentMethodQuery.cs index 9f9618571e..e03a785278 100644 --- a/src/Core/Billing/Payment/Queries/GetPaymentMethodQuery.cs +++ b/src/Core/Billing/Payment/Queries/GetPaymentMethodQuery.cs @@ -4,7 +4,6 @@ using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Services; using Bit.Core.Entities; -using Bit.Core.Services; using Braintree; using Microsoft.Extensions.Logging; using Stripe; @@ -53,7 +52,7 @@ public class GetPaymentMethodQuery( if (!string.IsNullOrEmpty(setupIntentId)) { - var setupIntent = await stripeAdapter.SetupIntentGet(setupIntentId, new SetupIntentGetOptions + var setupIntent = await stripeAdapter.GetSetupIntentAsync(setupIntentId, new SetupIntentGetOptions { Expand = ["payment_method"] }); diff --git a/src/Core/Billing/Payment/Queries/HasPaymentMethodQuery.cs b/src/Core/Billing/Payment/Queries/HasPaymentMethodQuery.cs index ec77ee0712..c972c3fe5f 100644 --- a/src/Core/Billing/Payment/Queries/HasPaymentMethodQuery.cs +++ b/src/Core/Billing/Payment/Queries/HasPaymentMethodQuery.cs @@ -3,7 +3,6 @@ using Bit.Core.Billing.Constants; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Services; using Bit.Core.Entities; -using Bit.Core.Services; using Stripe; namespace Bit.Core.Billing.Payment.Queries; @@ -48,7 +47,7 @@ public class HasPaymentMethodQuery( return false; } - var setupIntent = await stripeAdapter.SetupIntentGet(setupIntentId, new SetupIntentGetOptions + var setupIntent = await stripeAdapter.GetSetupIntentAsync(setupIntentId, new SetupIntentGetOptions { Expand = ["payment_method"] }); diff --git a/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs b/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs index 472f31ac4b..764406ee56 100644 --- a/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs +++ b/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs @@ -7,6 +7,7 @@ using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Payment.Queries; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; +using Bit.Core.Billing.Subscriptions.Models; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Platform.Push; @@ -49,6 +50,7 @@ public interface ICreatePremiumCloudHostedSubscriptionCommand public class CreatePremiumCloudHostedSubscriptionCommand( IBraintreeGateway braintreeGateway, + IBraintreeService braintreeService, IGlobalSettings globalSettings, ISetupIntentCache setupIntentCache, IStripeAdapter stripeAdapter, @@ -210,7 +212,7 @@ public class CreatePremiumCloudHostedSubscriptionCommand( case TokenizablePaymentMethodType.BankAccount: { var setupIntent = - (await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = tokenizedPaymentMethod.Token })) + (await stripeAdapter.ListSetupIntentsAsync(new SetupIntentListOptions { PaymentMethod = tokenizedPaymentMethod.Token })) .FirstOrDefault(); if (setupIntent == null) @@ -243,7 +245,7 @@ public class CreatePremiumCloudHostedSubscriptionCommand( try { - return await stripeAdapter.CustomerCreateAsync(customerCreateOptions); + return await stripeAdapter.CreateCustomerAsync(customerCreateOptions); } catch { @@ -300,7 +302,8 @@ public class CreatePremiumCloudHostedSubscriptionCommand( ValidateLocation = ValidateTaxLocationTiming.Immediately } }; - return await stripeAdapter.CustomerUpdateAsync(customer.Id, options); + + return await stripeAdapter.UpdateCustomerAsync(customer.Id, options); } private async Task CreateSubscriptionAsync( @@ -349,16 +352,21 @@ public class CreatePremiumCloudHostedSubscriptionCommand( OffSession = true }; - var subscription = await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions); + var subscription = await stripeAdapter.CreateSubscriptionAsync(subscriptionCreateOptions); - if (usingPayPal) + if (!usingPayPal) { - await stripeAdapter.InvoiceUpdateAsync(subscription.LatestInvoiceId, new InvoiceUpdateOptions - { - AutoAdvance = false - }); + return subscription; } + var invoice = await stripeAdapter.UpdateInvoiceAsync(subscription.LatestInvoiceId, new InvoiceUpdateOptions + { + AutoAdvance = false, + Expand = ["customer"] + }); + + await braintreeService.PayInvoice(new UserId(userId), invoice); + return subscription; } } diff --git a/src/Core/Billing/Premium/Commands/PreviewPremiumTaxCommand.cs b/src/Core/Billing/Premium/Commands/PreviewPremiumTaxCommand.cs index 5f09b8b77b..07247c83cb 100644 --- a/src/Core/Billing/Premium/Commands/PreviewPremiumTaxCommand.cs +++ b/src/Core/Billing/Premium/Commands/PreviewPremiumTaxCommand.cs @@ -1,7 +1,7 @@ using Bit.Core.Billing.Commands; using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Pricing; -using Bit.Core.Services; +using Bit.Core.Billing.Services; using Microsoft.Extensions.Logging; using Stripe; @@ -56,7 +56,7 @@ public class PreviewPremiumTaxCommand( }); } - var invoice = await stripeAdapter.InvoiceCreatePreviewAsync(options); + var invoice = await stripeAdapter.CreateInvoicePreviewAsync(options); return GetAmounts(invoice); }); diff --git a/src/Core/Billing/Premium/Commands/UpdatePremiumStorageCommand.cs b/src/Core/Billing/Premium/Commands/UpdatePremiumStorageCommand.cs new file mode 100644 index 0000000000..219f450f1d --- /dev/null +++ b/src/Core/Billing/Premium/Commands/UpdatePremiumStorageCommand.cs @@ -0,0 +1,177 @@ +using Bit.Core.Billing.Commands; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Services; +using Bit.Core.Billing.Subscriptions.Models; +using Bit.Core.Entities; +using Bit.Core.Services; +using Bit.Core.Utilities; +using Microsoft.Extensions.Logging; +using OneOf.Types; +using Stripe; + +namespace Bit.Core.Billing.Premium.Commands; + +using static StripeConstants; + +/// +/// Updates the storage allocation for a premium user's subscription. +/// Handles both increases and decreases in storage in an idempotent manner. +/// +public interface IUpdatePremiumStorageCommand +{ + /// + /// Updates the user's storage by the specified additional amount. + /// + /// The premium user whose storage should be updated. + /// The additional storage amount in GB beyond base storage. + /// A billing command result indicating success or failure. + Task> Run(User user, short additionalStorageGb); +} + +public class UpdatePremiumStorageCommand( + IBraintreeService braintreeService, + IStripeAdapter stripeAdapter, + IUserService userService, + IPricingClient pricingClient, + ILogger logger) + : BaseBillingCommand(logger), IUpdatePremiumStorageCommand +{ + public Task> Run(User user, short additionalStorageGb) => HandleAsync(async () => + { + if (user is not { Premium: true, GatewaySubscriptionId: not null and not "" }) + { + return new BadRequest("User does not have a premium subscription."); + } + + if (!user.MaxStorageGb.HasValue) + { + return new BadRequest("User has no access to storage."); + } + + // Fetch all premium plans and the user's subscription to find which plan they're on + var premiumPlans = await pricingClient.ListPremiumPlans(); + var subscription = await stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, new SubscriptionGetOptions + { + Expand = ["customer"] + }); + + // Find the password manager subscription item (seat, not storage) and match it to a plan + var passwordManagerItem = subscription.Items.Data.FirstOrDefault(i => + premiumPlans.Any(p => p.Seat.StripePriceId == i.Price.Id)); + + if (passwordManagerItem == null) + { + return new Conflict("Premium subscription does not have a Password Manager line item."); + } + + var premiumPlan = premiumPlans.First(p => p.Seat.StripePriceId == passwordManagerItem.Price.Id); + + var baseStorageGb = (short)premiumPlan.Storage.Provided; + + if (additionalStorageGb < 0) + { + return new BadRequest("Additional storage cannot be negative."); + } + + var maxStorageGb = (short)(baseStorageGb + additionalStorageGb); + + if (maxStorageGb > 100) + { + return new BadRequest("Maximum storage is 100 GB."); + } + + // Idempotency check: if user already has the requested storage, return success + if (user.MaxStorageGb == maxStorageGb) + { + return new None(); + } + + var remainingStorage = user.StorageBytesRemaining(maxStorageGb); + if (remainingStorage < 0) + { + return new BadRequest( + $"You are currently using {CoreHelpers.ReadableBytesSize(user.Storage.GetValueOrDefault(0))} of storage. " + + "Delete some stored data first."); + } + + // Find the storage line item in the subscription + var storageItem = subscription.Items.Data.FirstOrDefault(i => i.Price.Id == premiumPlan.Storage.StripePriceId); + + var subscriptionItemOptions = new List(); + + if (additionalStorageGb > 0) + { + if (storageItem != null) + { + // Update existing storage item + subscriptionItemOptions.Add(new SubscriptionItemOptions + { + Id = storageItem.Id, + Price = premiumPlan.Storage.StripePriceId, + Quantity = additionalStorageGb + }); + } + else + { + // Add new storage item + subscriptionItemOptions.Add(new SubscriptionItemOptions + { + Price = premiumPlan.Storage.StripePriceId, + Quantity = additionalStorageGb + }); + } + } + else if (storageItem != null) + { + // Remove storage item if setting to 0 + subscriptionItemOptions.Add(new SubscriptionItemOptions + { + Id = storageItem.Id, + Deleted = true + }); + } + + var usingPayPal = subscription.Customer.Metadata.ContainsKey(MetadataKeys.BraintreeCustomerId); + + if (usingPayPal) + { + var options = new SubscriptionUpdateOptions + { + Items = subscriptionItemOptions, + ProrationBehavior = ProrationBehavior.CreateProrations + }; + + await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, options); + + var draftInvoice = await stripeAdapter.CreateInvoiceAsync(new InvoiceCreateOptions + { + Customer = subscription.CustomerId, + Subscription = subscription.Id, + AutoAdvance = false, + CollectionMethod = CollectionMethod.ChargeAutomatically + }); + + var finalizedInvoice = await stripeAdapter.FinalizeInvoiceAsync(draftInvoice.Id, + new InvoiceFinalizeOptions { AutoAdvance = false, Expand = ["customer"] }); + + await braintreeService.PayInvoice(new UserId(user.Id), finalizedInvoice); + } + else + { + var options = new SubscriptionUpdateOptions + { + Items = subscriptionItemOptions, + ProrationBehavior = ProrationBehavior.AlwaysInvoice + }; + + await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, options); + } + + // Update the user's max storage + user.MaxStorageGb = maxStorageGb; + await userService.SaveUserAsync(user); + + return new None(); + }); +} diff --git a/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs b/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs new file mode 100644 index 0000000000..81bc5c9e2c --- /dev/null +++ b/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs @@ -0,0 +1,228 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Commands; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Extensions; +using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Services; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Utilities; +using Microsoft.Extensions.Logging; +using OneOf.Types; +using Stripe; + +namespace Bit.Core.Billing.Premium.Commands; +/// +/// Upgrades a user's Premium subscription to an Organization plan by creating a new Organization +/// and transferring the subscription from the User to the Organization. +/// +public interface IUpgradePremiumToOrganizationCommand +{ + /// + /// Upgrades a Premium subscription to an Organization subscription. + /// + /// The user with an active Premium subscription to upgrade. + /// The name for the new organization. + /// The encrypted organization key for the owner. + /// The target organization plan type to upgrade to. + /// A billing command result indicating success or failure with appropriate error details. + Task> Run( + User user, + string organizationName, + string key, + PlanType targetPlanType); +} + +public class UpgradePremiumToOrganizationCommand( + ILogger logger, + IPricingClient pricingClient, + IStripeAdapter stripeAdapter, + IUserService userService, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationApiKeyRepository organizationApiKeyRepository, + IApplicationCacheService applicationCacheService) + : BaseBillingCommand(logger), IUpgradePremiumToOrganizationCommand +{ + public Task> Run( + User user, + string organizationName, + string key, + PlanType targetPlanType) => HandleAsync(async () => + { + // Validate that the user has an active Premium subscription + if (user is not { Premium: true, GatewaySubscriptionId: not null and not "" }) + { + return new BadRequest("User does not have an active Premium subscription."); + } + + // Hardcode seats to 1 for upgrade flow + const int seats = 1; + + // Fetch the current Premium subscription from Stripe + var currentSubscription = await stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId); + + // Fetch all premium plans to find which specific plan the user is on + var premiumPlans = await pricingClient.ListPremiumPlans(); + + // Find the password manager subscription item (seat, not storage) and match it to a plan + var passwordManagerItem = currentSubscription.Items.Data.FirstOrDefault(i => + premiumPlans.Any(p => p.Seat.StripePriceId == i.Price.Id)); + + if (passwordManagerItem == null) + { + return new BadRequest("Premium subscription item not found."); + } + + var usersPremiumPlan = premiumPlans.First(p => p.Seat.StripePriceId == passwordManagerItem.Price.Id); + + // Get the target organization plan + var targetPlan = await pricingClient.GetPlanOrThrow(targetPlanType); + + // Build the list of subscription item updates + var subscriptionItemOptions = new List(); + + // Delete the user's specific password manager item + subscriptionItemOptions.Add(new SubscriptionItemOptions + { + Id = passwordManagerItem.Id, + Deleted = true + }); + + // Delete the storage item if it exists for this user's plan + var storageItem = currentSubscription.Items.Data.FirstOrDefault(i => + i.Price.Id == usersPremiumPlan.Storage.StripePriceId); + + // Capture the previous additional storage quantity for potential revert + var previousAdditionalStorage = storageItem?.Quantity ?? 0; + + if (storageItem != null) + { + subscriptionItemOptions.Add(new SubscriptionItemOptions + { + Id = storageItem.Id, + Deleted = true + }); + } + + // Add new organization subscription items + if (targetPlan.HasNonSeatBasedPasswordManagerPlan()) + { + subscriptionItemOptions.Add(new SubscriptionItemOptions + { + Price = targetPlan.PasswordManager.StripePlanId, + Quantity = 1 + }); + } + else + { + subscriptionItemOptions.Add(new SubscriptionItemOptions + { + Price = targetPlan.PasswordManager.StripeSeatPlanId, + Quantity = seats + }); + } + + // Generate organization ID early to include in metadata + var organizationId = CoreHelpers.GenerateComb(); + + // Build the subscription update options + var subscriptionUpdateOptions = new SubscriptionUpdateOptions + { + Items = subscriptionItemOptions, + ProrationBehavior = StripeConstants.ProrationBehavior.None, + Metadata = new Dictionary + { + [StripeConstants.MetadataKeys.OrganizationId] = organizationId.ToString(), + [StripeConstants.MetadataKeys.PreviousPremiumPriceId] = usersPremiumPlan.Seat.StripePriceId, + [StripeConstants.MetadataKeys.PreviousPeriodEndDate] = currentSubscription.GetCurrentPeriodEnd()?.ToString("O") ?? string.Empty, + [StripeConstants.MetadataKeys.PreviousAdditionalStorage] = previousAdditionalStorage.ToString(), + [StripeConstants.MetadataKeys.PreviousPremiumUserId] = user.Id.ToString(), + [StripeConstants.MetadataKeys.UserId] = string.Empty // Remove userId to unlink subscription from User + } + }; + + // Create the Organization entity + var organization = new Organization + { + Id = organizationId, + Name = organizationName, + BillingEmail = user.Email, + PlanType = targetPlan.Type, + Seats = (short)seats, + MaxCollections = targetPlan.PasswordManager.MaxCollections, + MaxStorageGb = targetPlan.PasswordManager.BaseStorageGb, + UsePolicies = targetPlan.HasPolicies, + UseSso = targetPlan.HasSso, + UseGroups = targetPlan.HasGroups, + UseEvents = targetPlan.HasEvents, + UseDirectory = targetPlan.HasDirectory, + UseTotp = targetPlan.HasTotp, + Use2fa = targetPlan.Has2fa, + UseApi = targetPlan.HasApi, + UseResetPassword = targetPlan.HasResetPassword, + SelfHost = targetPlan.HasSelfHost, + UsersGetPremium = targetPlan.UsersGetPremium, + UseCustomPermissions = targetPlan.HasCustomPermissions, + UseScim = targetPlan.HasScim, + Plan = targetPlan.Name, + Gateway = GatewayType.Stripe, + Enabled = true, + LicenseKey = CoreHelpers.SecureRandomString(20), + CreationDate = DateTime.UtcNow, + RevisionDate = DateTime.UtcNow, + Status = OrganizationStatusType.Created, + UsePasswordManager = true, + UseSecretsManager = false, + UseOrganizationDomains = targetPlan.HasOrganizationDomains, + GatewayCustomerId = user.GatewayCustomerId, + GatewaySubscriptionId = currentSubscription.Id + }; + + // Update the subscription in Stripe + await stripeAdapter.UpdateSubscriptionAsync(currentSubscription.Id, subscriptionUpdateOptions); + + // Save the organization + await organizationRepository.CreateAsync(organization); + + // Create organization API key + await organizationApiKeyRepository.CreateAsync(new OrganizationApiKey + { + OrganizationId = organization.Id, + ApiKey = CoreHelpers.SecureRandomString(30), + Type = OrganizationApiKeyType.Default, + RevisionDate = DateTime.UtcNow, + }); + + // Update cache + await applicationCacheService.UpsertOrganizationAbilityAsync(organization); + + // Create OrganizationUser for the upgrading user as owner + var organizationUser = new OrganizationUser + { + OrganizationId = organization.Id, + UserId = user.Id, + Key = key, + AccessSecretsManager = false, + Type = OrganizationUserType.Owner, + Status = OrganizationUserStatusType.Confirmed, + CreationDate = organization.CreationDate, + RevisionDate = organization.CreationDate + }; + organizationUser.SetNewId(); + await organizationUserRepository.CreateAsync(organizationUser); + + // Remove subscription from user + user.Premium = false; + user.PremiumExpirationDate = null; + user.GatewaySubscriptionId = null; + user.GatewayCustomerId = null; + user.RevisionDate = DateTime.UtcNow; + await userService.SaveUserAsync(user); + + return new None(); + }); +} diff --git a/src/Core/Billing/Premium/Models/UserPremiumAccess.cs b/src/Core/Billing/Premium/Models/UserPremiumAccess.cs new file mode 100644 index 0000000000..639d175d25 --- /dev/null +++ b/src/Core/Billing/Premium/Models/UserPremiumAccess.cs @@ -0,0 +1,29 @@ +namespace Bit.Core.Billing.Premium.Models; + +/// +/// Represents user premium access status from personal subscriptions and organization memberships. +/// +public class UserPremiumAccess +{ + /// + /// The unique identifier for the user. + /// + public Guid Id { get; set; } + + /// + /// Indicates whether the user has a personal premium subscription. + /// This does NOT include premium access from organizations. + /// + public bool PersonalPremium { get; set; } + + /// + /// Indicates whether the user has premium access through any organization membership. + /// This is true if the user is a member of at least one enabled organization that grants premium access to users. + /// + public bool OrganizationPremium { get; set; } + + /// + /// Indicates whether the user has premium access from any source (personal subscription or organization). + /// + public bool HasPremiumAccess => PersonalPremium || OrganizationPremium; +} diff --git a/src/Core/Billing/Premium/Queries/HasPremiumAccessQuery.cs b/src/Core/Billing/Premium/Queries/HasPremiumAccessQuery.cs new file mode 100644 index 0000000000..e90710a9b3 --- /dev/null +++ b/src/Core/Billing/Premium/Queries/HasPremiumAccessQuery.cs @@ -0,0 +1,49 @@ +using Bit.Core.Exceptions; +using Bit.Core.Repositories; + +namespace Bit.Core.Billing.Premium.Queries; + +public class HasPremiumAccessQuery : IHasPremiumAccessQuery +{ + private readonly IUserRepository _userRepository; + + public HasPremiumAccessQuery(IUserRepository userRepository) + { + _userRepository = userRepository; + } + + public async Task HasPremiumAccessAsync(Guid userId) + { + var user = await _userRepository.GetPremiumAccessAsync(userId); + if (user == null) + { + throw new NotFoundException(); + } + + return user.HasPremiumAccess; + } + + public async Task> HasPremiumAccessAsync(IEnumerable userIds) + { + var distinctUserIds = userIds.Distinct().ToList(); + var usersWithPremium = await _userRepository.GetPremiumAccessByIdsAsync(distinctUserIds); + + if (usersWithPremium.Count() != distinctUserIds.Count) + { + throw new NotFoundException(); + } + + return usersWithPremium.ToDictionary(u => u.Id, u => u.HasPremiumAccess); + } + + public async Task HasPremiumFromOrganizationAsync(Guid userId) + { + var user = await _userRepository.GetPremiumAccessAsync(userId); + if (user == null) + { + throw new NotFoundException(); + } + + return user.OrganizationPremium; + } +} diff --git a/src/Core/Billing/Premium/Queries/IHasPremiumAccessQuery.cs b/src/Core/Billing/Premium/Queries/IHasPremiumAccessQuery.cs new file mode 100644 index 0000000000..e5545b1ade --- /dev/null +++ b/src/Core/Billing/Premium/Queries/IHasPremiumAccessQuery.cs @@ -0,0 +1,30 @@ +namespace Bit.Core.Billing.Premium.Queries; + +/// +/// Centralized query for checking if users have premium access through personal subscriptions or organizations. +/// Note: Different from User.Premium which only checks personal subscriptions. +/// +public interface IHasPremiumAccessQuery +{ + /// + /// Checks if a user has premium access (personal or organization). + /// + /// The user ID to check + /// True if user can access premium features + Task HasPremiumAccessAsync(Guid userId); + + /// + /// Checks premium access for multiple users. + /// + /// The user IDs to check + /// Dictionary mapping user IDs to their premium access status + Task> HasPremiumAccessAsync(IEnumerable userIds); + + /// + /// Checks if a user belongs to any organization that grants premium (enabled org with UsersGetPremium). + /// Returns true regardless of personal subscription. Useful for UI decisions like showing subscription options. + /// + /// The user ID to check + /// True if user is in any organization that grants premium + Task HasPremiumFromOrganizationAsync(Guid userId); +} diff --git a/src/Core/Billing/Providers/Services/IProviderBillingService.cs b/src/Core/Billing/Providers/Services/IProviderBillingService.cs index 57d68db038..3f5a48e817 100644 --- a/src/Core/Billing/Providers/Services/IProviderBillingService.cs +++ b/src/Core/Billing/Providers/Services/IProviderBillingService.cs @@ -113,4 +113,11 @@ public interface IProviderBillingService TaxInformation taxInformation); Task UpdateSeatMinimums(UpdateProviderSeatMinimumsCommand command); + + /// + /// Updates the provider name and email on the Stripe customer entry. + /// This only updates Stripe, not the Bitwarden database. + /// + /// The provider to update in Stripe. + Task UpdateProviderNameAndEmail(Provider provider); } diff --git a/src/Core/Billing/Services/IStripeAdapter.cs b/src/Core/Billing/Services/IStripeAdapter.cs new file mode 100644 index 0000000000..12ea3d5a7c --- /dev/null +++ b/src/Core/Billing/Services/IStripeAdapter.cs @@ -0,0 +1,51 @@ +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Models.BitStripe; +using Stripe; +using Stripe.Tax; + +namespace Bit.Core.Billing.Services; + +public interface IStripeAdapter +{ + Task CreateCustomerAsync(CustomerCreateOptions customerCreateOptions); + Task GetCustomerAsync(string id, CustomerGetOptions options = null); + Task UpdateCustomerAsync(string id, CustomerUpdateOptions options = null); + Task DeleteCustomerAsync(string id); + Task> ListCustomerPaymentMethodsAsync(string id, CustomerPaymentMethodListOptions options = null); + Task CreateCustomerBalanceTransactionAsync(string customerId, + CustomerBalanceTransactionCreateOptions options); + Task CreateSubscriptionAsync(SubscriptionCreateOptions subscriptionCreateOptions); + Task GetSubscriptionAsync(string id, SubscriptionGetOptions options = null); + Task> ListTaxRegistrationsAsync(RegistrationListOptions options = null); + Task DeleteCustomerDiscountAsync(string customerId, CustomerDeleteDiscountOptions options = null); + Task UpdateSubscriptionAsync(string id, SubscriptionUpdateOptions options = null); + Task CancelSubscriptionAsync(string id, SubscriptionCancelOptions options = null); + Task GetInvoiceAsync(string id, InvoiceGetOptions options); + Task> ListInvoicesAsync(StripeInvoiceListOptions options); + Task CreateInvoiceAsync(InvoiceCreateOptions options); + Task CreateInvoicePreviewAsync(InvoiceCreatePreviewOptions options); + Task> SearchInvoiceAsync(InvoiceSearchOptions options); + Task UpdateInvoiceAsync(string id, InvoiceUpdateOptions options); + Task FinalizeInvoiceAsync(string id, InvoiceFinalizeOptions options); + Task SendInvoiceAsync(string id, InvoiceSendOptions options); + Task PayInvoiceAsync(string id, InvoicePayOptions options = null); + Task DeleteInvoiceAsync(string id, InvoiceDeleteOptions options = null); + Task VoidInvoiceAsync(string id, InvoiceVoidOptions options = null); + IEnumerable ListPaymentMethodsAutoPaging(PaymentMethodListOptions options); + IAsyncEnumerable ListPaymentMethodsAutoPagingAsync(PaymentMethodListOptions options); + Task AttachPaymentMethodAsync(string id, PaymentMethodAttachOptions options = null); + Task DetachPaymentMethodAsync(string id, PaymentMethodDetachOptions options = null); + Task CreateTaxIdAsync(string id, TaxIdCreateOptions options); + Task DeleteTaxIdAsync(string customerId, string taxIdId, TaxIdDeleteOptions options = null); + Task> ListChargesAsync(ChargeListOptions options); + Task CreateRefundAsync(RefundCreateOptions options); + Task DeleteCardAsync(string customerId, string cardId, CardDeleteOptions options = null); + Task DeleteBankAccountAsync(string customerId, string bankAccount, BankAccountDeleteOptions options = null); + Task CreateSetupIntentAsync(SetupIntentCreateOptions options); + Task> ListSetupIntentsAsync(SetupIntentListOptions options); + Task CancelSetupIntentAsync(string id, SetupIntentCancelOptions options = null); + Task GetSetupIntentAsync(string id, SetupIntentGetOptions options = null); + Task GetPriceAsync(string id, PriceGetOptions options = null); +} diff --git a/src/Core/Services/IPaymentService.cs b/src/Core/Billing/Services/IStripePaymentService.cs similarity index 85% rename from src/Core/Services/IPaymentService.cs rename to src/Core/Billing/Services/IStripePaymentService.cs index e7e848bcba..b948cf6921 100644 --- a/src/Core/Services/IPaymentService.cs +++ b/src/Core/Billing/Services/IStripePaymentService.cs @@ -4,15 +4,13 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Models.Business; using Bit.Core.Billing.Models; -using Bit.Core.Billing.Tax.Requests; -using Bit.Core.Billing.Tax.Responses; using Bit.Core.Entities; using Bit.Core.Models.Business; using Bit.Core.Models.StaticStore; -namespace Bit.Core.Services; +namespace Bit.Core.Billing.Services; -public interface IPaymentService +public interface IStripePaymentService { Task CancelAndRecoverChargesAsync(ISubscriber subscriber); Task SponsorOrganizationAsync(Organization org, OrganizationSponsorship sponsorship); @@ -44,8 +42,6 @@ public interface IPaymentService Task GetBillingAsync(ISubscriber subscriber); Task GetBillingHistoryAsync(ISubscriber subscriber); Task GetSubscriptionAsync(ISubscriber subscriber); - Task GetTaxInfoAsync(ISubscriber subscriber); - Task SaveTaxInfoAsync(ISubscriber subscriber, TaxInfo taxInfo); Task AddSecretsManagerToSubscription(Organization org, Plan plan, int additionalSmSeats, int additionalServiceAccount); /// /// Secrets Manager Standalone is a discount in Stripe that is used to give an organization access to Secrets Manager. @@ -68,7 +64,4 @@ public interface IPaymentService /// Organization Representation used for Inviting Organization Users /// If the organization has Secrets Manager and has the Standalone Stripe Discount Task HasSecretsManagerStandalone(InviteOrganization organization); - Task PreviewInvoiceAsync(PreviewIndividualInvoiceRequestBody parameters, string gatewayCustomerId, string gatewaySubscriptionId); - Task PreviewInvoiceAsync(PreviewOrganizationInvoiceRequestBody parameters, string gatewayCustomerId, string gatewaySubscriptionId); - } diff --git a/src/Core/Billing/Services/IStripeSyncService.cs b/src/Core/Billing/Services/IStripeSyncService.cs new file mode 100644 index 0000000000..b56204cd47 --- /dev/null +++ b/src/Core/Billing/Services/IStripeSyncService.cs @@ -0,0 +1,6 @@ +namespace Bit.Core.Billing.Services; + +public interface IStripeSyncService +{ + Task UpdateCustomerEmailAddressAsync(string gatewayCustomerId, string emailAddress); +} diff --git a/src/Core/Billing/Services/ISubscriberService.cs b/src/Core/Billing/Services/ISubscriberService.cs index f88727f37b..343a0e4f38 100644 --- a/src/Core/Billing/Services/ISubscriberService.cs +++ b/src/Core/Billing/Services/ISubscriberService.cs @@ -6,7 +6,6 @@ using Bit.Core.Billing.Tax.Models; using Bit.Core.Entities; using Bit.Core.Enums; using Stripe; -using PaymentMethod = Bit.Core.Billing.Models.PaymentMethod; namespace Bit.Core.Billing.Services; @@ -64,16 +63,6 @@ public interface ISubscriberService ISubscriber subscriber, CustomerGetOptions customerGetOptions = null); - /// - /// Retrieves the account credit, a masked representation of the default payment source and the tax information for the - /// provided . This is essentially a consolidated invocation of the - /// and methods with a response that includes the customer's as account credit in order to cut down on Stripe API calls. - /// - /// The subscriber to retrieve payment method for. - /// A containing the subscriber's account credit, payment source and tax information. - Task GetPaymentMethod( - ISubscriber subscriber); - /// /// Retrieves a masked representation of the subscriber's payment source for presentation to a client. /// @@ -107,16 +96,6 @@ public interface ISubscriberService ISubscriber subscriber, SubscriptionGetOptions subscriptionGetOptions = null); - /// - /// Retrieves the 's tax information using their Stripe 's . - /// - /// The subscriber to retrieve the tax information for. - /// A representing the 's tax information. - /// Thrown when the is . - /// This method opts for returning rather than throwing exceptions, making it ideal for surfacing data from API endpoints. - Task GetTaxInformation( - ISubscriber subscriber); - /// /// Attempts to remove a subscriber's saved payment source. If the Stripe representing the /// contains a valid "btCustomerId" key in its property, @@ -147,17 +126,6 @@ public interface ISubscriberService ISubscriber subscriber, TaxInformation taxInformation); - /// - /// Verifies the subscriber's pending bank account using the provided . - /// - /// The subscriber to verify the bank account for. - /// The code attached to a deposit made to the subscriber's bank account in order to ensure they have access to it. - /// Learn more. - /// - Task VerifyBankAccount( - ISubscriber subscriber, - string descriptorCode); - /// /// Validates whether the 's exists in the gateway. /// If the 's is or empty, returns . diff --git a/src/Core/Billing/Services/Implementations/PaymentHistoryService.cs b/src/Core/Billing/Services/Implementations/PaymentHistoryService.cs index 5a8cf16f5a..16b3f7e0c3 100644 --- a/src/Core/Billing/Services/Implementations/PaymentHistoryService.cs +++ b/src/Core/Billing/Services/Implementations/PaymentHistoryService.cs @@ -4,7 +4,6 @@ using Bit.Core.Billing.Models; using Bit.Core.Entities; using Bit.Core.Models.BitStripe; using Bit.Core.Repositories; -using Bit.Core.Services; namespace Bit.Core.Billing.Services.Implementations; @@ -23,7 +22,7 @@ public class PaymentHistoryService( return Array.Empty(); } - var invoices = await stripeAdapter.InvoiceListAsync(new StripeInvoiceListOptions + var invoices = await stripeAdapter.ListInvoicesAsync(new StripeInvoiceListOptions { Customer = subscriber.GatewayCustomerId, Limit = pageSize, diff --git a/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs b/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs index daa06b907a..9c85971dff 100644 --- a/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs +++ b/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs @@ -12,7 +12,6 @@ using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; -using Bit.Core.Services; using Bit.Core.Settings; using Braintree; using Microsoft.Extensions.Logging; @@ -68,7 +67,7 @@ public class PremiumUserBillingService( } }; - customer = await stripeAdapter.CustomerCreateAsync(options); + customer = await stripeAdapter.CreateCustomerAsync(options); user.Gateway = GatewayType.Stripe; user.GatewayCustomerId = customer.Id; @@ -81,7 +80,7 @@ public class PremiumUserBillingService( Balance = customer.Balance + credit }; - await stripeAdapter.CustomerUpdateAsync(customer.Id, options); + await stripeAdapter.UpdateCustomerAsync(customer.Id, options); } } @@ -227,7 +226,7 @@ public class PremiumUserBillingService( case PaymentMethodType.BankAccount: { var setupIntent = - (await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = paymentMethodToken })) + (await stripeAdapter.ListSetupIntentsAsync(new SetupIntentListOptions { PaymentMethod = paymentMethodToken })) .FirstOrDefault(); if (setupIntent == null) @@ -260,7 +259,7 @@ public class PremiumUserBillingService( try { - return await stripeAdapter.CustomerCreateAsync(customerCreateOptions); + return await stripeAdapter.CreateCustomerAsync(customerCreateOptions); } catch (StripeException stripeException) when (stripeException.StripeError?.Code == StripeConstants.ErrorCodes.CustomerTaxLocationInvalid) @@ -347,11 +346,11 @@ public class PremiumUserBillingService( OffSession = true }; - var subscription = await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions); + var subscription = await stripeAdapter.CreateSubscriptionAsync(subscriptionCreateOptions); if (usingPayPal) { - await stripeAdapter.InvoiceUpdateAsync(subscription.LatestInvoiceId, new InvoiceUpdateOptions + await stripeAdapter.UpdateInvoiceAsync(subscription.LatestInvoiceId, new InvoiceUpdateOptions { AutoAdvance = false }); @@ -387,6 +386,6 @@ public class PremiumUserBillingService( } }; - return await stripeAdapter.CustomerUpdateAsync(customer.Id, options); + return await stripeAdapter.UpdateCustomerAsync(customer.Id, options); } } diff --git a/src/Core/Billing/Services/Implementations/StripeAdapter.cs b/src/Core/Billing/Services/Implementations/StripeAdapter.cs new file mode 100644 index 0000000000..5b90500021 --- /dev/null +++ b/src/Core/Billing/Services/Implementations/StripeAdapter.cs @@ -0,0 +1,212 @@ +// FIXME: Update this file to be null safe and then delete the line below + +#nullable disable + +using Bit.Core.Models.BitStripe; +using Stripe; +using Stripe.Tax; +using Stripe.TestHelpers; +using CustomerService = Stripe.CustomerService; +using RefundService = Stripe.RefundService; + +namespace Bit.Core.Billing.Services.Implementations; + +public class StripeAdapter : IStripeAdapter +{ + private readonly CustomerService _customerService; + private readonly SubscriptionService _subscriptionService; + private readonly InvoiceService _invoiceService; + private readonly PaymentMethodService _paymentMethodService; + private readonly TaxIdService _taxIdService; + private readonly ChargeService _chargeService; + private readonly RefundService _refundService; + private readonly CardService _cardService; + private readonly BankAccountService _bankAccountService; + private readonly PriceService _priceService; + private readonly SetupIntentService _setupIntentService; + private readonly TestClockService _testClockService; + private readonly CustomerBalanceTransactionService _customerBalanceTransactionService; + private readonly RegistrationService _taxRegistrationService; + + public StripeAdapter() + { + _customerService = new CustomerService(); + _subscriptionService = new SubscriptionService(); + _invoiceService = new InvoiceService(); + _paymentMethodService = new PaymentMethodService(); + _taxIdService = new TaxIdService(); + _chargeService = new ChargeService(); + _refundService = new RefundService(); + _cardService = new CardService(); + _bankAccountService = new BankAccountService(); + _priceService = new PriceService(); + _setupIntentService = new SetupIntentService(); + _testClockService = new TestClockService(); + _customerBalanceTransactionService = new CustomerBalanceTransactionService(); + _taxRegistrationService = new RegistrationService(); + } + + /************** + ** CUSTOMER ** + **************/ + public Task CreateCustomerAsync(CustomerCreateOptions options) => + _customerService.CreateAsync(options); + + public Task DeleteCustomerDiscountAsync(string customerId, CustomerDeleteDiscountOptions options = null) => + _customerService.DeleteDiscountAsync(customerId, options); + + public Task GetCustomerAsync(string id, CustomerGetOptions options = null) => + _customerService.GetAsync(id, options); + + public Task UpdateCustomerAsync(string id, CustomerUpdateOptions options = null) => + _customerService.UpdateAsync(id, options); + + public Task DeleteCustomerAsync(string id) => + _customerService.DeleteAsync(id); + + public async Task> ListCustomerPaymentMethodsAsync(string id, + CustomerPaymentMethodListOptions options = null) + { + var paymentMethods = await _customerService.ListPaymentMethodsAsync(id, options); + return paymentMethods.Data; + } + + public Task CreateCustomerBalanceTransactionAsync(string customerId, + CustomerBalanceTransactionCreateOptions options) => + _customerBalanceTransactionService.CreateAsync(customerId, options); + + /****************** + ** SUBSCRIPTION ** + ******************/ + public Task CreateSubscriptionAsync(SubscriptionCreateOptions options) => + _subscriptionService.CreateAsync(options); + + public Task GetSubscriptionAsync(string id, SubscriptionGetOptions options = null) => + _subscriptionService.GetAsync(id, options); + + public Task UpdateSubscriptionAsync(string id, + SubscriptionUpdateOptions options = null) => + _subscriptionService.UpdateAsync(id, options); + + public Task CancelSubscriptionAsync(string id, SubscriptionCancelOptions options = null) => + _subscriptionService.CancelAsync(id, options); + + /************* + ** INVOICE ** + *************/ + public Task GetInvoiceAsync(string id, InvoiceGetOptions options) => + _invoiceService.GetAsync(id, options); + + public async Task> ListInvoicesAsync(StripeInvoiceListOptions options) + { + if (!options.SelectAll) + { + return (await _invoiceService.ListAsync(options.ToInvoiceListOptions())).Data; + } + + options.Limit = 100; + + var invoices = new List(); + + await foreach (var invoice in _invoiceService.ListAutoPagingAsync(options.ToInvoiceListOptions())) + { + invoices.Add(invoice); + } + + return invoices; + } + + public Task CreateInvoiceAsync(InvoiceCreateOptions options) => + _invoiceService.CreateAsync(options); + + public Task CreateInvoicePreviewAsync(InvoiceCreatePreviewOptions options) => + _invoiceService.CreatePreviewAsync(options); + + public async Task> SearchInvoiceAsync(InvoiceSearchOptions options) => + (await _invoiceService.SearchAsync(options)).Data; + + public Task UpdateInvoiceAsync(string id, InvoiceUpdateOptions options) => + _invoiceService.UpdateAsync(id, options); + + public Task FinalizeInvoiceAsync(string id, InvoiceFinalizeOptions options) => + _invoiceService.FinalizeInvoiceAsync(id, options); + + public Task SendInvoiceAsync(string id, InvoiceSendOptions options) => + _invoiceService.SendInvoiceAsync(id, options); + + public Task PayInvoiceAsync(string id, InvoicePayOptions options = null) => + _invoiceService.PayAsync(id, options); + + public Task DeleteInvoiceAsync(string id, InvoiceDeleteOptions options = null) => + _invoiceService.DeleteAsync(id, options); + + public Task VoidInvoiceAsync(string id, InvoiceVoidOptions options = null) => + _invoiceService.VoidInvoiceAsync(id, options); + + /******************** + ** PAYMENT METHOD ** + ********************/ + public IEnumerable ListPaymentMethodsAutoPaging(PaymentMethodListOptions options) => + _paymentMethodService.ListAutoPaging(options); + + public IAsyncEnumerable ListPaymentMethodsAutoPagingAsync(PaymentMethodListOptions options) + => _paymentMethodService.ListAutoPagingAsync(options); + + public Task AttachPaymentMethodAsync(string id, PaymentMethodAttachOptions options = null) => + _paymentMethodService.AttachAsync(id, options); + + public Task DetachPaymentMethodAsync(string id, PaymentMethodDetachOptions options = null) => + _paymentMethodService.DetachAsync(id, options); + + /************ + ** TAX ID ** + ************/ + public Task CreateTaxIdAsync(string id, TaxIdCreateOptions options) => + _taxIdService.CreateAsync(id, options); + + public Task DeleteTaxIdAsync(string customerId, string taxIdId, + TaxIdDeleteOptions options = null) => + _taxIdService.DeleteAsync(customerId, taxIdId, options); + + /****************** + ** BANK ACCOUNT ** + ******************/ + public Task DeleteBankAccountAsync(string customerId, string bankAccount, BankAccountDeleteOptions options = null) => + _bankAccountService.DeleteAsync(customerId, bankAccount, options); + + /*********** + ** PRICE ** + ***********/ + public Task GetPriceAsync(string id, PriceGetOptions options = null) => + _priceService.GetAsync(id, options); + + /****************** + ** SETUP INTENT ** + ******************/ + public Task CreateSetupIntentAsync(SetupIntentCreateOptions options) => + _setupIntentService.CreateAsync(options); + + public async Task> ListSetupIntentsAsync(SetupIntentListOptions options) => + (await _setupIntentService.ListAsync(options)).Data; + + public Task CancelSetupIntentAsync(string id, SetupIntentCancelOptions options = null) => + _setupIntentService.CancelAsync(id, options); + + public Task GetSetupIntentAsync(string id, SetupIntentGetOptions options = null) => + _setupIntentService.GetAsync(id, options); + + /******************* + ** MISCELLANEOUS ** + *******************/ + public Task> ListChargesAsync(ChargeListOptions options) => + _chargeService.ListAsync(options); + + public Task> ListTaxRegistrationsAsync(RegistrationListOptions options = null) => + _taxRegistrationService.ListAsync(options); + + public Task CreateRefundAsync(RefundCreateOptions options) => + _refundService.CreateAsync(options); + + public Task DeleteCardAsync(string customerId, string cardId, CardDeleteOptions options = null) => + _cardService.DeleteAsync(customerId, cardId, options); +} diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Billing/Services/Implementations/StripePaymentService.cs similarity index 61% rename from src/Core/Services/Implementations/StripePaymentService.cs rename to src/Core/Billing/Services/Implementations/StripePaymentService.cs index 4c64abc73e..ffc18aa748 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Billing/Services/Implementations/StripePaymentService.cs @@ -8,11 +8,7 @@ using Bit.Core.Billing.Constants; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Models; using Bit.Core.Billing.Organizations.Models; -using Bit.Core.Billing.Premium.Commands; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Tax.Requests; -using Bit.Core.Billing.Tax.Responses; -using Bit.Core.Billing.Tax.Services; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -25,9 +21,9 @@ using Stripe; using PaymentMethod = Stripe.PaymentMethod; using StaticStore = Bit.Core.Models.StaticStore; -namespace Bit.Core.Services; +namespace Bit.Core.Billing.Services.Implementations; -public class StripePaymentService : IPaymentService +public class StripePaymentService : IStripePaymentService { private const string SecretsManagerStandaloneDiscountId = "sm-standalone"; @@ -36,8 +32,6 @@ public class StripePaymentService : IPaymentService private readonly Braintree.IBraintreeGateway _btGateway; private readonly IStripeAdapter _stripeAdapter; private readonly IGlobalSettings _globalSettings; - private readonly IFeatureService _featureService; - private readonly ITaxService _taxService; private readonly IPricingClient _pricingClient; public StripePaymentService( @@ -46,8 +40,6 @@ public class StripePaymentService : IPaymentService IStripeAdapter stripeAdapter, Braintree.IBraintreeGateway braintreeGateway, IGlobalSettings globalSettings, - IFeatureService featureService, - ITaxService taxService, IPricingClient pricingClient) { _transactionRepository = transactionRepository; @@ -55,8 +47,6 @@ public class StripePaymentService : IPaymentService _stripeAdapter = stripeAdapter; _btGateway = braintreeGateway; _globalSettings = globalSettings; - _featureService = featureService; - _taxService = taxService; _pricingClient = pricingClient; } @@ -74,7 +64,7 @@ public class StripePaymentService : IPaymentService await FinalizeSubscriptionChangeAsync(org, subscriptionUpdate, true); - var sub = await _stripeAdapter.SubscriptionGetAsync(org.GatewaySubscriptionId); + var sub = await _stripeAdapter.GetSubscriptionAsync(org.GatewaySubscriptionId); org.ExpirationDate = sub.GetCurrentPeriodEnd(); if (sponsorship is not null) @@ -94,7 +84,7 @@ public class StripePaymentService : IPaymentService { // remember, when in doubt, throw var subGetOptions = new SubscriptionGetOptions { Expand = ["customer.tax", "customer.tax_ids"] }; - var sub = await _stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId, subGetOptions); + var sub = await _stripeAdapter.GetSubscriptionAsync(subscriber.GatewaySubscriptionId, subGetOptions); if (sub == null) { throw new GatewayException("Subscription not found."); @@ -117,7 +107,7 @@ public class StripePaymentService : IPaymentService var subUpdateOptions = new SubscriptionUpdateOptions { Items = updatedItemOptions, - ProrationBehavior = invoiceNow ? Constants.AlwaysInvoice : Constants.CreateProrations, + ProrationBehavior = invoiceNow ? Core.Constants.AlwaysInvoice : Core.Constants.CreateProrations, DaysUntilDue = daysUntilDue ?? 1, CollectionMethod = "send_invoice" }; @@ -131,11 +121,11 @@ public class StripePaymentService : IPaymentService { if (sub.Customer is { - Address.Country: not Constants.CountryAbbreviations.UnitedStates, + Address.Country: not Core.Constants.CountryAbbreviations.UnitedStates, TaxExempt: not StripeConstants.TaxExempt.Reverse }) { - await _stripeAdapter.CustomerUpdateAsync(sub.CustomerId, + await _stripeAdapter.UpdateCustomerAsync(sub.CustomerId, new CustomerUpdateOptions { TaxExempt = StripeConstants.TaxExempt.Reverse }); } @@ -151,9 +141,9 @@ public class StripePaymentService : IPaymentService string paymentIntentClientSecret = null; try { - var subResponse = await _stripeAdapter.SubscriptionUpdateAsync(sub.Id, subUpdateOptions); + var subResponse = await _stripeAdapter.UpdateSubscriptionAsync(sub.Id, subUpdateOptions); - var invoice = await _stripeAdapter.InvoiceGetAsync(subResponse?.LatestInvoiceId, new InvoiceGetOptions()); + var invoice = await _stripeAdapter.GetInvoiceAsync(subResponse?.LatestInvoiceId, new InvoiceGetOptions()); if (invoice == null) { throw new BadRequestException("Unable to locate draft invoice for subscription update."); @@ -172,9 +162,9 @@ public class StripePaymentService : IPaymentService } else { - invoice = await _stripeAdapter.InvoiceFinalizeInvoiceAsync(subResponse.LatestInvoiceId, + invoice = await _stripeAdapter.FinalizeInvoiceAsync(subResponse.LatestInvoiceId, new InvoiceFinalizeOptions { AutoAdvance = false, }); - await _stripeAdapter.InvoiceSendInvoiceAsync(invoice.Id, new InvoiceSendOptions()); + await _stripeAdapter.SendInvoiceAsync(invoice.Id, new InvoiceSendOptions()); paymentIntentClientSecret = null; } } @@ -182,7 +172,7 @@ public class StripePaymentService : IPaymentService catch { // Need to revert the subscription - await _stripeAdapter.SubscriptionUpdateAsync(sub.Id, new SubscriptionUpdateOptions + await _stripeAdapter.UpdateSubscriptionAsync(sub.Id, new SubscriptionUpdateOptions { Items = subscriptionUpdate.RevertItemsOptions(sub), // This proration behavior prevents a false "credit" from @@ -197,7 +187,7 @@ public class StripePaymentService : IPaymentService else if (invoice.Status != StripeConstants.InvoiceStatus.Paid) { // Pay invoice with no charge to the customer this completes the invoice immediately without waiting the scheduled 1h - invoice = await _stripeAdapter.InvoicePayAsync(subResponse.LatestInvoiceId); + invoice = await _stripeAdapter.PayInvoiceAsync(subResponse.LatestInvoiceId); paymentIntentClientSecret = null; } } @@ -206,7 +196,7 @@ public class StripePaymentService : IPaymentService // Change back the subscription collection method and/or days until due if (collectionMethod != "send_invoice" || daysUntilDue == null) { - await _stripeAdapter.SubscriptionUpdateAsync(sub.Id, + await _stripeAdapter.UpdateSubscriptionAsync(sub.Id, new SubscriptionUpdateOptions { CollectionMethod = collectionMethod, @@ -214,14 +204,14 @@ public class StripePaymentService : IPaymentService }); } - var customer = await _stripeAdapter.CustomerGetAsync(sub.CustomerId); + var customer = await _stripeAdapter.GetCustomerAsync(sub.CustomerId); var newCoupon = customer.Discount?.Coupon?.Id; if (!string.IsNullOrEmpty(existingCoupon) && string.IsNullOrEmpty(newCoupon)) { // Re-add the lost coupon due to the update. - await _stripeAdapter.SubscriptionUpdateAsync(sub.Id, new SubscriptionUpdateOptions + await _stripeAdapter.UpdateSubscriptionAsync(sub.Id, new SubscriptionUpdateOptions { Discounts = [ @@ -294,7 +284,7 @@ public class StripePaymentService : IPaymentService { if (!string.IsNullOrWhiteSpace(subscriber.GatewaySubscriptionId)) { - await _stripeAdapter.SubscriptionCancelAsync(subscriber.GatewaySubscriptionId, + await _stripeAdapter.CancelSubscriptionAsync(subscriber.GatewaySubscriptionId, new SubscriptionCancelOptions()); } @@ -303,7 +293,7 @@ public class StripePaymentService : IPaymentService return; } - var customer = await _stripeAdapter.CustomerGetAsync(subscriber.GatewayCustomerId); + var customer = await _stripeAdapter.GetCustomerAsync(subscriber.GatewayCustomerId); if (customer == null) { return; @@ -328,7 +318,7 @@ public class StripePaymentService : IPaymentService } else { - var charges = await _stripeAdapter.ChargeListAsync(new ChargeListOptions + var charges = await _stripeAdapter.ListChargesAsync(new ChargeListOptions { Customer = subscriber.GatewayCustomerId }); @@ -337,12 +327,12 @@ public class StripePaymentService : IPaymentService { foreach (var charge in charges.Data.Where(c => c.Captured && !c.Refunded)) { - await _stripeAdapter.RefundCreateAsync(new RefundCreateOptions { Charge = charge.Id }); + await _stripeAdapter.CreateRefundAsync(new RefundCreateOptions { Charge = charge.Id }); } } } - await _stripeAdapter.CustomerDeleteAsync(subscriber.GatewayCustomerId); + await _stripeAdapter.DeleteCustomerAsync(subscriber.GatewayCustomerId); } public async Task PayInvoiceAfterSubscriptionChangeAsync(ISubscriber subscriber, Invoice invoice) @@ -350,7 +340,7 @@ public class StripePaymentService : IPaymentService var customerOptions = new CustomerGetOptions(); customerOptions.AddExpand("default_source"); customerOptions.AddExpand("invoice_settings.default_payment_method"); - var customer = await _stripeAdapter.CustomerGetAsync(subscriber.GatewayCustomerId, customerOptions); + var customer = await _stripeAdapter.GetCustomerAsync(subscriber.GatewayCustomerId, customerOptions); string paymentIntentClientSecret = null; @@ -370,13 +360,13 @@ public class StripePaymentService : IPaymentService // We're going to delete this draft invoice, it can't be paid try { - await _stripeAdapter.InvoiceDeleteAsync(invoice.Id); + await _stripeAdapter.DeleteInvoiceAsync(invoice.Id); } catch { - await _stripeAdapter.InvoiceFinalizeInvoiceAsync(invoice.Id, + await _stripeAdapter.FinalizeInvoiceAsync(invoice.Id, new InvoiceFinalizeOptions { AutoAdvance = false }); - await _stripeAdapter.InvoiceVoidInvoiceAsync(invoice.Id); + await _stripeAdapter.VoidInvoiceAsync(invoice.Id); } throw new BadRequestException("No payment method is available."); @@ -389,7 +379,7 @@ public class StripePaymentService : IPaymentService { // Finalize the invoice (from Draft) w/o auto-advance so we // can attempt payment manually. - invoice = await _stripeAdapter.InvoiceFinalizeInvoiceAsync(invoice.Id, + invoice = await _stripeAdapter.FinalizeInvoiceAsync(invoice.Id, new InvoiceFinalizeOptions { AutoAdvance = false, }); var invoicePayOptions = new InvoicePayOptions { PaymentMethod = cardPaymentMethodId, }; if (customer?.Metadata?.ContainsKey("btCustomerId") ?? false) @@ -424,7 +414,7 @@ public class StripePaymentService : IPaymentService } braintreeTransaction = transactionResult.Target; - invoice = await _stripeAdapter.InvoiceUpdateAsync(invoice.Id, new InvoiceUpdateOptions + invoice = await _stripeAdapter.UpdateInvoiceAsync(invoice.Id, new InvoiceUpdateOptions { Metadata = new Dictionary { @@ -438,7 +428,7 @@ public class StripePaymentService : IPaymentService try { - invoice = await _stripeAdapter.InvoicePayAsync(invoice.Id, invoicePayOptions); + invoice = await _stripeAdapter.PayInvoiceAsync(invoice.Id, invoicePayOptions); } catch (StripeException e) { @@ -448,7 +438,7 @@ public class StripePaymentService : IPaymentService // SCA required, get intent client secret var invoiceGetOptions = new InvoiceGetOptions(); invoiceGetOptions.AddExpand("confirmation_secret"); - invoice = await _stripeAdapter.InvoiceGetAsync(invoice.Id, invoiceGetOptions); + invoice = await _stripeAdapter.GetInvoiceAsync(invoice.Id, invoiceGetOptions); paymentIntentClientSecret = invoice?.ConfirmationSecret?.ClientSecret; } else @@ -472,7 +462,7 @@ public class StripePaymentService : IPaymentService return paymentIntentClientSecret; } - invoice = await _stripeAdapter.InvoiceVoidInvoiceAsync(invoice.Id, new InvoiceVoidOptions()); + invoice = await _stripeAdapter.VoidInvoiceAsync(invoice.Id, new InvoiceVoidOptions()); // HACK: Workaround for customer balance credit if (invoice.StartingBalance < 0) @@ -480,12 +470,12 @@ public class StripePaymentService : IPaymentService // Customer had a balance applied to this invoice. Since we can't fully trust Stripe to // credit it back to the customer (even though their docs claim they will), we need to // check that balance against the current customer balance and determine if it needs to be re-applied - customer = await _stripeAdapter.CustomerGetAsync(subscriber.GatewayCustomerId, customerOptions); + customer = await _stripeAdapter.GetCustomerAsync(subscriber.GatewayCustomerId, customerOptions); // Assumption: Customer balance should now be $0, otherwise payment would not have failed. if (customer.Balance == 0) { - await _stripeAdapter.CustomerUpdateAsync(customer.Id, + await _stripeAdapter.UpdateCustomerAsync(customer.Id, new CustomerUpdateOptions { Balance = invoice.StartingBalance }); } } @@ -516,7 +506,7 @@ public class StripePaymentService : IPaymentService throw new GatewayException("No subscription."); } - var sub = await _stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId); + var sub = await _stripeAdapter.GetSubscriptionAsync(subscriber.GatewaySubscriptionId); if (sub == null) { throw new GatewayException("Subscription was not found."); @@ -532,9 +522,9 @@ public class StripePaymentService : IPaymentService try { var canceledSub = endOfPeriod - ? await _stripeAdapter.SubscriptionUpdateAsync(sub.Id, + ? await _stripeAdapter.UpdateSubscriptionAsync(sub.Id, new SubscriptionUpdateOptions { CancelAtPeriodEnd = true }) - : await _stripeAdapter.SubscriptionCancelAsync(sub.Id, new SubscriptionCancelOptions()); + : await _stripeAdapter.CancelSubscriptionAsync(sub.Id, new SubscriptionCancelOptions()); if (!canceledSub.CanceledAt.HasValue) { throw new GatewayException("Unable to cancel subscription."); @@ -561,7 +551,7 @@ public class StripePaymentService : IPaymentService throw new GatewayException("No subscription."); } - var sub = await _stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId); + var sub = await _stripeAdapter.GetSubscriptionAsync(subscriber.GatewaySubscriptionId); if (sub == null) { throw new GatewayException("Subscription was not found."); @@ -573,7 +563,7 @@ public class StripePaymentService : IPaymentService throw new GatewayException("Subscription is not marked for cancellation."); } - var updatedSub = await _stripeAdapter.SubscriptionUpdateAsync(sub.Id, + var updatedSub = await _stripeAdapter.UpdateSubscriptionAsync(sub.Id, new SubscriptionUpdateOptions { CancelAtPeriodEnd = false }); if (updatedSub.CanceledAt.HasValue) { @@ -588,11 +578,11 @@ public class StripePaymentService : IPaymentService !string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId); if (customerExists) { - customer = await _stripeAdapter.CustomerGetAsync(subscriber.GatewayCustomerId); + customer = await _stripeAdapter.GetCustomerAsync(subscriber.GatewayCustomerId); } else { - customer = await _stripeAdapter.CustomerCreateAsync(new CustomerCreateOptions + customer = await _stripeAdapter.CreateCustomerAsync(new CustomerCreateOptions { Email = subscriber.BillingEmailAddress(), Description = subscriber.BillingName(), @@ -601,9 +591,8 @@ public class StripePaymentService : IPaymentService subscriber.GatewayCustomerId = customer.Id; } - await _stripeAdapter.CustomerUpdateAsync(customer.Id, + await _stripeAdapter.UpdateCustomerAsync(customer.Id, new CustomerUpdateOptions { Balance = customer.Balance - (long)(creditAmount * 100) }); - return !customerExists; } @@ -640,7 +629,7 @@ public class StripePaymentService : IPaymentService return subscriptionInfo; } - var subscription = await _stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId, + var subscription = await _stripeAdapter.GetSubscriptionAsync(subscriber.GatewaySubscriptionId, new SubscriptionGetOptions { Expand = ["customer.discount.coupon.applies_to", "discounts.coupon.applies_to", "test_clock"] }); if (subscription == null) @@ -685,7 +674,7 @@ public class StripePaymentService : IPaymentService Subscription = subscriber.GatewaySubscriptionId }; - var upcomingInvoice = await _stripeAdapter.InvoiceCreatePreviewAsync(invoiceCreatePreviewOptions); + var upcomingInvoice = await _stripeAdapter.CreateInvoicePreviewAsync(invoiceCreatePreviewOptions); if (upcomingInvoice != null) { @@ -705,133 +694,6 @@ public class StripePaymentService : IPaymentService return subscriptionInfo; } - public async Task GetTaxInfoAsync(ISubscriber subscriber) - { - if (subscriber == null || string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId)) - { - return null; - } - - var customer = await _stripeAdapter.CustomerGetAsync(subscriber.GatewayCustomerId, - new CustomerGetOptions { Expand = ["tax_ids"] }); - - if (customer == null) - { - return null; - } - - var address = customer.Address; - var taxId = customer.TaxIds?.FirstOrDefault(); - - // Line1 is required, so if missing we're using the subscriber name, - // see: https://stripe.com/docs/api/customers/create#create_customer-address-line1 - if (address != null && string.IsNullOrWhiteSpace(address.Line1)) - { - address.Line1 = null; - } - - return new TaxInfo - { - TaxIdNumber = taxId?.Value, - TaxIdType = taxId?.Type, - BillingAddressLine1 = address?.Line1, - BillingAddressLine2 = address?.Line2, - BillingAddressCity = address?.City, - BillingAddressState = address?.State, - BillingAddressPostalCode = address?.PostalCode, - BillingAddressCountry = address?.Country, - }; - } - - public async Task SaveTaxInfoAsync(ISubscriber subscriber, TaxInfo taxInfo) - { - if (string.IsNullOrWhiteSpace(subscriber?.GatewayCustomerId) || subscriber.IsUser()) - { - return; - } - - var customer = await _stripeAdapter.CustomerUpdateAsync(subscriber.GatewayCustomerId, - new CustomerUpdateOptions - { - Address = new AddressOptions - { - Line1 = taxInfo.BillingAddressLine1 ?? string.Empty, - Line2 = taxInfo.BillingAddressLine2, - City = taxInfo.BillingAddressCity, - State = taxInfo.BillingAddressState, - PostalCode = taxInfo.BillingAddressPostalCode, - Country = taxInfo.BillingAddressCountry, - }, - Expand = ["tax_ids"] - }); - - if (customer == null) - { - return; - } - - var taxId = customer.TaxIds?.FirstOrDefault(); - - if (taxId != null) - { - await _stripeAdapter.TaxIdDeleteAsync(customer.Id, taxId.Id); - } - - if (string.IsNullOrWhiteSpace(taxInfo.TaxIdNumber)) - { - return; - } - - var taxIdType = taxInfo.TaxIdType; - - if (string.IsNullOrWhiteSpace(taxIdType)) - { - taxIdType = _taxService.GetStripeTaxCode(taxInfo.BillingAddressCountry, taxInfo.TaxIdNumber); - - if (taxIdType == null) - { - _logger.LogWarning("Could not infer tax ID type in country '{Country}' with tax ID '{TaxID}'.", - taxInfo.BillingAddressCountry, - taxInfo.TaxIdNumber); - throw new BadRequestException("billingTaxIdTypeInferenceError"); - } - } - - try - { - await _stripeAdapter.TaxIdCreateAsync(customer.Id, - new TaxIdCreateOptions { Type = taxInfo.TaxIdType, Value = taxInfo.TaxIdNumber }); - - if (taxInfo.TaxIdType == StripeConstants.TaxIdType.SpanishNIF) - { - await _stripeAdapter.TaxIdCreateAsync(customer.Id, - new TaxIdCreateOptions - { - Type = StripeConstants.TaxIdType.EUVAT, - Value = $"ES{taxInfo.TaxIdNumber}" - }); - } - } - catch (StripeException e) - { - switch (e.StripeError.Code) - { - case StripeConstants.ErrorCodes.TaxIdInvalid: - _logger.LogWarning("Invalid tax ID '{TaxID}' for country '{Country}'.", - taxInfo.TaxIdNumber, - taxInfo.BillingAddressCountry); - throw new BadRequestException("billingInvalidTaxIdError"); - default: - _logger.LogError(e, - "Error creating tax ID '{TaxId}' in country '{Country}' for customer '{CustomerID}'.", - taxInfo.TaxIdNumber, - taxInfo.BillingAddressCountry, - customer.Id); - throw new BadRequestException("billingTaxIdCreationError"); - } - } - } - public async Task AddSecretsManagerToSubscription( Organization org, StaticStore.Plan plan, @@ -863,7 +725,7 @@ public class StripePaymentService : IPaymentService return false; } - var customer = await _stripeAdapter.CustomerGetAsync(gatewayCustomerId); + var customer = await _stripeAdapter.GetCustomerAsync(gatewayCustomerId); return customer?.Discount?.Coupon?.Id == SecretsManagerStandaloneDiscountId; } @@ -875,7 +737,7 @@ public class StripePaymentService : IPaymentService return (null, null); } - var openInvoices = await _stripeAdapter.InvoiceSearchAsync(new InvoiceSearchOptions + var openInvoices = await _stripeAdapter.SearchInvoiceAsync(new InvoiceSearchOptions { Query = $"subscription:'{subscription.Id}' status:'open'" }); @@ -909,312 +771,9 @@ public class StripePaymentService : IPaymentService } } - [Obsolete($"Use {nameof(PreviewPremiumTaxCommand)} instead.")] - public async Task PreviewInvoiceAsync( - PreviewIndividualInvoiceRequestBody parameters, - string gatewayCustomerId, - string gatewaySubscriptionId) - { - var premiumPlan = await _pricingClient.GetAvailablePremiumPlan(); - - var options = new InvoiceCreatePreviewOptions - { - AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true, }, - Currency = "usd", - SubscriptionDetails = new InvoiceSubscriptionDetailsOptions - { - Items = - [ - new InvoiceSubscriptionDetailsItemOptions - { - Quantity = 1, - Plan = premiumPlan.Seat.StripePriceId - }, - - new InvoiceSubscriptionDetailsItemOptions - { - Quantity = parameters.PasswordManager.AdditionalStorage, - Plan = premiumPlan.Storage.StripePriceId - } - ] - }, - CustomerDetails = new InvoiceCustomerDetailsOptions - { - Address = new AddressOptions - { - PostalCode = parameters.TaxInformation.PostalCode, - Country = parameters.TaxInformation.Country, - } - }, - }; - - if (!string.IsNullOrEmpty(parameters.TaxInformation.TaxId)) - { - var taxIdType = _taxService.GetStripeTaxCode( - options.CustomerDetails.Address.Country, - parameters.TaxInformation.TaxId); - - if (taxIdType == null) - { - _logger.LogWarning("Invalid tax ID '{TaxID}' for country '{Country}'.", - parameters.TaxInformation.TaxId, - parameters.TaxInformation.Country); - throw new BadRequestException("billingPreviewInvalidTaxIdError"); - } - - options.CustomerDetails.TaxIds = - [ - new InvoiceCustomerDetailsTaxIdOptions { Type = taxIdType, Value = parameters.TaxInformation.TaxId } - ]; - - if (taxIdType == StripeConstants.TaxIdType.SpanishNIF) - { - options.CustomerDetails.TaxIds.Add(new InvoiceCustomerDetailsTaxIdOptions - { - Type = StripeConstants.TaxIdType.EUVAT, - Value = $"ES{parameters.TaxInformation.TaxId}" - }); - } - } - - if (!string.IsNullOrWhiteSpace(gatewayCustomerId)) - { - var gatewayCustomer = await _stripeAdapter.CustomerGetAsync(gatewayCustomerId); - - if (gatewayCustomer.Discount != null) - { - options.Discounts = [new InvoiceDiscountOptions { Coupon = gatewayCustomer.Discount.Coupon.Id }]; - } - } - - if (!string.IsNullOrWhiteSpace(gatewaySubscriptionId)) - { - var gatewaySubscription = await _stripeAdapter.SubscriptionGetAsync(gatewaySubscriptionId); - - if (gatewaySubscription?.Discounts is { Count: > 0 }) - { - options.Discounts = gatewaySubscription.Discounts.Select(x => new InvoiceDiscountOptions { Coupon = x.Coupon.Id }).ToList(); - } - } - - if (options.Discounts is { Count: > 0 }) - { - options.Discounts = options.Discounts.DistinctBy(invoiceDiscountOptions => invoiceDiscountOptions.Coupon).ToList(); - } - - try - { - var invoice = await _stripeAdapter.InvoiceCreatePreviewAsync(options); - - var tax = invoice.TotalTaxes.Sum(invoiceTotalTax => invoiceTotalTax.Amount); - - var effectiveTaxRate = invoice.TotalExcludingTax != null && invoice.TotalExcludingTax.Value != 0 - ? tax.ToMajor() / invoice.TotalExcludingTax.Value.ToMajor() - : 0M; - - var result = new PreviewInvoiceResponseModel( - effectiveTaxRate, - invoice.TotalExcludingTax.ToMajor() ?? 0, - tax.ToMajor(), - invoice.Total.ToMajor()); - return result; - } - catch (StripeException e) - { - switch (e.StripeError.Code) - { - case StripeConstants.ErrorCodes.TaxIdInvalid: - _logger.LogWarning("Invalid tax ID '{TaxID}' for country '{Country}'.", - parameters.TaxInformation.TaxId, - parameters.TaxInformation.Country); - throw new BadRequestException("billingPreviewInvalidTaxIdError"); - default: - _logger.LogError(e, - "Unexpected error previewing invoice with tax ID '{TaxId}' in country '{Country}'.", - parameters.TaxInformation.TaxId, - parameters.TaxInformation.Country); - throw new BadRequestException("billingPreviewInvoiceError"); - } - } - } - - public async Task PreviewInvoiceAsync( - PreviewOrganizationInvoiceRequestBody parameters, - string gatewayCustomerId, - string gatewaySubscriptionId) - { - var plan = await _pricingClient.GetPlanOrThrow(parameters.PasswordManager.Plan); - var isSponsored = parameters.PasswordManager.SponsoredPlan.HasValue; - - var options = new InvoiceCreatePreviewOptions - { - Currency = "usd", - SubscriptionDetails = new InvoiceSubscriptionDetailsOptions - { - Items = - [ - new InvoiceSubscriptionDetailsItemOptions - { - Quantity = parameters.PasswordManager.AdditionalStorage, - Plan = plan.PasswordManager.StripeStoragePlanId - } - ] - }, - CustomerDetails = new InvoiceCustomerDetailsOptions - { - Address = new AddressOptions - { - PostalCode = parameters.TaxInformation.PostalCode, - Country = parameters.TaxInformation.Country, - } - }, - }; - - if (isSponsored) - { - var sponsoredPlan = SponsoredPlans.Get(parameters.PasswordManager.SponsoredPlan.Value); - options.SubscriptionDetails.Items.Add( - new InvoiceSubscriptionDetailsItemOptions { Quantity = 1, Plan = sponsoredPlan.StripePlanId } - ); - } - else - { - if (plan.PasswordManager.HasAdditionalSeatsOption) - { - options.SubscriptionDetails.Items.Add( - new InvoiceSubscriptionDetailsItemOptions { Quantity = parameters.PasswordManager.Seats, Plan = plan.PasswordManager.StripeSeatPlanId } - ); - } - else - { - options.SubscriptionDetails.Items.Add( - new InvoiceSubscriptionDetailsItemOptions { Quantity = 1, Plan = plan.PasswordManager.StripePlanId } - ); - } - - if (plan.SupportsSecretsManager) - { - if (plan.SecretsManager.HasAdditionalSeatsOption) - { - options.SubscriptionDetails.Items.Add(new InvoiceSubscriptionDetailsItemOptions - { - Quantity = parameters.SecretsManager?.Seats ?? 0, - Plan = plan.SecretsManager.StripeSeatPlanId - }); - } - - if (plan.SecretsManager.HasAdditionalServiceAccountOption) - { - options.SubscriptionDetails.Items.Add(new InvoiceSubscriptionDetailsItemOptions - { - Quantity = parameters.SecretsManager?.AdditionalMachineAccounts ?? 0, - Plan = plan.SecretsManager.StripeServiceAccountPlanId - }); - } - } - } - - if (!string.IsNullOrWhiteSpace(parameters.TaxInformation.TaxId)) - { - var taxIdType = _taxService.GetStripeTaxCode( - options.CustomerDetails.Address.Country, - parameters.TaxInformation.TaxId); - - if (taxIdType == null) - { - _logger.LogWarning("Invalid tax ID '{TaxID}' for country '{Country}'.", - parameters.TaxInformation.TaxId, - parameters.TaxInformation.Country); - throw new BadRequestException("billingTaxIdTypeInferenceError"); - } - - options.CustomerDetails.TaxIds = - [ - new InvoiceCustomerDetailsTaxIdOptions { Type = taxIdType, Value = parameters.TaxInformation.TaxId } - ]; - - if (taxIdType == StripeConstants.TaxIdType.SpanishNIF) - { - options.CustomerDetails.TaxIds.Add(new InvoiceCustomerDetailsTaxIdOptions - { - Type = StripeConstants.TaxIdType.EUVAT, - Value = $"ES{parameters.TaxInformation.TaxId}" - }); - } - } - - Customer gatewayCustomer = null; - - if (!string.IsNullOrWhiteSpace(gatewayCustomerId)) - { - gatewayCustomer = await _stripeAdapter.CustomerGetAsync(gatewayCustomerId); - - if (gatewayCustomer.Discount != null) - { - options.Discounts = - [ - new InvoiceDiscountOptions { Coupon = gatewayCustomer.Discount.Coupon.Id } - ]; - } - } - - if (!string.IsNullOrWhiteSpace(gatewaySubscriptionId)) - { - var gatewaySubscription = await _stripeAdapter.SubscriptionGetAsync(gatewaySubscriptionId); - - if (gatewaySubscription?.Discounts != null) - { - options.Discounts = gatewaySubscription.Discounts - .Select(discount => new InvoiceDiscountOptions { Coupon = discount.Coupon.Id }).ToList(); - } - } - - options.AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true }; - if (parameters.PasswordManager.Plan.IsBusinessProductTierType() && - parameters.TaxInformation.Country != Constants.CountryAbbreviations.UnitedStates) - { - options.CustomerDetails.TaxExempt = StripeConstants.TaxExempt.Reverse; - } - - try - { - var invoice = await _stripeAdapter.InvoiceCreatePreviewAsync(options); - - var tax = invoice.TotalTaxes.Sum(invoiceTotalTax => invoiceTotalTax.Amount); - - var effectiveTaxRate = invoice.TotalExcludingTax != null && invoice.TotalExcludingTax.Value != 0 - ? tax.ToMajor() / invoice.TotalExcludingTax.Value.ToMajor() - : 0M; - - var result = new PreviewInvoiceResponseModel( - effectiveTaxRate, - invoice.TotalExcludingTax.ToMajor() ?? 0, - tax.ToMajor(), - invoice.Total.ToMajor()); - return result; - } - catch (StripeException e) - { - switch (e.StripeError.Code) - { - case StripeConstants.ErrorCodes.TaxIdInvalid: - _logger.LogWarning("Invalid tax ID '{TaxID}' for country '{Country}'.", - parameters.TaxInformation.TaxId, - parameters.TaxInformation.Country); - throw new BadRequestException("billingPreviewInvalidTaxIdError"); - default: - _logger.LogError(e, - "Unexpected error previewing invoice with tax ID '{TaxId}' in country '{Country}'.", - parameters.TaxInformation.TaxId, - parameters.TaxInformation.Country); - throw new BadRequestException("billingPreviewInvoiceError"); - } - } - } - private PaymentMethod GetLatestCardPaymentMethod(string customerId) { - var cardPaymentMethods = _stripeAdapter.PaymentMethodListAutoPaging( + var cardPaymentMethods = _stripeAdapter.ListPaymentMethodsAutoPaging( new PaymentMethodListOptions { Customer = customerId, Type = "card" }); return cardPaymentMethods.OrderByDescending(m => m.Created).FirstOrDefault(); } @@ -1277,7 +836,7 @@ public class StripePaymentService : IPaymentService Customer customer = null; try { - customer = await _stripeAdapter.CustomerGetAsync(gatewayCustomerId, options); + customer = await _stripeAdapter.GetCustomerAsync(gatewayCustomerId, options); } catch (StripeException) { @@ -1310,21 +869,21 @@ public class StripePaymentService : IPaymentService try { - var paidInvoicesTask = _stripeAdapter.InvoiceListAsync(new StripeInvoiceListOptions + var paidInvoicesTask = _stripeAdapter.ListInvoicesAsync(new StripeInvoiceListOptions { Customer = customer.Id, SelectAll = !limit.HasValue, Limit = limit, Status = "paid" }); - var openInvoicesTask = _stripeAdapter.InvoiceListAsync(new StripeInvoiceListOptions + var openInvoicesTask = _stripeAdapter.ListInvoicesAsync(new StripeInvoiceListOptions { Customer = customer.Id, SelectAll = !limit.HasValue, Limit = limit, Status = "open" }); - var uncollectibleInvoicesTask = _stripeAdapter.InvoiceListAsync(new StripeInvoiceListOptions + var uncollectibleInvoicesTask = _stripeAdapter.ListInvoicesAsync(new StripeInvoiceListOptions { Customer = customer.Id, SelectAll = !limit.HasValue, diff --git a/src/Core/Services/Implementations/StripeSyncService.cs b/src/Core/Billing/Services/Implementations/StripeSyncService.cs similarity index 68% rename from src/Core/Services/Implementations/StripeSyncService.cs rename to src/Core/Billing/Services/Implementations/StripeSyncService.cs index b2700e65d1..31dd89d72d 100644 --- a/src/Core/Services/Implementations/StripeSyncService.cs +++ b/src/Core/Billing/Services/Implementations/StripeSyncService.cs @@ -1,6 +1,6 @@ using Bit.Core.Exceptions; -namespace Bit.Core.Services; +namespace Bit.Core.Billing.Services.Implementations; public class StripeSyncService : IStripeSyncService { @@ -11,7 +11,7 @@ public class StripeSyncService : IStripeSyncService _stripeAdapter = stripeAdapter; } - public async Task UpdateCustomerEmailAddress(string gatewayCustomerId, string emailAddress) + public async Task UpdateCustomerEmailAddressAsync(string gatewayCustomerId, string emailAddress) { if (string.IsNullOrWhiteSpace(gatewayCustomerId)) { @@ -23,9 +23,9 @@ public class StripeSyncService : IStripeSyncService throw new InvalidEmailException(); } - var customer = await _stripeAdapter.CustomerGetAsync(gatewayCustomerId); + var customer = await _stripeAdapter.GetCustomerAsync(gatewayCustomerId); - await _stripeAdapter.CustomerUpdateAsync(customer.Id, + await _stripeAdapter.UpdateCustomerAsync(customer.Id, new Stripe.CustomerUpdateOptions { Email = emailAddress }); } } diff --git a/src/Core/Billing/Services/Implementations/SubscriberService.cs b/src/Core/Billing/Services/Implementations/SubscriberService.cs index 8e75bf3dca..7acbe20014 100644 --- a/src/Core/Billing/Services/Implementations/SubscriberService.cs +++ b/src/Core/Billing/Services/Implementations/SubscriberService.cs @@ -15,7 +15,6 @@ using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; -using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Utilities; using Braintree; @@ -24,7 +23,6 @@ using Stripe; using static Bit.Core.Billing.Utilities; using Customer = Stripe.Customer; -using PaymentMethod = Bit.Core.Billing.Models.PaymentMethod; using Subscription = Stripe.Subscription; namespace Bit.Core.Billing.Services.Implementations; @@ -79,7 +77,7 @@ public class SubscriberService( { if (subscription.Metadata != null && subscription.Metadata.ContainsKey("organizationId")) { - await stripeAdapter.SubscriptionUpdateAsync(subscription.Id, new SubscriptionUpdateOptions + await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, new SubscriptionUpdateOptions { Metadata = metadata }); @@ -98,7 +96,7 @@ public class SubscriberService( options.CancellationDetails.Feedback = offboardingSurveyResponse.Reason; } - await stripeAdapter.SubscriptionCancelAsync(subscription.Id, options); + await stripeAdapter.CancelSubscriptionAsync(subscription.Id, options); } else { @@ -117,7 +115,7 @@ public class SubscriberService( options.CancellationDetails.Feedback = offboardingSurveyResponse.Reason; } - await stripeAdapter.SubscriptionUpdateAsync(subscription.Id, options); + await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, options); } } @@ -228,7 +226,7 @@ public class SubscriberService( _ => throw new ArgumentOutOfRangeException(nameof(subscriber)) }; - var customer = await stripeAdapter.CustomerCreateAsync(options); + var customer = await stripeAdapter.CreateCustomerAsync(options); switch (subscriber) { @@ -271,7 +269,7 @@ public class SubscriberService( try { - var customer = await stripeAdapter.CustomerGetAsync(subscriber.GatewayCustomerId, customerGetOptions); + var customer = await stripeAdapter.GetCustomerAsync(subscriber.GatewayCustomerId, customerGetOptions); if (customer != null) { @@ -307,7 +305,7 @@ public class SubscriberService( try { - var customer = await stripeAdapter.CustomerGetAsync(subscriber.GatewayCustomerId, customerGetOptions); + var customer = await stripeAdapter.GetCustomerAsync(subscriber.GatewayCustomerId, customerGetOptions); if (customer != null) { @@ -330,38 +328,6 @@ public class SubscriberService( } } - public async Task GetPaymentMethod( - ISubscriber subscriber) - { - ArgumentNullException.ThrowIfNull(subscriber); - - var customer = await GetCustomer(subscriber, new CustomerGetOptions - { - Expand = ["default_source", "invoice_settings.default_payment_method", "subscriptions", "tax_ids"] - }); - - if (customer == null) - { - return PaymentMethod.Empty; - } - - var accountCredit = customer.Balance * -1 / 100M; - - var paymentMethod = await GetPaymentSourceAsync(subscriber.Id, customer); - - var subscriptionStatus = customer.Subscriptions - .FirstOrDefault(subscription => subscription.Id == subscriber.GatewaySubscriptionId)? - .Status; - - var taxInformation = GetTaxInformation(customer); - - return new PaymentMethod( - accountCredit, - paymentMethod, - subscriptionStatus, - taxInformation); - } - public async Task GetPaymentSource( ISubscriber subscriber) { @@ -390,7 +356,7 @@ public class SubscriberService( try { - var subscription = await stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId, subscriptionGetOptions); + var subscription = await stripeAdapter.GetSubscriptionAsync(subscriber.GatewaySubscriptionId, subscriptionGetOptions); if (subscription != null) { @@ -426,7 +392,7 @@ public class SubscriberService( try { - var subscription = await stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId, subscriptionGetOptions); + var subscription = await stripeAdapter.GetSubscriptionAsync(subscriber.GatewaySubscriptionId, subscriptionGetOptions); if (subscription != null) { @@ -449,16 +415,6 @@ public class SubscriberService( } } - public async Task GetTaxInformation( - ISubscriber subscriber) - { - ArgumentNullException.ThrowIfNull(subscriber); - - var customer = await GetCustomerOrThrow(subscriber, new CustomerGetOptions { Expand = ["tax_ids"] }); - - return GetTaxInformation(customer); - } - public async Task RemovePaymentSource( ISubscriber subscriber) { @@ -530,23 +486,23 @@ public class SubscriberService( switch (source) { case BankAccount: - await stripeAdapter.BankAccountDeleteAsync(stripeCustomer.Id, source.Id); + await stripeAdapter.DeleteBankAccountAsync(stripeCustomer.Id, source.Id); break; case Card: - await stripeAdapter.CardDeleteAsync(stripeCustomer.Id, source.Id); + await stripeAdapter.DeleteCardAsync(stripeCustomer.Id, source.Id); break; } } } - var paymentMethods = stripeAdapter.PaymentMethodListAutoPagingAsync(new PaymentMethodListOptions + var paymentMethods = stripeAdapter.ListPaymentMethodsAutoPagingAsync(new PaymentMethodListOptions { Customer = stripeCustomer.Id }); await foreach (var paymentMethod in paymentMethods) { - await stripeAdapter.PaymentMethodDetachAsync(paymentMethod.Id); + await stripeAdapter.DetachPaymentMethodAsync(paymentMethod.Id); } } } @@ -575,7 +531,7 @@ public class SubscriberService( { case PaymentMethodType.BankAccount: { - var getSetupIntentsForUpdatedPaymentMethod = stripeAdapter.SetupIntentList(new SetupIntentListOptions + var getSetupIntentsForUpdatedPaymentMethod = stripeAdapter.ListSetupIntentsAsync(new SetupIntentListOptions { PaymentMethod = token }); @@ -612,7 +568,7 @@ public class SubscriberService( await RemoveStripePaymentMethodsAsync(customer); // Attach the incoming payment method. - await stripeAdapter.PaymentMethodAttachAsync(token, + await stripeAdapter.AttachPaymentMethodAsync(token, new PaymentMethodAttachOptions { Customer = subscriber.GatewayCustomerId }); var metadata = customer.Metadata; @@ -624,7 +580,7 @@ public class SubscriberService( } // Set the customer's default payment method in Stripe and remove their Braintree customer ID. - await stripeAdapter.CustomerUpdateAsync(subscriber.GatewayCustomerId, new CustomerUpdateOptions + await stripeAdapter.UpdateCustomerAsync(subscriber.GatewayCustomerId, new CustomerUpdateOptions { InvoiceSettings = new CustomerInvoiceSettingsOptions { @@ -687,7 +643,7 @@ public class SubscriberService( Expand = ["subscriptions", "tax", "tax_ids"] }); - customer = await stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions + customer = await stripeAdapter.UpdateCustomerAsync(customer.Id, new CustomerUpdateOptions { Address = new AddressOptions { @@ -705,7 +661,7 @@ public class SubscriberService( if (taxId != null) { - await stripeAdapter.TaxIdDeleteAsync(customer.Id, taxId.Id); + await stripeAdapter.DeleteTaxIdAsync(customer.Id, taxId.Id); } if (!string.IsNullOrWhiteSpace(taxInformation.TaxId)) @@ -728,12 +684,12 @@ public class SubscriberService( try { - await stripeAdapter.TaxIdCreateAsync(customer.Id, + await stripeAdapter.CreateTaxIdAsync(customer.Id, new TaxIdCreateOptions { Type = taxIdType, Value = taxInformation.TaxId }); if (taxIdType == StripeConstants.TaxIdType.SpanishNIF) { - await stripeAdapter.TaxIdCreateAsync(customer.Id, + await stripeAdapter.CreateTaxIdAsync(customer.Id, new TaxIdCreateOptions { Type = StripeConstants.TaxIdType.EUVAT, Value = $"ES{taxInformation.TaxId}" }); } } @@ -779,7 +735,7 @@ public class SubscriberService( Address.Country: not Core.Constants.CountryAbbreviations.UnitedStates, TaxExempt: not TaxExempt.Reverse }: - await stripeAdapter.CustomerUpdateAsync(customer.Id, + await stripeAdapter.UpdateCustomerAsync(customer.Id, new CustomerUpdateOptions { TaxExempt = TaxExempt.Reverse }); break; case @@ -787,14 +743,14 @@ public class SubscriberService( Address.Country: Core.Constants.CountryAbbreviations.UnitedStates, TaxExempt: TaxExempt.Reverse }: - await stripeAdapter.CustomerUpdateAsync(customer.Id, + await stripeAdapter.UpdateCustomerAsync(customer.Id, new CustomerUpdateOptions { TaxExempt = TaxExempt.None }); break; } if (!subscription.AutomaticTax.Enabled) { - await stripeAdapter.SubscriptionUpdateAsync(subscription.Id, + await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, new SubscriptionUpdateOptions { AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true } @@ -814,7 +770,7 @@ public class SubscriberService( if (automaticTaxShouldBeEnabled && !subscription.AutomaticTax.Enabled) { - await stripeAdapter.SubscriptionUpdateAsync(subscription.Id, + await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, new SubscriptionUpdateOptions { AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true } @@ -823,57 +779,6 @@ public class SubscriberService( } } - public async Task VerifyBankAccount( - ISubscriber subscriber, - string descriptorCode) - { - var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(subscriber.Id); - - if (string.IsNullOrEmpty(setupIntentId)) - { - logger.LogError("No setup intent ID exists to verify for subscriber with ID ({SubscriberID})", subscriber.Id); - throw new BillingException(); - } - - try - { - await stripeAdapter.SetupIntentVerifyMicroDeposit(setupIntentId, - new SetupIntentVerifyMicrodepositsOptions { DescriptorCode = descriptorCode }); - - var setupIntent = await stripeAdapter.SetupIntentGet(setupIntentId); - - await stripeAdapter.PaymentMethodAttachAsync(setupIntent.PaymentMethodId, - new PaymentMethodAttachOptions { Customer = subscriber.GatewayCustomerId }); - - await stripeAdapter.CustomerUpdateAsync(subscriber.GatewayCustomerId, - new CustomerUpdateOptions - { - InvoiceSettings = new CustomerInvoiceSettingsOptions - { - DefaultPaymentMethod = setupIntent.PaymentMethodId - } - }); - } - catch (StripeException stripeException) - { - if (!string.IsNullOrEmpty(stripeException.StripeError?.Code)) - { - var message = stripeException.StripeError.Code switch - { - StripeConstants.ErrorCodes.PaymentMethodMicroDepositVerificationAttemptsExceeded => "You have exceeded the number of allowed verification attempts. Please contact support.", - StripeConstants.ErrorCodes.PaymentMethodMicroDepositVerificationDescriptorCodeMismatch => "The verification code you provided does not match the one sent to your bank account. Please try again.", - StripeConstants.ErrorCodes.PaymentMethodMicroDepositVerificationTimeout => "Your bank account was not verified within the required time period. Please contact support.", - _ => BillingException.DefaultMessage - }; - - throw new BadRequestException(message); - } - - logger.LogError(stripeException, "An unhandled Stripe exception was thrown while verifying subscriber's ({SubscriberID}) bank account", subscriber.Id); - throw new BillingException(); - } - } - public async Task IsValidGatewayCustomerIdAsync(ISubscriber subscriber) { ArgumentNullException.ThrowIfNull(subscriber); @@ -884,7 +789,7 @@ public class SubscriberService( } try { - await stripeAdapter.CustomerGetAsync(subscriber.GatewayCustomerId); + await stripeAdapter.GetCustomerAsync(subscriber.GatewayCustomerId); return true; } catch (StripeException e) when (e.StripeError.Code == "resource_missing") @@ -903,7 +808,7 @@ public class SubscriberService( } try { - await stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId); + await stripeAdapter.GetSubscriptionAsync(subscriber.GatewaySubscriptionId); return true; } catch (StripeException e) when (e.StripeError.Code == "resource_missing") @@ -922,7 +827,7 @@ public class SubscriberService( metadata[BraintreeCustomerIdKey] = braintreeCustomerId; - await stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions + await stripeAdapter.UpdateCustomerAsync(customer.Id, new CustomerUpdateOptions { Metadata = metadata }); @@ -962,7 +867,7 @@ public class SubscriberService( return null; } - var setupIntent = await stripeAdapter.SetupIntentGet(setupIntentId, new SetupIntentGetOptions + var setupIntent = await stripeAdapter.GetSetupIntentAsync(setupIntentId, new SetupIntentGetOptions { Expand = ["payment_method"] }); @@ -970,25 +875,6 @@ public class SubscriberService( return PaymentSource.From(setupIntent); } - private static TaxInformation GetTaxInformation( - Customer customer) - { - if (customer.Address == null) - { - return null; - } - - return new TaxInformation( - customer.Address.Country, - customer.Address.PostalCode, - customer.TaxIds?.FirstOrDefault()?.Value, - customer.TaxIds?.FirstOrDefault()?.Type, - customer.Address.Line1, - customer.Address.Line2, - customer.Address.City, - customer.Address.State); - } - private async Task RemoveBraintreeCustomerIdAsync( Customer customer) { @@ -999,7 +885,7 @@ public class SubscriberService( metadata[BraintreeCustomerIdOldKey] = value; metadata[BraintreeCustomerIdKey] = null; - await stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions + await stripeAdapter.UpdateCustomerAsync(customer.Id, new CustomerUpdateOptions { Metadata = metadata }); @@ -1016,18 +902,18 @@ public class SubscriberService( switch (source) { case BankAccount: - await stripeAdapter.BankAccountDeleteAsync(customer.Id, source.Id); + await stripeAdapter.DeleteBankAccountAsync(customer.Id, source.Id); break; case Card: - await stripeAdapter.CardDeleteAsync(customer.Id, source.Id); + await stripeAdapter.DeleteCardAsync(customer.Id, source.Id); break; } } } - var paymentMethods = await stripeAdapter.CustomerListPaymentMethods(customer.Id); + var paymentMethods = await stripeAdapter.ListCustomerPaymentMethodsAsync(customer.Id); - await Task.WhenAll(paymentMethods.Select(pm => stripeAdapter.PaymentMethodDetachAsync(pm.Id))); + await Task.WhenAll(paymentMethods.Select(pm => stripeAdapter.DetachPaymentMethodAsync(pm.Id))); } private async Task ReplaceBraintreePaymentMethodAsync( diff --git a/src/Core/Billing/Subscriptions/Commands/ReinstateSubscriptionCommand.cs b/src/Core/Billing/Subscriptions/Commands/ReinstateSubscriptionCommand.cs new file mode 100644 index 0000000000..e7d988a107 --- /dev/null +++ b/src/Core/Billing/Subscriptions/Commands/ReinstateSubscriptionCommand.cs @@ -0,0 +1,42 @@ +using Bit.Core.Billing.Commands; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Services; +using Bit.Core.Entities; +using Microsoft.Extensions.Logging; +using OneOf.Types; +using Stripe; + +namespace Bit.Core.Billing.Subscriptions.Commands; + +using static StripeConstants; + +public interface IReinstateSubscriptionCommand +{ + Task> Run(ISubscriber subscriber); +} + +public class ReinstateSubscriptionCommand( + ILogger logger, + IStripeAdapter stripeAdapter) : BaseBillingCommand(logger), IReinstateSubscriptionCommand +{ + public Task> Run(ISubscriber subscriber) => HandleAsync(async () => + { + var subscription = await stripeAdapter.GetSubscriptionAsync(subscriber.GatewaySubscriptionId); + + if (subscription is not + { + Status: SubscriptionStatus.Trialing or SubscriptionStatus.Active, + CancelAt: not null + }) + { + return new BadRequest("Subscription is not pending cancellation."); + } + + await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, new SubscriptionUpdateOptions + { + CancelAtPeriodEnd = false + }); + + return new None(); + }); +} diff --git a/src/Core/Billing/Subscriptions/Commands/RestartSubscriptionCommand.cs b/src/Core/Billing/Subscriptions/Commands/RestartSubscriptionCommand.cs index ee60597601..165b8218a9 100644 --- a/src/Core/Billing/Subscriptions/Commands/RestartSubscriptionCommand.cs +++ b/src/Core/Billing/Subscriptions/Commands/RestartSubscriptionCommand.cs @@ -1,13 +1,13 @@ using Bit.Core.AdminConsole.Entities; -using Bit.Core.AdminConsole.Entities.Provider; -using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Commands; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Extensions; +using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Entities; +using Bit.Core.Exceptions; using Bit.Core.Repositories; -using Bit.Core.Services; +using Microsoft.Extensions.Logging; using OneOf.Types; using Stripe; @@ -22,14 +22,14 @@ public interface IRestartSubscriptionCommand } public class RestartSubscriptionCommand( + ILogger logger, IOrganizationRepository organizationRepository, - IProviderRepository providerRepository, + IPricingClient pricingClient, IStripeAdapter stripeAdapter, - ISubscriberService subscriberService, - IUserRepository userRepository) : IRestartSubscriptionCommand + ISubscriberService subscriberService) : BaseBillingCommand(logger), IRestartSubscriptionCommand { - public async Task> Run( - ISubscriber subscriber) + public Task> Run( + ISubscriber subscriber) => HandleAsync(async () => { var existingSubscription = await subscriberService.GetSubscription(subscriber); @@ -38,56 +38,147 @@ public class RestartSubscriptionCommand( return new BadRequest("Cannot restart a subscription that is not canceled."); } + await RestartSubscriptionAsync(subscriber, existingSubscription); + + return new None(); + }); + + private Task RestartSubscriptionAsync( + ISubscriber subscriber, + Subscription canceledSubscription) => subscriber switch + { + Organization organization => RestartOrganizationSubscriptionAsync(organization, canceledSubscription), + _ => throw new NotSupportedException("Only organization subscriptions can be restarted") + }; + + private async Task RestartOrganizationSubscriptionAsync( + Organization organization, + Subscription canceledSubscription) + { + var plans = await pricingClient.ListPlans(); + + var oldPlan = plans.FirstOrDefault(plan => plan.Type == organization.PlanType); + + if (oldPlan == null) + { + throw new ConflictException("Could not find plan for organization's plan type"); + } + + var newPlan = oldPlan.Disabled + ? plans.FirstOrDefault(plan => + plan.ProductTier == oldPlan.ProductTier && + plan.IsAnnual == oldPlan.IsAnnual && + !plan.Disabled) + : oldPlan; + + if (newPlan == null) + { + throw new ConflictException("Could not find the current, enabled plan for organization's tier and cadence"); + } + + if (newPlan.Type != oldPlan.Type) + { + organization.PlanType = newPlan.Type; + organization.Plan = newPlan.Name; + organization.SelfHost = newPlan.HasSelfHost; + organization.UsePolicies = newPlan.HasPolicies; + organization.UseGroups = newPlan.HasGroups; + organization.UseDirectory = newPlan.HasDirectory; + organization.UseEvents = newPlan.HasEvents; + organization.UseTotp = newPlan.HasTotp; + organization.Use2fa = newPlan.Has2fa; + organization.UseApi = newPlan.HasApi; + organization.UseSso = newPlan.HasSso; + organization.UseOrganizationDomains = newPlan.HasOrganizationDomains; + organization.UseKeyConnector = newPlan.HasKeyConnector; + organization.UseScim = newPlan.HasScim; + organization.UseResetPassword = newPlan.HasResetPassword; + organization.UsersGetPremium = newPlan.UsersGetPremium; + organization.UseCustomPermissions = newPlan.HasCustomPermissions; + } + + var items = new List(); + + // Password Manager + var passwordManagerItem = canceledSubscription.Items.FirstOrDefault(item => + item.Price.Id == (oldPlan.HasNonSeatBasedPasswordManagerPlan() + ? oldPlan.PasswordManager.StripePlanId + : oldPlan.PasswordManager.StripeSeatPlanId)); + + if (passwordManagerItem == null) + { + throw new ConflictException("Organization's subscription does not have a Password Manager subscription item."); + } + + items.Add(new SubscriptionItemOptions + { + Price = newPlan.HasNonSeatBasedPasswordManagerPlan() ? newPlan.PasswordManager.StripePlanId : newPlan.PasswordManager.StripeSeatPlanId, + Quantity = passwordManagerItem.Quantity + }); + + // Storage + var storageItem = canceledSubscription.Items.FirstOrDefault( + item => item.Price.Id == oldPlan.PasswordManager.StripeStoragePlanId); + + if (storageItem != null) + { + items.Add(new SubscriptionItemOptions + { + Price = newPlan.PasswordManager.StripeStoragePlanId, + Quantity = storageItem.Quantity + }); + } + + // Secrets Manager & Service Accounts + var secretsManagerItem = oldPlan.SecretsManager != null + ? canceledSubscription.Items.FirstOrDefault(item => + item.Price.Id == oldPlan.SecretsManager.StripeSeatPlanId) + : null; + + var serviceAccountsItem = oldPlan.SecretsManager != null + ? canceledSubscription.Items.FirstOrDefault(item => + item.Price.Id == oldPlan.SecretsManager.StripeServiceAccountPlanId) + : null; + + if (newPlan.SecretsManager != null) + { + if (secretsManagerItem != null) + { + items.Add(new SubscriptionItemOptions + { + Price = newPlan.SecretsManager.StripeSeatPlanId, + Quantity = secretsManagerItem.Quantity + }); + } + + if (serviceAccountsItem != null) + { + items.Add(new SubscriptionItemOptions + { + Price = newPlan.SecretsManager.StripeServiceAccountPlanId, + Quantity = serviceAccountsItem.Quantity + }); + } + } + var options = new SubscriptionCreateOptions { AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }, CollectionMethod = CollectionMethod.ChargeAutomatically, - Customer = existingSubscription.CustomerId, - Items = existingSubscription.Items.Select(subscriptionItem => new SubscriptionItemOptions - { - Price = subscriptionItem.Price.Id, - Quantity = subscriptionItem.Quantity - }).ToList(), - Metadata = existingSubscription.Metadata, + Customer = canceledSubscription.CustomerId, + Items = items, + Metadata = canceledSubscription.Metadata, OffSession = true, TrialPeriodDays = 0 }; - var subscription = await stripeAdapter.SubscriptionCreateAsync(options); - await EnableAsync(subscriber, subscription); - return new None(); - } + var subscription = await stripeAdapter.CreateSubscriptionAsync(options); - private async Task EnableAsync(ISubscriber subscriber, Subscription subscription) - { - switch (subscriber) - { - case Organization organization: - { - organization.GatewaySubscriptionId = subscription.Id; - organization.Enabled = true; - organization.ExpirationDate = subscription.GetCurrentPeriodEnd(); - organization.RevisionDate = DateTime.UtcNow; - await organizationRepository.ReplaceAsync(organization); - break; - } - case Provider provider: - { - provider.GatewaySubscriptionId = subscription.Id; - provider.Enabled = true; - provider.RevisionDate = DateTime.UtcNow; - await providerRepository.ReplaceAsync(provider); - break; - } - case User user: - { - user.GatewaySubscriptionId = subscription.Id; - user.Premium = true; - user.PremiumExpirationDate = subscription.GetCurrentPeriodEnd(); - user.RevisionDate = DateTime.UtcNow; - await userRepository.ReplaceAsync(user); - break; - } - } + organization.GatewaySubscriptionId = subscription.Id; + organization.Enabled = true; + organization.ExpirationDate = subscription.GetCurrentPeriodEnd(); + organization.RevisionDate = DateTime.UtcNow; + + await organizationRepository.ReplaceAsync(organization); } } diff --git a/src/Core/Billing/Subscriptions/Models/BitwardenDiscount.cs b/src/Core/Billing/Subscriptions/Models/BitwardenDiscount.cs new file mode 100644 index 0000000000..dde005b7bd --- /dev/null +++ b/src/Core/Billing/Subscriptions/Models/BitwardenDiscount.cs @@ -0,0 +1,61 @@ +using System.Runtime.Serialization; +using System.Text.Json.Serialization; +using Bit.Core.Utilities; +using Stripe; + +namespace Bit.Core.Billing.Subscriptions.Models; + +/// +/// The type of discounts Bitwarden supports. +/// +public enum BitwardenDiscountType +{ + [EnumMember(Value = "amount-off")] + AmountOff, + + [EnumMember(Value = "percent-off")] + PercentOff +} + +/// +/// A record representing a discount applied to a Bitwarden subscription. +/// +public record BitwardenDiscount +{ + /// + /// The type of the discount. + /// + [JsonConverter(typeof(EnumMemberJsonConverter))] + public required BitwardenDiscountType Type { get; init; } + + /// + /// The value of the discount. + /// + public required decimal Value { get; init; } + + public static implicit operator BitwardenDiscount(Discount? discount) + { + if (discount is not + { + Coupon.Valid: true + }) + { + return null!; + } + + return discount.Coupon switch + { + { AmountOff: > 0 } => new BitwardenDiscount + { + Type = BitwardenDiscountType.AmountOff, + Value = discount.Coupon.AmountOff.Value + }, + { PercentOff: > 0 } => new BitwardenDiscount + { + Type = BitwardenDiscountType.PercentOff, + Value = discount.Coupon.PercentOff.Value + }, + _ => null! + }; + } +} diff --git a/src/Core/Billing/Subscriptions/Models/BitwardenSubscription.cs b/src/Core/Billing/Subscriptions/Models/BitwardenSubscription.cs new file mode 100644 index 0000000000..5643b35cda --- /dev/null +++ b/src/Core/Billing/Subscriptions/Models/BitwardenSubscription.cs @@ -0,0 +1,52 @@ +namespace Bit.Core.Billing.Subscriptions.Models; + +public record BitwardenSubscription +{ + /// + /// The status of the subscription. + /// + public required string Status { get; init; } + + /// + /// The subscription's cart, including line items, any discounts, and estimated tax. + /// + public required Cart Cart { get; init; } + + /// + /// The amount of storage available and used for the subscription. + /// Allowed Subscribers: User, Organization + /// + public Storage? Storage { get; init; } + + /// + /// If the subscription is pending cancellation, the date at which the + /// subscription will be canceled. + /// Allowed Statuses: 'trialing', 'active' + /// + public DateTime? CancelAt { get; init; } + + /// + /// The date the subscription was canceled. + /// Allowed Statuses: 'canceled' + /// + public DateTime? Canceled { get; init; } + + /// + /// The date of the next charge for the subscription. + /// Allowed Statuses: 'trialing', 'active' + /// + public DateTime? NextCharge { get; init; } + + /// + /// The date the subscription will be or was suspended due to lack of payment. + /// Allowed Statuses: 'incomplete', 'incomplete_expired', 'past_due', 'unpaid' + /// + public DateTime? Suspension { get; init; } + + /// + /// The number of days after the subscription goes 'past_due' the subscriber has to resolve their + /// open invoices before the subscription is suspended. + /// Allowed Statuses: 'past_due' + /// + public int? GracePeriod { get; init; } +} diff --git a/src/Core/Billing/Subscriptions/Models/Cart.cs b/src/Core/Billing/Subscriptions/Models/Cart.cs new file mode 100644 index 0000000000..e7c08919d9 --- /dev/null +++ b/src/Core/Billing/Subscriptions/Models/Cart.cs @@ -0,0 +1,83 @@ +using System.Text.Json.Serialization; +using Bit.Core.Billing.Enums; +using Bit.Core.Utilities; + +namespace Bit.Core.Billing.Subscriptions.Models; + +public record CartItem +{ + /// + /// The client-side translation key for the name of the cart item. + /// + public required string TranslationKey { get; init; } + + /// + /// The quantity of the cart item. + /// + public required long Quantity { get; init; } + + /// + /// The unit-cost of the cart item. + /// + public required decimal Cost { get; init; } + + /// + /// An optional discount applied specifically to this cart item. + /// + public BitwardenDiscount? Discount { get; init; } +} + +public record PasswordManagerCartItems +{ + /// + /// The Password Manager seats in the cart. + /// + public required CartItem Seats { get; init; } + + /// + /// The additional storage in the cart. + /// + public CartItem? AdditionalStorage { get; init; } +} + +public record SecretsManagerCartItems +{ + /// + /// The Secrets Manager seats in the cart. + /// + public required CartItem Seats { get; init; } + + /// + /// The additional service accounts in the cart. + /// + public CartItem? AdditionalServiceAccounts { get; init; } +} + +public record Cart +{ + /// + /// The Password Manager items in the cart. + /// + public required PasswordManagerCartItems PasswordManager { get; init; } + + /// + /// The Secrets Manager items in the cart. + /// + public SecretsManagerCartItems? SecretsManager { get; init; } + + /// + /// The cart's billing cadence. + /// + [JsonConverter(typeof(EnumMemberJsonConverter))] + public PlanCadenceType Cadence { get; init; } + + /// + /// An optional discount applied to the entire cart. + /// + public BitwardenDiscount? Discount { get; init; } + + /// + /// The estimated tax for the cart. + /// + public required decimal EstimatedTax { get; init; } +} diff --git a/src/Core/Billing/Subscriptions/Models/Storage.cs b/src/Core/Billing/Subscriptions/Models/Storage.cs new file mode 100644 index 0000000000..cd26579bee --- /dev/null +++ b/src/Core/Billing/Subscriptions/Models/Storage.cs @@ -0,0 +1,52 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Entities; +using Bit.Core.Utilities; +using OneOf; + +namespace Bit.Core.Billing.Subscriptions.Models; + +public record Storage +{ + private const double _bytesPerGibibyte = 1073741824D; + + /// + /// The amount of storage the subscriber has available. + /// + public required short Available { get; init; } + + /// + /// The amount of storage the subscriber has used. + /// + public required double Used { get; init; } + + /// + /// The amount of storage the subscriber has used, formatted as a human-readable string. + /// + public required string ReadableUsed { get; init; } + + public static implicit operator Storage(User user) => From(user); + public static implicit operator Storage(Organization organization) => From(organization); + + private static Storage From(OneOf subscriber) + { + var maxStorageGB = subscriber.Match( + user => user.MaxStorageGb, + organization => organization.MaxStorageGb); + + if (maxStorageGB == null) + { + return null!; + } + + var storage = subscriber.Match( + user => user.Storage, + organization => organization.Storage); + + return new Storage + { + Available = maxStorageGB.Value, + Used = Math.Round((storage ?? 0) / _bytesPerGibibyte, 2), + ReadableUsed = CoreHelpers.ReadableBytesSize(storage ?? 0) + }; + } +} diff --git a/src/Core/Billing/Subscriptions/Models/SubscriberId.cs b/src/Core/Billing/Subscriptions/Models/SubscriberId.cs new file mode 100644 index 0000000000..1ea842b0e6 --- /dev/null +++ b/src/Core/Billing/Subscriptions/Models/SubscriberId.cs @@ -0,0 +1,43 @@ +using Bit.Core.Billing.Constants; +using Bit.Core.Exceptions; +using OneOf; +using Stripe; + +namespace Bit.Core.Billing.Subscriptions.Models; + +using static StripeConstants; + +public record UserId(Guid Value); + +public record OrganizationId(Guid Value); + +public record ProviderId(Guid Value); + +public class SubscriberId : OneOfBase +{ + private SubscriberId(OneOf input) : base(input) { } + + public static implicit operator SubscriberId(UserId value) => new(value); + public static implicit operator SubscriberId(OrganizationId value) => new(value); + public static implicit operator SubscriberId(ProviderId value) => new(value); + + public static implicit operator SubscriberId(Subscription subscription) + { + if (subscription.Metadata.TryGetValue(MetadataKeys.UserId, out var userIdValue) + && Guid.TryParse(userIdValue, out var userId)) + { + return new UserId(userId); + } + + if (subscription.Metadata.TryGetValue(MetadataKeys.OrganizationId, out var organizationIdValue) + && Guid.TryParse(organizationIdValue, out var organizationId)) + { + return new OrganizationId(organizationId); + } + + return subscription.Metadata.TryGetValue(MetadataKeys.ProviderId, out var providerIdValue) && + Guid.TryParse(providerIdValue, out var providerId) + ? new ProviderId(providerId) + : throw new ConflictException("Subscription does not have a valid subscriber ID"); + } +} diff --git a/src/Core/Billing/Subscriptions/Queries/GetBitwardenSubscriptionQuery.cs b/src/Core/Billing/Subscriptions/Queries/GetBitwardenSubscriptionQuery.cs new file mode 100644 index 0000000000..cd7fa91fff --- /dev/null +++ b/src/Core/Billing/Subscriptions/Queries/GetBitwardenSubscriptionQuery.cs @@ -0,0 +1,201 @@ +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Extensions; +using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Services; +using Bit.Core.Billing.Subscriptions.Models; +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Microsoft.Extensions.Logging; +using OneOf; +using Stripe; + +namespace Bit.Core.Billing.Subscriptions.Queries; + +using static StripeConstants; +using static Utilities; + +public interface IGetBitwardenSubscriptionQuery +{ + /// + /// Retrieves detailed subscription information for a user, including subscription status, + /// cart items, discounts, and billing details. + /// + /// The user whose subscription information to retrieve. + /// + /// A containing the subscription details, or null if no + /// subscription is found or the subscription status is not recognized. + /// + /// + /// Currently only supports subscribers. Future versions will support all + /// types (User and Organization). + /// + Task Run(User user); +} + +public class GetBitwardenSubscriptionQuery( + ILogger logger, + IPricingClient pricingClient, + IStripeAdapter stripeAdapter) : IGetBitwardenSubscriptionQuery +{ + public async Task Run(User user) + { + var subscription = await stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, new SubscriptionGetOptions + { + Expand = + [ + "customer.discount.coupon.applies_to", + "discounts.coupon.applies_to", + "items.data.price.product", + "test_clock" + ] + }); + + var cart = await GetPremiumCartAsync(subscription); + + var baseSubscription = new BitwardenSubscription { Status = subscription.Status, Cart = cart, Storage = user }; + + switch (subscription.Status) + { + case SubscriptionStatus.Incomplete: + case SubscriptionStatus.IncompleteExpired: + return baseSubscription with { Suspension = subscription.Created.AddHours(23), GracePeriod = 1 }; + + case SubscriptionStatus.Trialing: + case SubscriptionStatus.Active: + return baseSubscription with + { + NextCharge = subscription.GetCurrentPeriodEnd(), + CancelAt = subscription.CancelAt + }; + + case SubscriptionStatus.PastDue: + case SubscriptionStatus.Unpaid: + var suspension = await GetSubscriptionSuspensionAsync(stripeAdapter, subscription); + if (suspension == null) + { + return baseSubscription; + } + return baseSubscription with { Suspension = suspension.SuspensionDate, GracePeriod = suspension.GracePeriod }; + + case SubscriptionStatus.Canceled: + return baseSubscription with { Canceled = subscription.CanceledAt }; + + default: + { + logger.LogError("Subscription ({SubscriptionID}) has an unmanaged status ({Status})", subscription.Id, subscription.Status); + throw new ConflictException("Subscription is in an invalid state. Please contact support for assistance."); + } + } + } + + private async Task GetPremiumCartAsync( + Subscription subscription) + { + var plans = await pricingClient.ListPremiumPlans(); + + var passwordManagerSeatsItem = subscription.Items.FirstOrDefault(item => + plans.Any(plan => plan.Seat.StripePriceId == item.Price.Id)); + + if (passwordManagerSeatsItem == null) + { + throw new ConflictException("Premium subscription does not have a Password Manager line item."); + } + + var additionalStorageItem = subscription.Items.FirstOrDefault(item => + plans.Any(plan => plan.Storage.StripePriceId == item.Price.Id)); + + var (cartLevelDiscount, productLevelDiscounts) = GetStripeDiscounts(subscription); + + var passwordManagerSeats = new CartItem + { + TranslationKey = "premiumMembership", + Quantity = passwordManagerSeatsItem.Quantity, + Cost = GetCost(passwordManagerSeatsItem), + Discount = productLevelDiscounts.FirstOrDefault(discount => discount.AppliesTo(passwordManagerSeatsItem)) + }; + + var additionalStorage = additionalStorageItem != null + ? new CartItem + { + TranslationKey = "additionalStorageGB", + Quantity = additionalStorageItem.Quantity, + Cost = GetCost(additionalStorageItem), + Discount = productLevelDiscounts.FirstOrDefault(discount => discount.AppliesTo(additionalStorageItem)) + } + : null; + + var estimatedTax = await EstimateTaxAsync(subscription); + + return new Cart + { + PasswordManager = new PasswordManagerCartItems + { + Seats = passwordManagerSeats, + AdditionalStorage = additionalStorage + }, + Cadence = PlanCadenceType.Annually, + Discount = cartLevelDiscount, + EstimatedTax = estimatedTax + }; + } + + #region Utilities + + private async Task EstimateTaxAsync(Subscription subscription) + { + try + { + var invoice = await stripeAdapter.CreateInvoicePreviewAsync(new InvoiceCreatePreviewOptions + { + Customer = subscription.Customer.Id, + Subscription = subscription.Id + }); + + return GetCost(invoice.TotalTaxes); + } + catch (StripeException stripeException) when + (stripeException.StripeError.Code == ErrorCodes.InvoiceUpcomingNone) + { + return 0; + } + } + + private static decimal GetCost(OneOf> value) => + value.Match( + item => (item.Price.UnitAmountDecimal ?? 0) / 100M, + taxes => taxes.Sum(invoiceTotalTax => invoiceTotalTax.Amount) / 100M); + + private static (Discount? CartLevel, List ProductLevel) GetStripeDiscounts( + Subscription subscription) + { + var discounts = new List(); + + if (subscription.Customer.Discount.IsValid()) + { + discounts.Add(subscription.Customer.Discount); + } + + discounts.AddRange(subscription.Discounts.Where(discount => discount.IsValid())); + + var cartLevel = new List(); + var productLevel = new List(); + + foreach (var discount in discounts) + { + switch (discount) + { + case { Coupon.AppliesTo.Products: null or { Count: 0 } }: + cartLevel.Add(discount); + break; + case { Coupon.AppliesTo.Products.Count: > 0 }: + productLevel.Add(discount); + break; + } + } + + return (cartLevel.FirstOrDefault(), productLevel); + } + + #endregion +} diff --git a/src/Core/Billing/Utilities.cs b/src/Core/Billing/Utilities.cs index 2ee6b75664..ec5978988c 100644 --- a/src/Core/Billing/Utilities.cs +++ b/src/Core/Billing/Utilities.cs @@ -2,8 +2,8 @@ #nullable disable using Bit.Core.Billing.Models; +using Bit.Core.Billing.Services; using Bit.Core.Billing.Tax.Models; -using Bit.Core.Services; using Stripe; namespace Bit.Core.Billing; @@ -22,7 +22,7 @@ public static class Utilities return null; } - var openInvoices = await stripeAdapter.InvoiceSearchAsync(new InvoiceSearchOptions + var openInvoices = await stripeAdapter.SearchInvoiceAsync(new InvoiceSearchOptions { Query = $"subscription:'{subscription.Id}' status:'open'" }); diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 3f7eb4c786..c3c5cb5e5b 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -140,8 +140,10 @@ public static class FeatureFlagKeys public const string CreateDefaultLocation = "pm-19467-create-default-location"; public const string AutomaticConfirmUsers = "pm-19934-auto-confirm-organization-users"; public const string PM23845_VNextApplicationCache = "pm-24957-refactor-memory-application-cache"; - public const string AccountRecoveryCommand = "pm-25581-prevent-provider-account-recovery"; - public const string PolicyValidatorsRefactor = "pm-26423-refactor-policy-side-effects"; + public const string BlockClaimedDomainAccountCreation = "pm-28297-block-uninvited-claimed-domain-registration"; + public const string IncreaseBulkReinviteLimitForCloud = "pm-28251-increase-bulk-reinvite-limit-for-cloud"; + public const string PremiumAccessQuery = "pm-29495-refactor-premium-interface"; + public const string RefactorMembersComponent = "pm-29503-refactor-members-inheritance"; /* Architecture */ public const string DesktopMigrationMilestone1 = "desktop-ui-migration-milestone-1"; @@ -155,58 +157,42 @@ public static class FeatureFlagKeys public const string SetInitialPasswordRefactor = "pm-16117-set-initial-password-refactor"; public const string ChangeExistingPasswordRefactor = "pm-16117-change-existing-password-refactor"; public const string Otp6Digits = "pm-18612-otp-6-digits"; - public const string FailedTwoFactorEmail = "pm-24425-send-2fa-failed-email"; public const string PM24579_PreventSsoOnExistingNonCompliantUsers = "pm-24579-prevent-sso-on-existing-non-compliant-users"; public const string DisableAlternateLoginMethods = "pm-22110-disable-alternate-login-methods"; - public const string PM23174ManageAccountRecoveryPermissionDrivesTheNeedToSetMasterPassword = - "pm-23174-manage-account-recovery-permission-drives-the-need-to-set-master-password"; - public const string RecoveryCodeSupportForSsoRequiredUsers = "pm-21153-recovery-code-support-for-sso-required"; public const string MJMLBasedEmailTemplates = "mjml-based-email-templates"; - public const string MjmlWelcomeEmailTemplates = "mjml-welcome-email-templates"; + public const string MjmlWelcomeEmailTemplates = "pm-21741-mjml-welcome-email"; + public const string OrganizationConfirmationEmail = "pm-28402-update-confirmed-to-org-email-template"; public const string MarketingInitiatedPremiumFlow = "pm-26140-marketing-initiated-premium-flow"; + public const string PrefetchPasswordPrelogin = "pm-23801-prefetch-password-prelogin"; + public const string PM27086_UpdateAuthenticationApisForInputPassword = "pm-27086-update-authentication-apis-for-input-password"; /* Autofill Team */ - public const string IdpAutoSubmitLogin = "idp-auto-submit-login"; - public const string UseTreeWalkerApiForPageDetailsCollection = "use-tree-walker-api-for-page-details-collection"; - public const string InlineMenuFieldQualification = "inline-menu-field-qualification"; - public const string InlineMenuPositioningImprovements = "inline-menu-positioning-improvements"; public const string SSHAgent = "ssh-agent"; public const string SSHAgentV2 = "ssh-agent-v2"; public const string SSHVersionCheckQAOverride = "ssh-version-check-qa-override"; - public const string GenerateIdentityFillScriptRefactor = "generate-identity-fill-script-refactor"; - public const string DelayFido2PageScriptInitWithinMv2 = "delay-fido2-page-script-init-within-mv2"; - public const string NotificationBarAddLoginImprovements = "notification-bar-add-login-improvements"; - public const string BlockBrowserInjectionsByDomain = "block-browser-injections-by-domain"; public const string NotificationRefresh = "notification-refresh"; - public const string EnableNewCardCombinedExpiryAutofill = "enable-new-card-combined-expiry-autofill"; public const string MacOsNativeCredentialSync = "macos-native-credential-sync"; - public const string InlineMenuTotp = "inline-menu-totp"; public const string WindowsDesktopAutotype = "windows-desktop-autotype"; + public const string WindowsDesktopAutotypeGA = "windows-desktop-autotype-ga"; + public const string NotificationUndeterminedCipherScenarioLogic = "undetermined-cipher-scenario-logic"; /* Billing Team */ public const string TrialPayment = "PM-8163-trial-payment"; - public const string PM19422_AllowAutomaticTaxUpdates = "pm-19422-allow-automatic-tax-updates"; - public const string PM21821_ProviderPortalTakeover = "pm-21821-provider-portal-takeover"; - public const string PM22415_TaxIDWarnings = "pm-22415-tax-id-warnings"; public const string PM25379_UseNewOrganizationMetadataStructure = "pm-25379-use-new-organization-metadata-structure"; - public const string PM24996ImplementUpgradeFromFreeDialog = "pm-24996-implement-upgrade-from-free-dialog"; public const string PM24032_NewNavigationPremiumUpgradeButton = "pm-24032-new-navigation-premium-upgrade-button"; public const string PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog = "pm-23713-premium-badge-opens-new-premium-upgrade-dialog"; - public const string PremiumUpgradeNewDesign = "pm-24033-updat-premium-subscription-page"; public const string PM26793_FetchPremiumPriceFromPricingService = "pm-26793-fetch-premium-price-from-pricing-service"; public const string PM23341_Milestone_2 = "pm-23341-milestone-2"; public const string PM26462_Milestone_3 = "pm-26462-milestone-3"; + public const string PM28265_EnableReconcileAdditionalStorageJob = "pm-28265-enable-reconcile-additional-storage-job"; + public const string PM28265_ReconcileAdditionalStorageJobEnableLiveMode = "pm-28265-reconcile-additional-storage-job-enable-live-mode"; + public const string PM29594_UpdateIndividualSubscriptionPage = "pm-29594-update-individual-subscription-page"; /* Key Management Team */ - public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair"; - public const string PM4154BulkEncryptionService = "PM-4154-bulk-encryption-service"; public const string PrivateKeyRegeneration = "pm-12241-private-key-regeneration"; public const string Argon2Default = "argon2-default"; - public const string UserkeyRotationV2 = "userkey-rotation-v2"; public const string SSHKeyItemVaultItem = "ssh-key-vault-item"; - public const string UserSdkForDecryption = "use-sdk-for-decryption"; public const string EnrollAeadOnKeyRotation = "enroll-aead-on-key-rotation"; - public const string PM17987_BlockType0 = "pm-17987-block-type-0"; public const string ForceUpdateKDFSettings = "pm-18021-force-update-kdf-settings"; public const string UnlockWithMasterPasswordUnlockData = "pm-23246-unlock-with-master-password-unlock-data"; public const string WindowsBiometricsV2 = "pm-25373-windows-biometrics-v2"; @@ -214,6 +200,11 @@ public static class FeatureFlagKeys public const string NoLogoutOnKdfChange = "pm-23995-no-logout-on-kdf-change"; public const string DisableType0Decryption = "pm-25174-disable-type-0-decryption"; public const string ConsolidatedSessionTimeoutComponent = "pm-26056-consolidated-session-timeout-component"; + public const string V2RegistrationTDEJIT = "pm-27279-v2-registration-tde-jit"; + public const string DataRecoveryTool = "pm-28813-data-recovery-tool"; + public const string EnableAccountEncryptionV2KeyConnectorRegistration = "enable-account-encryption-v2-key-connector-registration"; + public const string SdkKeyRotation = "pm-30144-sdk-key-rotation"; + public const string EnableAccountEncryptionV2JitPasswordRegistration = "enable-account-encryption-v2-jit-password-registration"; /* Mobile Team */ public const string AndroidImportLoginsFlow = "import-logins-flow"; @@ -230,38 +221,48 @@ public static class FeatureFlagKeys public const string CxpExportMobile = "cxp-export-mobile"; /* Platform Team */ + public const string WebPush = "web-push"; public const string IpcChannelFramework = "ipc-channel-framework"; public const string PushNotificationsWhenLocked = "pm-19388-push-notifications-when-locked"; public const string PushNotificationsWhenInactive = "pm-25130-receive-push-notifications-for-inactive-users"; /* Tools Team */ - public const string DesktopSendUIRefresh = "desktop-send-ui-refresh"; + /// + /// Enable this flag to share the send view used by the web and browser clients + /// on the desktop client. + /// public const string UseSdkPasswordGenerators = "pm-19976-use-sdk-password-generators"; public const string UseChromiumImporter = "pm-23982-chromium-importer"; public const string ChromiumImporterWithABE = "pm-25855-chromium-importer-abe"; + public const string SendUIRefresh = "pm-28175-send-ui-refresh"; + public const string SendEmailOTP = "pm-19051-send-email-verification"; /* Vault Team */ - public const string PM8851_BrowserOnboardingNudge = "pm-8851-browser-onboarding-nudge"; public const string CipherKeyEncryption = "cipher-key-encryption"; public const string PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk"; - public const string EndUserNotifications = "pm-10609-end-user-notifications"; public const string PhishingDetection = "phishing-detection"; - public const string RemoveCardItemTypePolicy = "pm-16442-remove-card-item-type-policy"; public const string PM22134SdkCipherListView = "pm-22134-sdk-cipher-list-view"; - public const string PM19315EndUserActivationMvp = "pm-19315-end-user-activation-mvp"; public const string PM22136_SdkCipherEncryption = "pm-22136-sdk-cipher-encryption"; public const string PM23904_RiskInsightsForPremium = "pm-23904-risk-insights-for-premium"; public const string PM25083_AutofillConfirmFromSearch = "pm-25083-autofill-confirm-from-search"; public const string VaultLoadingSkeletons = "pm-25081-vault-skeleton-loaders"; + public const string BrowserPremiumSpotlight = "pm-23384-browser-premium-spotlight"; + public const string MigrateMyVaultToMyItems = "pm-20558-migrate-myvault-to-myitems"; + public const string PM27632_CipherCrudOperationsToSdk = "pm-27632-cipher-crud-operations-to-sdk"; /* Innovation Team */ public const string ArchiveVaultItems = "pm-19148-innovation-archive"; /* DIRT Team */ - public const string PM22887_RiskInsightsActivityTab = "pm-22887-risk-insights-activity-tab"; public const string EventManagementForDataDogAndCrowdStrike = "event-management-for-datadog-and-crowdstrike"; public const string EventDiagnosticLogging = "pm-27666-siem-event-log-debugging"; + /* UIF Team */ + public const string RouterFocusManagement = "router-focus-management"; + + /* Secrets Manager Team */ + public const string SM1719_RemoveSecretsManagerAds = "sm-1719-remove-secrets-manager-ads"; + public static List GetAllKeys() { return typeof(FeatureFlagKeys).GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy) diff --git a/src/Core/Context/CurrentContext.cs b/src/Core/Context/CurrentContext.cs index 5d9b5a1759..6067c60556 100644 --- a/src/Core/Context/CurrentContext.cs +++ b/src/Core/Context/CurrentContext.cs @@ -38,10 +38,6 @@ public class CurrentContext( public virtual List Providers { get; set; } public virtual Guid? InstallationId { get; set; } public virtual Guid? OrganizationId { get; set; } - public virtual bool CloudflareWorkerProxied { get; set; } - public virtual bool IsBot { get; set; } - public virtual bool MaybeBot { get; set; } - public virtual int? BotScore { get; set; } public virtual string ClientId { get; set; } public virtual Version ClientVersion { get; set; } public virtual bool ClientVersionIsPrerelease { get; set; } @@ -70,27 +66,6 @@ public class CurrentContext( DeviceType = dType; } - if (!BotScore.HasValue && httpContext.Request.Headers.TryGetValue("X-Cf-Bot-Score", out var cfBotScore) && - int.TryParse(cfBotScore, out var parsedBotScore)) - { - BotScore = parsedBotScore; - } - - if (httpContext.Request.Headers.TryGetValue("X-Cf-Worked-Proxied", out var cfWorkedProxied)) - { - CloudflareWorkerProxied = cfWorkedProxied == "1"; - } - - if (httpContext.Request.Headers.TryGetValue("X-Cf-Is-Bot", out var cfIsBot)) - { - IsBot = cfIsBot == "1"; - } - - if (httpContext.Request.Headers.TryGetValue("X-Cf-Maybe-Bot", out var cfMaybeBot)) - { - MaybeBot = cfMaybeBot == "1"; - } - if (httpContext.Request.Headers.TryGetValue("Bitwarden-Client-Version", out var bitWardenClientVersion) && Version.TryParse(bitWardenClientVersion, out var cVersion)) { ClientVersion = cVersion; diff --git a/src/Core/Context/ICurrentContext.cs b/src/Core/Context/ICurrentContext.cs index f62a048070..d527cdd363 100644 --- a/src/Core/Context/ICurrentContext.cs +++ b/src/Core/Context/ICurrentContext.cs @@ -31,9 +31,6 @@ public interface ICurrentContext Guid? InstallationId { get; set; } Guid? OrganizationId { get; set; } IdentityClientType IdentityClientType { get; set; } - bool IsBot { get; set; } - bool MaybeBot { get; set; } - int? BotScore { get; set; } string ClientId { get; set; } Version ClientVersion { get; set; } bool ClientVersionIsPrerelease { get; set; } diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index 81370fe173..a423d9377d 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -3,6 +3,8 @@ false bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml + + $(WarningsNotAsErrors);CA1304;CA1305 @@ -23,50 +25,47 @@ - - - + + + - - - + + + - + + - + - - - - - + - - - - + + + + diff --git a/src/Core/AdminConsole/Entities/Event.cs b/src/Core/Dirt/Entities/Event.cs similarity index 100% rename from src/Core/AdminConsole/Entities/Event.cs rename to src/Core/Dirt/Entities/Event.cs diff --git a/src/Core/AdminConsole/Entities/OrganizationIntegration.cs b/src/Core/Dirt/Entities/OrganizationIntegration.cs similarity index 80% rename from src/Core/AdminConsole/Entities/OrganizationIntegration.cs rename to src/Core/Dirt/Entities/OrganizationIntegration.cs index 86de25ce9a..42b4e89e27 100644 --- a/src/Core/AdminConsole/Entities/OrganizationIntegration.cs +++ b/src/Core/Dirt/Entities/OrganizationIntegration.cs @@ -1,10 +1,8 @@ -using Bit.Core.Entities; -using Bit.Core.Enums; +using Bit.Core.Dirt.Enums; +using Bit.Core.Entities; using Bit.Core.Utilities; -#nullable enable - -namespace Bit.Core.AdminConsole.Entities; +namespace Bit.Core.Dirt.Entities; public class OrganizationIntegration : ITableObject { diff --git a/src/Core/AdminConsole/Entities/OrganizationIntegrationConfiguration.cs b/src/Core/Dirt/Entities/OrganizationIntegrationConfiguration.cs similarity index 91% rename from src/Core/AdminConsole/Entities/OrganizationIntegrationConfiguration.cs rename to src/Core/Dirt/Entities/OrganizationIntegrationConfiguration.cs index 52934cf7f3..2b8dbf9220 100644 --- a/src/Core/AdminConsole/Entities/OrganizationIntegrationConfiguration.cs +++ b/src/Core/Dirt/Entities/OrganizationIntegrationConfiguration.cs @@ -2,9 +2,7 @@ using Bit.Core.Enums; using Bit.Core.Utilities; -#nullable enable - -namespace Bit.Core.AdminConsole.Entities; +namespace Bit.Core.Dirt.Entities; public class OrganizationIntegrationConfiguration : ITableObject { diff --git a/src/Core/AdminConsole/Enums/EventSystemUser.cs b/src/Core/Dirt/Enums/EventSystemUser.cs similarity index 100% rename from src/Core/AdminConsole/Enums/EventSystemUser.cs rename to src/Core/Dirt/Enums/EventSystemUser.cs diff --git a/src/Core/AdminConsole/Enums/EventType.cs b/src/Core/Dirt/Enums/EventType.cs similarity index 95% rename from src/Core/AdminConsole/Enums/EventType.cs rename to src/Core/Dirt/Enums/EventType.cs index 09cda7ca0e..61372fc4e0 100644 --- a/src/Core/AdminConsole/Enums/EventType.cs +++ b/src/Core/Dirt/Enums/EventType.cs @@ -61,6 +61,7 @@ public enum EventType : int OrganizationUser_Deleted = 1515, // Both user and organization user data were deleted OrganizationUser_Left = 1516, // User voluntarily left the organization OrganizationUser_AutomaticallyConfirmed = 1517, + OrganizationUser_SelfRevoked = 1518, // User self-revoked due to declining organization data ownership policy Organization_Updated = 1600, Organization_PurgedVault = 1601, @@ -81,6 +82,8 @@ public enum EventType : int Organization_CollectionManagement_LimitItemDeletionDisabled = 1615, Organization_CollectionManagement_AllowAdminAccessToAllCollectionItemsEnabled = 1616, Organization_CollectionManagement_AllowAdminAccessToAllCollectionItemsDisabled = 1617, + Organization_ItemOrganization_Accepted = 1618, + Organization_ItemOrganization_Declined = 1619, Policy_Updated = 1700, diff --git a/src/Core/AdminConsole/Enums/IntegrationType.cs b/src/Core/Dirt/Enums/IntegrationType.cs similarity index 96% rename from src/Core/AdminConsole/Enums/IntegrationType.cs rename to src/Core/Dirt/Enums/IntegrationType.cs index 84e4de94e9..767f2feb06 100644 --- a/src/Core/AdminConsole/Enums/IntegrationType.cs +++ b/src/Core/Dirt/Enums/IntegrationType.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.Enums; +namespace Bit.Core.Dirt.Enums; public enum IntegrationType : int { diff --git a/src/Core/AdminConsole/Enums/OrganizationIntegrationStatus.cs b/src/Core/Dirt/Enums/OrganizationIntegrationStatus.cs similarity index 66% rename from src/Core/AdminConsole/Enums/OrganizationIntegrationStatus.cs rename to src/Core/Dirt/Enums/OrganizationIntegrationStatus.cs index 78a7bc6d63..aad0530971 100644 --- a/src/Core/AdminConsole/Enums/OrganizationIntegrationStatus.cs +++ b/src/Core/Dirt/Enums/OrganizationIntegrationStatus.cs @@ -1,4 +1,4 @@ -namespace Bit.Api.AdminConsole.Models.Response.Organizations; +namespace Bit.Core.Dirt.Enums; public enum OrganizationIntegrationStatus : int { diff --git a/src/Core/Dirt/EventIntegrations/EventIntegrationsServiceCollectionExtensions.cs b/src/Core/Dirt/EventIntegrations/EventIntegrationsServiceCollectionExtensions.cs new file mode 100644 index 0000000000..b03a68cfa6 --- /dev/null +++ b/src/Core/Dirt/EventIntegrations/EventIntegrationsServiceCollectionExtensions.cs @@ -0,0 +1,569 @@ +using Azure.Messaging.ServiceBus; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Dirt.EventIntegrations.OrganizationIntegrationConfigurations; +using Bit.Core.Dirt.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces; +using Bit.Core.Dirt.EventIntegrations.OrganizationIntegrations; +using Bit.Core.Dirt.EventIntegrations.OrganizationIntegrations.Interfaces; +using Bit.Core.Dirt.Models.Data.EventIntegrations; +using Bit.Core.Dirt.Models.Data.Teams; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Dirt.Services; +using Bit.Core.Dirt.Services.Implementations; +using Bit.Core.Dirt.Services.NoopImplementations; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Settings; +using Bit.Core.Utilities; +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Integration.AspNet.Core; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using ZiggyCreatures.Caching.Fusion; +using TableStorageRepos = Bit.Core.Repositories.TableStorage; + +namespace Microsoft.Extensions.DependencyInjection; + +public static class EventIntegrationsServiceCollectionExtensions +{ + /// + /// Adds all event integrations commands, queries, and required cache infrastructure. + /// This method is idempotent and can be called multiple times safely. + /// + public static IServiceCollection AddEventIntegrationsCommandsQueries( + this IServiceCollection services, + GlobalSettings globalSettings) + { + // Ensure cache is registered first - commands depend on this keyed cache. + // This is idempotent for the same named cache, so it's safe to call. + services.AddExtendedCache(EventIntegrationsCacheConstants.CacheName, globalSettings); + + // Add Validator + services.TryAddSingleton(); + + // Add all commands/queries + services.AddOrganizationIntegrationCommandsQueries(); + services.AddOrganizationIntegrationConfigurationCommandsQueries(); + + return services; + } + + /// + /// Registers event write services based on available configuration. + /// + /// The service collection to add services to. + /// The global settings containing event logging configuration. + /// The service collection for chaining. + /// + /// + /// This method registers the appropriate IEventWriteService implementation based on the available + /// configuration, checking in the following priority order: + /// + /// + /// 1. Azure Service Bus - If all Azure Service Bus settings are present, registers + /// EventIntegrationEventWriteService with AzureServiceBusService as the publisher + /// + /// + /// 2. RabbitMQ - If all RabbitMQ settings are present, registers EventIntegrationEventWriteService with + /// RabbitMqService as the publisher + /// + /// + /// 3. Azure Queue Storage - If Events.ConnectionString is present, registers AzureQueueEventWriteService + /// + /// + /// 4. Repository (Self-Hosted) - If SelfHosted is true, registers RepositoryEventWriteService + /// + /// + /// 5. Noop - If none of the above are configured, registers NoopEventWriteService (no-op implementation) + /// + /// + public static IServiceCollection AddEventWriteServices(this IServiceCollection services, GlobalSettings globalSettings) + { + if (IsAzureServiceBusEnabled(globalSettings)) + { + services.TryAddSingleton(); + services.TryAddSingleton(); + return services; + } + + if (IsRabbitMqEnabled(globalSettings)) + { + services.TryAddSingleton(); + services.TryAddSingleton(); + return services; + } + + if (CoreHelpers.SettingHasValue(globalSettings.Events.ConnectionString) && + CoreHelpers.SettingHasValue(globalSettings.Events.QueueName)) + { + services.TryAddSingleton(); + return services; + } + + if (globalSettings.SelfHosted) + { + services.TryAddSingleton(); + return services; + } + + services.TryAddSingleton(); + return services; + } + + /// + /// Registers Azure Service Bus-based event integration listeners and supporting infrastructure. + /// + /// The service collection to add services to. + /// The global settings containing Azure Service Bus configuration. + /// The service collection for chaining. + /// + /// + /// If Azure Service Bus is not enabled (missing required settings), this method returns immediately + /// without registering any services. + /// + /// + /// When Azure Service Bus is enabled, this method registers: + /// - IAzureServiceBusService and IEventIntegrationPublisher implementations + /// - Table Storage event repository + /// - Azure Table Storage event handler + /// - All event integration services via AddEventIntegrationServices + /// + /// + /// PREREQUISITE: Callers must ensure AddDistributedCache has been called before this method, + /// as it is required to create the event integrations extended cache. + /// + /// + public static IServiceCollection AddAzureServiceBusListeners(this IServiceCollection services, GlobalSettings globalSettings) + { + if (!IsAzureServiceBusEnabled(globalSettings)) + { + return services; + } + + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddKeyedSingleton("persistent"); + services.TryAddSingleton(); + + services.AddEventIntegrationServices(globalSettings); + + return services; + } + + /// + /// Registers RabbitMQ-based event integration listeners and supporting infrastructure. + /// + /// The service collection to add services to. + /// The global settings containing RabbitMQ configuration. + /// The service collection for chaining. + /// + /// + /// If RabbitMQ is not enabled (missing required settings), this method returns immediately + /// without registering any services. + /// + /// + /// When RabbitMQ is enabled, this method registers: + /// - IRabbitMqService and IEventIntegrationPublisher implementations + /// - Event repository handler + /// - All event integration services via AddEventIntegrationServices + /// + /// + /// PREREQUISITE: Callers must ensure AddDistributedCache has been called before this method, + /// as it is required to create the event integrations extended cache. + /// + /// + public static IServiceCollection AddRabbitMqListeners(this IServiceCollection services, GlobalSettings globalSettings) + { + if (!IsRabbitMqEnabled(globalSettings)) + { + return services; + } + + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + + services.AddEventIntegrationServices(globalSettings); + + return services; + } + + /// + /// Registers Slack integration services based on configuration settings. + /// + /// The service collection to add services to. + /// The global settings containing Slack configuration. + /// The service collection for chaining. + /// + /// If all required Slack settings are configured (ClientId, ClientSecret, Scopes), registers the full SlackService, + /// including an HttpClient for Slack API calls. Otherwise, registers a NoopSlackService that performs no operations. + /// + public static IServiceCollection AddSlackService(this IServiceCollection services, GlobalSettings globalSettings) + { + if (CoreHelpers.SettingHasValue(globalSettings.Slack.ClientId) && + CoreHelpers.SettingHasValue(globalSettings.Slack.ClientSecret) && + CoreHelpers.SettingHasValue(globalSettings.Slack.Scopes)) + { + services.AddHttpClient(SlackService.HttpClientName); + services.TryAddSingleton(); + } + else + { + services.TryAddSingleton(); + } + + return services; + } + + /// + /// Registers Microsoft Teams integration services based on configuration settings. + /// + /// The service collection to add services to. + /// The global settings containing Teams configuration. + /// The service collection for chaining. + /// + /// If all required Teams settings are configured (ClientId, ClientSecret, Scopes), registers: + /// - TeamsService and its interfaces (IBot, ITeamsService) + /// - IBotFrameworkHttpAdapter with Teams credentials + /// - HttpClient for Teams API calls + /// Otherwise, registers a NoopTeamsService that performs no operations. + /// + public static IServiceCollection AddTeamsService(this IServiceCollection services, GlobalSettings globalSettings) + { + if (CoreHelpers.SettingHasValue(globalSettings.Teams.ClientId) && + CoreHelpers.SettingHasValue(globalSettings.Teams.ClientSecret) && + CoreHelpers.SettingHasValue(globalSettings.Teams.Scopes)) + { + services.AddHttpClient(TeamsService.HttpClientName); + services.TryAddSingleton(); + services.TryAddSingleton(sp => sp.GetRequiredService()); + services.TryAddSingleton(sp => sp.GetRequiredService()); + services.TryAddSingleton(_ => + new BotFrameworkHttpAdapter( + new TeamsBotCredentialProvider( + clientId: globalSettings.Teams.ClientId, + clientSecret: globalSettings.Teams.ClientSecret + ) + ) + ); + } + else + { + services.TryAddSingleton(); + } + + return services; + } + + /// + /// Registers event integration services including handlers, listeners, and supporting infrastructure. + /// + /// The service collection to add services to. + /// The global settings containing integration configuration. + /// The service collection for chaining. + /// + /// + /// This method orchestrates the registration of all event integration components based on the enabled + /// message broker (Azure Service Bus or RabbitMQ). It is an internal method called by the public + /// entry points AddAzureServiceBusListeners and AddRabbitMqListeners. + /// + /// + /// NOTE: If both Azure Service Bus and RabbitMQ are configured, Azure Service Bus takes precedence. This means that + /// Azure Service Bus listeners will be registered (and RabbitMQ listeners will NOT) even if this event is called + /// from AddRabbitMqListeners when Azure Service Bus settings are configured. + /// + /// + /// PREREQUISITE: Callers must ensure AddDistributedCache has been called before invoking this method. + /// This method depends on distributed cache infrastructure being available for the keyed extended + /// cache registration. + /// + /// + /// Registered Services: + /// - Keyed ExtendedCache for event integrations + /// - Integration filter service + /// - Integration handlers for Slack, Webhook, Hec, Datadog, and Teams + /// - Hosted services for event and integration listeners (based on enabled message broker) + /// + /// + internal static IServiceCollection AddEventIntegrationServices(this IServiceCollection services, + GlobalSettings globalSettings) + { + // Add common services + // NOTE: AddDistributedCache must be called by the caller before this method + services.AddExtendedCache(EventIntegrationsCacheConstants.CacheName, globalSettings); + services.TryAddSingleton(); + services.TryAddKeyedSingleton("persistent"); + + // Add services in support of handlers + services.AddSlackService(globalSettings); + services.AddTeamsService(globalSettings); + services.TryAddSingleton(TimeProvider.System); + services.AddHttpClient(WebhookIntegrationHandler.HttpClientName); + services.AddHttpClient(DatadogIntegrationHandler.HttpClientName); + + // Add integration handlers + services.TryAddSingleton, SlackIntegrationHandler>(); + services.TryAddSingleton, WebhookIntegrationHandler>(); + services.TryAddSingleton, DatadogIntegrationHandler>(); + services.TryAddSingleton, TeamsIntegrationHandler>(); + + var repositoryConfiguration = new RepositoryListenerConfiguration(globalSettings); + var slackConfiguration = new SlackListenerConfiguration(globalSettings); + var webhookConfiguration = new WebhookListenerConfiguration(globalSettings); + var hecConfiguration = new HecListenerConfiguration(globalSettings); + var datadogConfiguration = new DatadogListenerConfiguration(globalSettings); + var teamsConfiguration = new TeamsListenerConfiguration(globalSettings); + + if (IsAzureServiceBusEnabled(globalSettings)) + { + services.TryAddEnumerable(ServiceDescriptor.Singleton>(provider => + new AzureServiceBusEventListenerService( + configuration: repositoryConfiguration, + handler: provider.GetRequiredService(), + serviceBusService: provider.GetRequiredService(), + serviceBusOptions: new ServiceBusProcessorOptions() + { + PrefetchCount = repositoryConfiguration.EventPrefetchCount, + MaxConcurrentCalls = repositoryConfiguration.EventMaxConcurrentCalls + }, + loggerFactory: provider.GetRequiredService() + ) + ) + ); + services.AddAzureServiceBusIntegration(slackConfiguration); + services.AddAzureServiceBusIntegration(webhookConfiguration); + services.AddAzureServiceBusIntegration(hecConfiguration); + services.AddAzureServiceBusIntegration(datadogConfiguration); + services.AddAzureServiceBusIntegration(teamsConfiguration); + + return services; + } + + if (IsRabbitMqEnabled(globalSettings)) + { + services.TryAddEnumerable(ServiceDescriptor.Singleton>(provider => + new RabbitMqEventListenerService( + handler: provider.GetRequiredService(), + configuration: repositoryConfiguration, + rabbitMqService: provider.GetRequiredService(), + loggerFactory: provider.GetRequiredService() + ) + ) + ); + services.AddRabbitMqIntegration(slackConfiguration); + services.AddRabbitMqIntegration(webhookConfiguration); + services.AddRabbitMqIntegration(hecConfiguration); + services.AddRabbitMqIntegration(datadogConfiguration); + services.AddRabbitMqIntegration(teamsConfiguration); + } + + return services; + } + + /// + /// Registers Azure Service Bus-based event integration listeners for a specific integration type. + /// + /// The integration configuration details type (e.g., SlackIntegrationConfigurationDetails). + /// The listener configuration type implementing IIntegrationListenerConfiguration. + /// The service collection to add services to. + /// The listener configuration containing routing keys and message processing settings. + /// The service collection for chaining. + /// + /// + /// This method registers three key components: + /// 1. EventIntegrationHandler - Keyed singleton for processing integration events + /// 2. AzureServiceBusEventListenerService - Hosted service for listening to event messages from Azure Service Bus + /// for this integration type + /// 3. AzureServiceBusIntegrationListenerService - Hosted service for listening to integration messages from + /// Azure Service Bus for this integration type + /// + /// + /// The handler uses the listener configuration's routing key as its service key, allowing multiple + /// handlers to be registered for different integration types. + /// + /// + /// Service Bus processor options (PrefetchCount and MaxConcurrentCalls) are configured from the listener + /// configuration to optimize message throughput and concurrency. + /// + /// + internal static IServiceCollection AddAzureServiceBusIntegration(this IServiceCollection services, + TListenerConfig listenerConfiguration) + where TConfig : class + where TListenerConfig : IIntegrationListenerConfiguration + { + services.TryAddKeyedSingleton(serviceKey: listenerConfiguration.RoutingKey, implementationFactory: (provider, _) => + new EventIntegrationHandler( + integrationType: listenerConfiguration.IntegrationType, + eventIntegrationPublisher: provider.GetRequiredService(), + integrationFilterService: provider.GetRequiredService(), + cache: provider.GetRequiredKeyedService(EventIntegrationsCacheConstants.CacheName), + configurationRepository: provider.GetRequiredService(), + groupRepository: provider.GetRequiredService(), + organizationRepository: provider.GetRequiredService(), + organizationUserRepository: provider.GetRequiredService(), logger: provider.GetRequiredService>>()) + ); + services.TryAddEnumerable(ServiceDescriptor.Singleton>(provider => + new AzureServiceBusEventListenerService( + configuration: listenerConfiguration, + handler: provider.GetRequiredKeyedService(serviceKey: listenerConfiguration.RoutingKey), + serviceBusService: provider.GetRequiredService(), + serviceBusOptions: new ServiceBusProcessorOptions() + { + PrefetchCount = listenerConfiguration.EventPrefetchCount, + MaxConcurrentCalls = listenerConfiguration.EventMaxConcurrentCalls + }, + loggerFactory: provider.GetRequiredService() + ) + ) + ); + services.TryAddEnumerable(ServiceDescriptor.Singleton>(provider => + new AzureServiceBusIntegrationListenerService( + configuration: listenerConfiguration, + handler: provider.GetRequiredService>(), + serviceBusService: provider.GetRequiredService(), + serviceBusOptions: new ServiceBusProcessorOptions() + { + PrefetchCount = listenerConfiguration.IntegrationPrefetchCount, + MaxConcurrentCalls = listenerConfiguration.IntegrationMaxConcurrentCalls + }, + loggerFactory: provider.GetRequiredService() + ) + ) + ); + + return services; + } + + /// + /// Registers RabbitMQ-based event integration listeners for a specific integration type. + /// + /// The integration configuration details type (e.g., SlackIntegrationConfigurationDetails). + /// The listener configuration type implementing IIntegrationListenerConfiguration. + /// The service collection to add services to. + /// The listener configuration containing routing keys and message processing settings. + /// The service collection for chaining. + /// + /// + /// This method registers three key components: + /// 1. EventIntegrationHandler - Keyed singleton for processing integration events + /// 2. RabbitMqEventListenerService - Hosted service for listening to event messages from RabbitMQ for + /// this integration type + /// 3. RabbitMqIntegrationListenerService - Hosted service for listening to integration messages from RabbitMQ for + /// this integration type + /// + /// + /// + /// The handler uses the listener configuration's routing key as its service key, allowing multiple + /// handlers to be registered for different integration types. + /// + /// + internal static IServiceCollection AddRabbitMqIntegration(this IServiceCollection services, + TListenerConfig listenerConfiguration) + where TConfig : class + where TListenerConfig : IIntegrationListenerConfiguration + { + services.TryAddKeyedSingleton(serviceKey: listenerConfiguration.RoutingKey, implementationFactory: (provider, _) => + new EventIntegrationHandler( + integrationType: listenerConfiguration.IntegrationType, + eventIntegrationPublisher: provider.GetRequiredService(), + integrationFilterService: provider.GetRequiredService(), + cache: provider.GetRequiredKeyedService(EventIntegrationsCacheConstants.CacheName), + configurationRepository: provider.GetRequiredService(), + groupRepository: provider.GetRequiredService(), + organizationRepository: provider.GetRequiredService(), + organizationUserRepository: provider.GetRequiredService(), logger: provider.GetRequiredService>>()) + ); + services.TryAddEnumerable(ServiceDescriptor.Singleton>(provider => + new RabbitMqEventListenerService( + handler: provider.GetRequiredKeyedService(serviceKey: listenerConfiguration.RoutingKey), + configuration: listenerConfiguration, + rabbitMqService: provider.GetRequiredService(), + loggerFactory: provider.GetRequiredService() + ) + ) + ); + services.TryAddEnumerable(ServiceDescriptor.Singleton>(provider => + new RabbitMqIntegrationListenerService( + handler: provider.GetRequiredService>(), + configuration: listenerConfiguration, + rabbitMqService: provider.GetRequiredService(), + loggerFactory: provider.GetRequiredService(), + timeProvider: provider.GetRequiredService() + ) + ) + ); + + return services; + } + + internal static IServiceCollection AddOrganizationIntegrationCommandsQueries(this IServiceCollection services) + { + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + + return services; + } + + internal static IServiceCollection AddOrganizationIntegrationConfigurationCommandsQueries(this IServiceCollection services) + { + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + + return services; + } + + /// + /// Determines if RabbitMQ is enabled for event integrations based on configuration settings. + /// + /// The global settings containing RabbitMQ configuration. + /// True if all required RabbitMQ settings are present; otherwise, false. + /// + /// Requires all the following settings to be configured: + /// + /// EventLogging.RabbitMq.HostName + /// EventLogging.RabbitMq.Username + /// EventLogging.RabbitMq.Password + /// EventLogging.RabbitMq.EventExchangeName + /// EventLogging.RabbitMq.IntegrationExchangeName + /// + /// + internal static bool IsRabbitMqEnabled(GlobalSettings settings) + { + return CoreHelpers.SettingHasValue(settings.EventLogging.RabbitMq.HostName) && + CoreHelpers.SettingHasValue(settings.EventLogging.RabbitMq.Username) && + CoreHelpers.SettingHasValue(settings.EventLogging.RabbitMq.Password) && + CoreHelpers.SettingHasValue(settings.EventLogging.RabbitMq.EventExchangeName) && + CoreHelpers.SettingHasValue(settings.EventLogging.RabbitMq.IntegrationExchangeName); + } + + /// + /// Determines if Azure Service Bus is enabled for event integrations based on configuration settings. + /// + /// The global settings containing Azure Service Bus configuration. + /// True if all required Azure Service Bus settings are present; otherwise, false. + /// + /// Requires all of the following settings to be configured: + /// + /// EventLogging.AzureServiceBus.ConnectionString + /// EventLogging.AzureServiceBus.EventTopicName + /// EventLogging.AzureServiceBus.IntegrationTopicName + /// + /// + internal static bool IsAzureServiceBusEnabled(GlobalSettings settings) + { + return CoreHelpers.SettingHasValue(settings.EventLogging.AzureServiceBus.ConnectionString) && + CoreHelpers.SettingHasValue(settings.EventLogging.AzureServiceBus.EventTopicName) && + CoreHelpers.SettingHasValue(settings.EventLogging.AzureServiceBus.IntegrationTopicName); + } +} diff --git a/src/Core/Dirt/EventIntegrations/OrganizationIntegrationConfigurations/CreateOrganizationIntegrationConfigurationCommand.cs b/src/Core/Dirt/EventIntegrations/OrganizationIntegrationConfigurations/CreateOrganizationIntegrationConfigurationCommand.cs new file mode 100644 index 0000000000..478b43bb7e --- /dev/null +++ b/src/Core/Dirt/EventIntegrations/OrganizationIntegrationConfigurations/CreateOrganizationIntegrationConfigurationCommand.cs @@ -0,0 +1,64 @@ +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Dirt.Services; +using Bit.Core.Exceptions; +using Bit.Core.Utilities; +using Microsoft.Extensions.DependencyInjection; +using ZiggyCreatures.Caching.Fusion; + +namespace Bit.Core.Dirt.EventIntegrations.OrganizationIntegrationConfigurations; + +/// +/// Command implementation for creating organization integration configurations with validation and cache invalidation support. +/// +public class CreateOrganizationIntegrationConfigurationCommand( + IOrganizationIntegrationRepository integrationRepository, + IOrganizationIntegrationConfigurationRepository configurationRepository, + [FromKeyedServices(EventIntegrationsCacheConstants.CacheName)] IFusionCache cache, + IOrganizationIntegrationConfigurationValidator validator) + : ICreateOrganizationIntegrationConfigurationCommand +{ + public async Task CreateAsync( + Guid organizationId, + Guid integrationId, + OrganizationIntegrationConfiguration configuration) + { + var integration = await integrationRepository.GetByIdAsync(integrationId); + if (integration == null || integration.OrganizationId != organizationId) + { + throw new NotFoundException(); + } + if (!validator.ValidateConfiguration(integration.Type, configuration)) + { + throw new BadRequestException( + $"Invalid Configuration and/or Filters for integration type {integration.Type}"); + } + + var created = await configurationRepository.CreateAsync(configuration); + + // Invalidate the cached configuration details + // Even though this is a new record, the cache could hold a stale empty list for this + if (created.EventType == null) + { + // Wildcard configuration - invalidate all cached results for this org/integration + await cache.RemoveByTagAsync( + EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration( + organizationId: organizationId, + integrationType: integration.Type + )); + } + else + { + // Specific event type - only invalidate that specific cache entry + await cache.RemoveAsync( + EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails( + organizationId: organizationId, + integrationType: integration.Type, + eventType: created.EventType.Value + )); + } + + return created; + } +} diff --git a/src/Core/Dirt/EventIntegrations/OrganizationIntegrationConfigurations/DeleteOrganizationIntegrationConfigurationCommand.cs b/src/Core/Dirt/EventIntegrations/OrganizationIntegrationConfigurations/DeleteOrganizationIntegrationConfigurationCommand.cs new file mode 100644 index 0000000000..d6369f1b1b --- /dev/null +++ b/src/Core/Dirt/EventIntegrations/OrganizationIntegrationConfigurations/DeleteOrganizationIntegrationConfigurationCommand.cs @@ -0,0 +1,54 @@ +using Bit.Core.Dirt.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; +using Bit.Core.Utilities; +using Microsoft.Extensions.DependencyInjection; +using ZiggyCreatures.Caching.Fusion; + +namespace Bit.Core.Dirt.EventIntegrations.OrganizationIntegrationConfigurations; + +/// +/// Command implementation for deleting organization integration configurations with cache invalidation support. +/// +public class DeleteOrganizationIntegrationConfigurationCommand( + IOrganizationIntegrationRepository integrationRepository, + IOrganizationIntegrationConfigurationRepository configurationRepository, + [FromKeyedServices(EventIntegrationsCacheConstants.CacheName)] IFusionCache cache) + : IDeleteOrganizationIntegrationConfigurationCommand +{ + public async Task DeleteAsync(Guid organizationId, Guid integrationId, Guid configurationId) + { + var integration = await integrationRepository.GetByIdAsync(integrationId); + if (integration == null || integration.OrganizationId != organizationId) + { + throw new NotFoundException(); + } + var configuration = await configurationRepository.GetByIdAsync(configurationId); + if (configuration is null || configuration.OrganizationIntegrationId != integrationId) + { + throw new NotFoundException(); + } + + await configurationRepository.DeleteAsync(configuration); + + if (configuration.EventType == null) + { + // Wildcard configuration - invalidate all cached results for this org/integration + await cache.RemoveByTagAsync( + EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration( + organizationId: organizationId, + integrationType: integration.Type + )); + } + else + { + // Specific event type - only invalidate that specific cache entry + await cache.RemoveAsync( + EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails( + organizationId: organizationId, + integrationType: integration.Type, + eventType: configuration.EventType.Value + )); + } + } +} diff --git a/src/Core/Dirt/EventIntegrations/OrganizationIntegrationConfigurations/GetOrganizationIntegrationConfigurationsQuery.cs b/src/Core/Dirt/EventIntegrations/OrganizationIntegrationConfigurations/GetOrganizationIntegrationConfigurationsQuery.cs new file mode 100644 index 0000000000..6dfe2949a4 --- /dev/null +++ b/src/Core/Dirt/EventIntegrations/OrganizationIntegrationConfigurations/GetOrganizationIntegrationConfigurationsQuery.cs @@ -0,0 +1,29 @@ +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; + +namespace Bit.Core.Dirt.EventIntegrations.OrganizationIntegrationConfigurations; + +/// +/// Query implementation for retrieving organization integration configurations. +/// +public class GetOrganizationIntegrationConfigurationsQuery( + IOrganizationIntegrationRepository integrationRepository, + IOrganizationIntegrationConfigurationRepository configurationRepository) + : IGetOrganizationIntegrationConfigurationsQuery +{ + public async Task> GetManyByIntegrationAsync( + Guid organizationId, + Guid integrationId) + { + var integration = await integrationRepository.GetByIdAsync(integrationId); + if (integration == null || integration.OrganizationId != organizationId) + { + throw new NotFoundException(); + } + + var configurations = await configurationRepository.GetManyByIntegrationAsync(integrationId); + return configurations.ToList(); + } +} diff --git a/src/Core/Dirt/EventIntegrations/OrganizationIntegrationConfigurations/Interfaces/ICreateOrganizationIntegrationConfigurationCommand.cs b/src/Core/Dirt/EventIntegrations/OrganizationIntegrationConfigurations/Interfaces/ICreateOrganizationIntegrationConfigurationCommand.cs new file mode 100644 index 0000000000..629a1ee8ed --- /dev/null +++ b/src/Core/Dirt/EventIntegrations/OrganizationIntegrationConfigurations/Interfaces/ICreateOrganizationIntegrationConfigurationCommand.cs @@ -0,0 +1,22 @@ +using Bit.Core.Dirt.Entities; + +namespace Bit.Core.Dirt.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces; + +/// +/// Command interface for creating organization integration configurations. +/// +public interface ICreateOrganizationIntegrationConfigurationCommand +{ + /// + /// Creates a new configuration for an organization integration. + /// + /// The unique identifier of the organization. + /// The unique identifier of the integration. + /// The configuration to create. + /// The created configuration. + /// Thrown when the integration does not exist + /// or does not belong to the specified organization. + /// Thrown when the configuration or filters + /// are invalid for the integration type. + Task CreateAsync(Guid organizationId, Guid integrationId, OrganizationIntegrationConfiguration configuration); +} diff --git a/src/Core/Dirt/EventIntegrations/OrganizationIntegrationConfigurations/Interfaces/IDeleteOrganizationIntegrationConfigurationCommand.cs b/src/Core/Dirt/EventIntegrations/OrganizationIntegrationConfigurations/Interfaces/IDeleteOrganizationIntegrationConfigurationCommand.cs new file mode 100644 index 0000000000..d6866443c2 --- /dev/null +++ b/src/Core/Dirt/EventIntegrations/OrganizationIntegrationConfigurations/Interfaces/IDeleteOrganizationIntegrationConfigurationCommand.cs @@ -0,0 +1,19 @@ +namespace Bit.Core.Dirt.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces; + +/// +/// Command interface for deleting organization integration configurations. +/// +public interface IDeleteOrganizationIntegrationConfigurationCommand +{ + /// + /// Deletes a configuration from an organization integration. + /// + /// The unique identifier of the organization. + /// The unique identifier of the integration. + /// The unique identifier of the configuration to delete. + /// + /// Thrown when the integration or configuration does not exist, + /// or the integration does not belong to the specified organization, + /// or the configuration does not belong to the specified integration. + Task DeleteAsync(Guid organizationId, Guid integrationId, Guid configurationId); +} diff --git a/src/Core/Dirt/EventIntegrations/OrganizationIntegrationConfigurations/Interfaces/IGetOrganizationIntegrationConfigurationsQuery.cs b/src/Core/Dirt/EventIntegrations/OrganizationIntegrationConfigurations/Interfaces/IGetOrganizationIntegrationConfigurationsQuery.cs new file mode 100644 index 0000000000..a6635cb3be --- /dev/null +++ b/src/Core/Dirt/EventIntegrations/OrganizationIntegrationConfigurations/Interfaces/IGetOrganizationIntegrationConfigurationsQuery.cs @@ -0,0 +1,19 @@ +using Bit.Core.Dirt.Entities; + +namespace Bit.Core.Dirt.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces; + +/// +/// Query interface for retrieving organization integration configurations. +/// +public interface IGetOrganizationIntegrationConfigurationsQuery +{ + /// + /// Retrieves all configurations for a specific organization integration. + /// + /// The unique identifier of the organization. + /// The unique identifier of the integration. + /// A list of configurations associated with the integration. + /// Thrown when the integration does not exist + /// or does not belong to the specified organization. + Task> GetManyByIntegrationAsync(Guid organizationId, Guid integrationId); +} diff --git a/src/Core/Dirt/EventIntegrations/OrganizationIntegrationConfigurations/Interfaces/IUpdateOrganizationIntegrationConfigurationCommand.cs b/src/Core/Dirt/EventIntegrations/OrganizationIntegrationConfigurations/Interfaces/IUpdateOrganizationIntegrationConfigurationCommand.cs new file mode 100644 index 0000000000..3ed680b808 --- /dev/null +++ b/src/Core/Dirt/EventIntegrations/OrganizationIntegrationConfigurations/Interfaces/IUpdateOrganizationIntegrationConfigurationCommand.cs @@ -0,0 +1,25 @@ +using Bit.Core.Dirt.Entities; + +namespace Bit.Core.Dirt.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces; + +/// +/// Command interface for updating organization integration configurations. +/// +public interface IUpdateOrganizationIntegrationConfigurationCommand +{ + /// + /// Updates an existing configuration for an organization integration. + /// + /// The unique identifier of the organization. + /// The unique identifier of the integration. + /// The unique identifier of the configuration to update. + /// The updated configuration data. + /// The updated configuration. + /// + /// Thrown when the integration or the configuration does not exist, + /// or the integration does not belong to the specified organization, + /// or the configuration does not belong to the specified integration. + /// Thrown when the configuration or filters + /// are invalid for the integration type. + Task UpdateAsync(Guid organizationId, Guid integrationId, Guid configurationId, OrganizationIntegrationConfiguration updatedConfiguration); +} diff --git a/src/Core/Dirt/EventIntegrations/OrganizationIntegrationConfigurations/UpdateOrganizationIntegrationConfigurationCommand.cs b/src/Core/Dirt/EventIntegrations/OrganizationIntegrationConfigurations/UpdateOrganizationIntegrationConfigurationCommand.cs new file mode 100644 index 0000000000..69c28f3e7e --- /dev/null +++ b/src/Core/Dirt/EventIntegrations/OrganizationIntegrationConfigurations/UpdateOrganizationIntegrationConfigurationCommand.cs @@ -0,0 +1,82 @@ +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Dirt.Services; +using Bit.Core.Exceptions; +using Bit.Core.Utilities; +using Microsoft.Extensions.DependencyInjection; +using ZiggyCreatures.Caching.Fusion; + +namespace Bit.Core.Dirt.EventIntegrations.OrganizationIntegrationConfigurations; + +/// +/// Command implementation for updating organization integration configurations with validation and cache invalidation support. +/// +public class UpdateOrganizationIntegrationConfigurationCommand( + IOrganizationIntegrationRepository integrationRepository, + IOrganizationIntegrationConfigurationRepository configurationRepository, + [FromKeyedServices(EventIntegrationsCacheConstants.CacheName)] IFusionCache cache, + IOrganizationIntegrationConfigurationValidator validator) + : IUpdateOrganizationIntegrationConfigurationCommand +{ + public async Task UpdateAsync( + Guid organizationId, + Guid integrationId, + Guid configurationId, + OrganizationIntegrationConfiguration updatedConfiguration) + { + var integration = await integrationRepository.GetByIdAsync(integrationId); + if (integration == null || integration.OrganizationId != organizationId) + { + throw new NotFoundException(); + } + var configuration = await configurationRepository.GetByIdAsync(configurationId); + if (configuration is null || configuration.OrganizationIntegrationId != integrationId) + { + throw new NotFoundException(); + } + if (!validator.ValidateConfiguration(integration.Type, updatedConfiguration)) + { + throw new BadRequestException($"Invalid Configuration and/or Filters for integration type {integration.Type}"); + } + + updatedConfiguration.Id = configuration.Id; + updatedConfiguration.CreationDate = configuration.CreationDate; + await configurationRepository.ReplaceAsync(updatedConfiguration); + + // If either old or new EventType is null (wildcard), invalidate all cached results + // for the specific integration + if (configuration.EventType == null || updatedConfiguration.EventType == null) + { + // Wildcard involved - invalidate all cached results for this org/integration + await cache.RemoveByTagAsync( + EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration( + organizationId: organizationId, + integrationType: integration.Type + )); + + return updatedConfiguration; + } + + // Both are specific event types - invalidate specific cache entries + await cache.RemoveAsync( + EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails( + organizationId: organizationId, + integrationType: integration.Type, + eventType: configuration.EventType.Value + )); + + // If event type changed, also clear the new event type's cache + if (configuration.EventType != updatedConfiguration.EventType) + { + await cache.RemoveAsync( + EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails( + organizationId: organizationId, + integrationType: integration.Type, + eventType: updatedConfiguration.EventType.Value + )); + } + + return updatedConfiguration; + } +} diff --git a/src/Core/Dirt/EventIntegrations/OrganizationIntegrations/CreateOrganizationIntegrationCommand.cs b/src/Core/Dirt/EventIntegrations/OrganizationIntegrations/CreateOrganizationIntegrationCommand.cs new file mode 100644 index 0000000000..4423c103f9 --- /dev/null +++ b/src/Core/Dirt/EventIntegrations/OrganizationIntegrations/CreateOrganizationIntegrationCommand.cs @@ -0,0 +1,38 @@ +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.EventIntegrations.OrganizationIntegrations.Interfaces; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; +using Bit.Core.Utilities; +using Microsoft.Extensions.DependencyInjection; +using ZiggyCreatures.Caching.Fusion; + +namespace Bit.Core.Dirt.EventIntegrations.OrganizationIntegrations; + +/// +/// Command implementation for creating organization integrations with cache invalidation support. +/// +public class CreateOrganizationIntegrationCommand( + IOrganizationIntegrationRepository integrationRepository, + [FromKeyedServices(EventIntegrationsCacheConstants.CacheName)] + IFusionCache cache) + : ICreateOrganizationIntegrationCommand +{ + public async Task CreateAsync(OrganizationIntegration integration) + { + var existingIntegrations = await integrationRepository + .GetManyByOrganizationAsync(integration.OrganizationId); + if (existingIntegrations.Any(i => i.Type == integration.Type)) + { + throw new BadRequestException("An integration of this type already exists for this organization."); + } + + var created = await integrationRepository.CreateAsync(integration); + await cache.RemoveByTagAsync( + EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration( + organizationId: integration.OrganizationId, + integrationType: integration.Type + )); + + return created; + } +} diff --git a/src/Core/Dirt/EventIntegrations/OrganizationIntegrations/DeleteOrganizationIntegrationCommand.cs b/src/Core/Dirt/EventIntegrations/OrganizationIntegrations/DeleteOrganizationIntegrationCommand.cs new file mode 100644 index 0000000000..dc1e7fb1dc --- /dev/null +++ b/src/Core/Dirt/EventIntegrations/OrganizationIntegrations/DeleteOrganizationIntegrationCommand.cs @@ -0,0 +1,33 @@ +using Bit.Core.Dirt.EventIntegrations.OrganizationIntegrations.Interfaces; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; +using Bit.Core.Utilities; +using Microsoft.Extensions.DependencyInjection; +using ZiggyCreatures.Caching.Fusion; + +namespace Bit.Core.Dirt.EventIntegrations.OrganizationIntegrations; + +/// +/// Command implementation for deleting organization integrations with cache invalidation support. +/// +public class DeleteOrganizationIntegrationCommand( + IOrganizationIntegrationRepository integrationRepository, + [FromKeyedServices(EventIntegrationsCacheConstants.CacheName)] IFusionCache cache) + : IDeleteOrganizationIntegrationCommand +{ + public async Task DeleteAsync(Guid organizationId, Guid integrationId) + { + var integration = await integrationRepository.GetByIdAsync(integrationId); + if (integration is null || integration.OrganizationId != organizationId) + { + throw new NotFoundException(); + } + + await integrationRepository.DeleteAsync(integration); + await cache.RemoveByTagAsync( + EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration( + organizationId: organizationId, + integrationType: integration.Type + )); + } +} diff --git a/src/Core/Dirt/EventIntegrations/OrganizationIntegrations/GetOrganizationIntegrationsQuery.cs b/src/Core/Dirt/EventIntegrations/OrganizationIntegrations/GetOrganizationIntegrationsQuery.cs new file mode 100644 index 0000000000..807f0b0b59 --- /dev/null +++ b/src/Core/Dirt/EventIntegrations/OrganizationIntegrations/GetOrganizationIntegrationsQuery.cs @@ -0,0 +1,18 @@ +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.EventIntegrations.OrganizationIntegrations.Interfaces; +using Bit.Core.Dirt.Repositories; + +namespace Bit.Core.Dirt.EventIntegrations.OrganizationIntegrations; + +/// +/// Query implementation for retrieving organization integrations. +/// +public class GetOrganizationIntegrationsQuery(IOrganizationIntegrationRepository integrationRepository) + : IGetOrganizationIntegrationsQuery +{ + public async Task> GetManyByOrganizationAsync(Guid organizationId) + { + var integrations = await integrationRepository.GetManyByOrganizationAsync(organizationId); + return integrations.ToList(); + } +} diff --git a/src/Core/Dirt/EventIntegrations/OrganizationIntegrations/Interfaces/ICreateOrganizationIntegrationCommand.cs b/src/Core/Dirt/EventIntegrations/OrganizationIntegrations/Interfaces/ICreateOrganizationIntegrationCommand.cs new file mode 100644 index 0000000000..0b06d79bdb --- /dev/null +++ b/src/Core/Dirt/EventIntegrations/OrganizationIntegrations/Interfaces/ICreateOrganizationIntegrationCommand.cs @@ -0,0 +1,18 @@ +using Bit.Core.Dirt.Entities; + +namespace Bit.Core.Dirt.EventIntegrations.OrganizationIntegrations.Interfaces; + +/// +/// Command interface for creating an OrganizationIntegration. +/// +public interface ICreateOrganizationIntegrationCommand +{ + /// + /// Creates a new organization integration. + /// + /// The OrganizationIntegration to create. + /// The created OrganizationIntegration. + /// Thrown when an integration + /// of the same type already exists for the organization. + Task CreateAsync(OrganizationIntegration integration); +} diff --git a/src/Core/Dirt/EventIntegrations/OrganizationIntegrations/Interfaces/IDeleteOrganizationIntegrationCommand.cs b/src/Core/Dirt/EventIntegrations/OrganizationIntegrations/Interfaces/IDeleteOrganizationIntegrationCommand.cs new file mode 100644 index 0000000000..8640f03ec8 --- /dev/null +++ b/src/Core/Dirt/EventIntegrations/OrganizationIntegrations/Interfaces/IDeleteOrganizationIntegrationCommand.cs @@ -0,0 +1,16 @@ +namespace Bit.Core.Dirt.EventIntegrations.OrganizationIntegrations.Interfaces; + +/// +/// Command interface for deleting organization integrations. +/// +public interface IDeleteOrganizationIntegrationCommand +{ + /// + /// Deletes an organization integration. + /// + /// The unique identifier of the organization. + /// The unique identifier of the integration to delete. + /// Thrown when the integration does not exist + /// or does not belong to the specified organization. + Task DeleteAsync(Guid organizationId, Guid integrationId); +} diff --git a/src/Core/Dirt/EventIntegrations/OrganizationIntegrations/Interfaces/IGetOrganizationIntegrationsQuery.cs b/src/Core/Dirt/EventIntegrations/OrganizationIntegrations/Interfaces/IGetOrganizationIntegrationsQuery.cs new file mode 100644 index 0000000000..1f378abe9b --- /dev/null +++ b/src/Core/Dirt/EventIntegrations/OrganizationIntegrations/Interfaces/IGetOrganizationIntegrationsQuery.cs @@ -0,0 +1,16 @@ +using Bit.Core.Dirt.Entities; + +namespace Bit.Core.Dirt.EventIntegrations.OrganizationIntegrations.Interfaces; + +/// +/// Query interface for retrieving organization integrations. +/// +public interface IGetOrganizationIntegrationsQuery +{ + /// + /// Retrieves all organization integrations for a specific organization. + /// + /// The unique identifier of the organization. + /// A list of organization integrations associated with the organization. + Task> GetManyByOrganizationAsync(Guid organizationId); +} diff --git a/src/Core/Dirt/EventIntegrations/OrganizationIntegrations/Interfaces/IUpdateOrganizationIntegrationCommand.cs b/src/Core/Dirt/EventIntegrations/OrganizationIntegrations/Interfaces/IUpdateOrganizationIntegrationCommand.cs new file mode 100644 index 0000000000..ddba2bd233 --- /dev/null +++ b/src/Core/Dirt/EventIntegrations/OrganizationIntegrations/Interfaces/IUpdateOrganizationIntegrationCommand.cs @@ -0,0 +1,20 @@ +using Bit.Core.Dirt.Entities; + +namespace Bit.Core.Dirt.EventIntegrations.OrganizationIntegrations.Interfaces; + +/// +/// Command interface for updating organization integrations. +/// +public interface IUpdateOrganizationIntegrationCommand +{ + /// + /// Updates an existing organization integration. + /// + /// The unique identifier of the organization. + /// The unique identifier of the integration to update. + /// The updated organization integration data. + /// The updated organization integration. + /// Thrown when the integration does not exist, + /// does not belong to the specified organization, or the integration type does not match. + Task UpdateAsync(Guid organizationId, Guid integrationId, OrganizationIntegration updatedIntegration); +} diff --git a/src/Core/Dirt/EventIntegrations/OrganizationIntegrations/UpdateOrganizationIntegrationCommand.cs b/src/Core/Dirt/EventIntegrations/OrganizationIntegrations/UpdateOrganizationIntegrationCommand.cs new file mode 100644 index 0000000000..77a3448276 --- /dev/null +++ b/src/Core/Dirt/EventIntegrations/OrganizationIntegrations/UpdateOrganizationIntegrationCommand.cs @@ -0,0 +1,45 @@ +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.EventIntegrations.OrganizationIntegrations.Interfaces; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; +using Bit.Core.Utilities; +using Microsoft.Extensions.DependencyInjection; +using ZiggyCreatures.Caching.Fusion; + +namespace Bit.Core.Dirt.EventIntegrations.OrganizationIntegrations; + +/// +/// Command implementation for updating organization integrations with cache invalidation support. +/// +public class UpdateOrganizationIntegrationCommand( + IOrganizationIntegrationRepository integrationRepository, + [FromKeyedServices(EventIntegrationsCacheConstants.CacheName)] + IFusionCache cache) + : IUpdateOrganizationIntegrationCommand +{ + public async Task UpdateAsync( + Guid organizationId, + Guid integrationId, + OrganizationIntegration updatedIntegration) + { + var integration = await integrationRepository.GetByIdAsync(integrationId); + if (integration is null || + integration.OrganizationId != organizationId || + integration.Type != updatedIntegration.Type) + { + throw new NotFoundException(); + } + + updatedIntegration.Id = integration.Id; + updatedIntegration.OrganizationId = integration.OrganizationId; + updatedIntegration.CreationDate = integration.CreationDate; + await integrationRepository.ReplaceAsync(updatedIntegration); + await cache.RemoveByTagAsync( + EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration( + organizationId: organizationId, + integrationType: integration.Type + )); + + return updatedIntegration; + } +} diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/README.md b/src/Core/Dirt/EventIntegrations/README.md similarity index 89% rename from src/Core/AdminConsole/Services/Implementations/EventIntegrations/README.md rename to src/Core/Dirt/EventIntegrations/README.md index 7570d47211..f9de5b9778 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/README.md +++ b/src/Core/Dirt/EventIntegrations/README.md @@ -295,33 +295,60 @@ graph TD ``` ## Caching -To reduce database load and improve performance, integration configurations are cached in-memory as a Dictionary -with a periodic load of all configurations. Without caching, each incoming `EventMessage` would trigger a database +To reduce database load and improve performance, event integrations uses its own named extended cache (see +[CACHING in Utilities](https://github.com/bitwarden/server/blob/main/src/Core/Utilities/CACHING.md) +for more information). Without caching, for instance, each incoming `EventMessage` would trigger a database query to retrieve the relevant `OrganizationIntegrationConfigurationDetails`. -By loading all configurations into memory on a fixed interval, we ensure: +### `EventIntegrationsCacheConstants` -- Consistent performance for reads. -- Reduced database pressure. -- Predictable refresh timing, independent of event activity. +`EventIntegrationsCacheConstants` allows the code to have strongly typed references to a number of cache-related +details when working with the extended cache. The cache name and all cache keys and tags are programmatically accessed +from `EventIntegrationsCacheConstants` rather than simple strings. For instance, +`EventIntegrationsCacheConstants.CacheName` is used in the cache setup, keyed services, dependency injection, etc., +rather than using a string literal (i.e. "EventIntegrations") in code. -### Architecture / Design +### `OrganizationIntegrationConfigurationDetails` -- The cache is read-only for consumers. It is only updated in bulk by a background refresh process. -- The cache is fully replaced on each refresh to avoid locking or partial state. +- This is one of the most actively used portions of the architecture because any event that has an associated + organization requires a check of the configurations to determine if we need to fire off an integration. +- By using the extended cache, all reads are hitting the L1 or L2 cache before needing to access the database. - Reads return a `List` for a given key or an empty list if no match exists. -- Failures or delays in the loading process do not affect the existing cache state. The cache will continue serving - the last known good state until the update replaces the whole cache. +- The TTL is set very high on these records (1 day). This is because when the admin API makes any changes, it + tells the cache to remove that key. This propagates to the event listening code via the extended cache backplane, + which means that the cache is then expired and the next read will fetch the new values. This allows us to have + a high TTL and avoid needing to refresh values except when necessary. -### Background Refresh +#### Tagging per integration -A hosted service (`IntegrationConfigurationDetailsCacheService`) runs in the background and: +- Each entry in the cache (which again, returns `List`) is tagged with + the organization id and the integration type. +- This allows us to remove all of a given organization's configuration details for an integration when the admin + makes changes at the integration level. + - For instance, if there were 5 events configured for a given organization's webhook and the admin changed the URL + at the integration level, the updates would need to be propagated or else the cache will continue returning the + stale URL. + - By tagging each of the entries, the API can ask the extended cache to remove all the entries for a given + organization integration in one call. The cache will handle dropping / refreshing these entries in a + performant way. +- There are two places in the code that are both aware of the tagging functionality + - The `EventIntegrationHandler` must use the tag when fetching relevant configuration details. This tells the cache + to store the entry with the tag when it successfully loads from the repository. + - The `CreateOrganizationIntegrationCommand`, `UpdateOrganizationIntegrationCommand`, and + `DeleteOrganizationIntegrationCommand` commands need to use the tag to remove all the tagged entries when an admin + creates, updates, or deletes an integration. + - To ensure both places are synchronized on how to tag entries, they both use + `EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration` to build the tag. -- Loads all configuration records at application startup. -- Refreshes the cache on a configurable interval. -- Logs timing and entry count on success. -- Logs exceptions on failure without disrupting application flow. +### Template Properties + +- The `IntegrationTemplateProcessor` supports some properties that require an additional lookup. For instance, + the `UserId` is provided as part of the `EventMessage`, but `UserName` means an additional lookup to map the user + id to the actual name. +- The properties for a `User` (which includes `ActingUser`), `Group`, and `Organization` are cached via the + extended cache with a default TTL of 30 minutes. +- This is cached in both the L1 (Memory) and L2 (Redis) and will be automatically refreshed as needed. # Building a new integration diff --git a/src/Core/Dirt/Models/Data/EventIntegrations/DatadogIntegration.cs b/src/Core/Dirt/Models/Data/EventIntegrations/DatadogIntegration.cs new file mode 100644 index 0000000000..69a4deb66b --- /dev/null +++ b/src/Core/Dirt/Models/Data/EventIntegrations/DatadogIntegration.cs @@ -0,0 +1,3 @@ +namespace Bit.Core.Dirt.Models.Data.EventIntegrations; + +public record DatadogIntegration(string ApiKey, Uri Uri); diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/DatadogIntegrationConfigurationDetails.cs b/src/Core/Dirt/Models/Data/EventIntegrations/DatadogIntegrationConfigurationDetails.cs similarity index 54% rename from src/Core/AdminConsole/Models/Data/EventIntegrations/DatadogIntegrationConfigurationDetails.cs rename to src/Core/Dirt/Models/Data/EventIntegrations/DatadogIntegrationConfigurationDetails.cs index 07aafa4bd8..ed91c3828b 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/DatadogIntegrationConfigurationDetails.cs +++ b/src/Core/Dirt/Models/Data/EventIntegrations/DatadogIntegrationConfigurationDetails.cs @@ -1,3 +1,3 @@ -namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; +namespace Bit.Core.Dirt.Models.Data.EventIntegrations; public record DatadogIntegrationConfigurationDetails(string ApiKey, Uri Uri); diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/DatadogListenerConfiguration.cs b/src/Core/Dirt/Models/Data/EventIntegrations/DatadogListenerConfiguration.cs similarity index 91% rename from src/Core/AdminConsole/Models/Data/EventIntegrations/DatadogListenerConfiguration.cs rename to src/Core/Dirt/Models/Data/EventIntegrations/DatadogListenerConfiguration.cs index 1c74826791..ce35e29927 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/DatadogListenerConfiguration.cs +++ b/src/Core/Dirt/Models/Data/EventIntegrations/DatadogListenerConfiguration.cs @@ -1,7 +1,7 @@ -using Bit.Core.Enums; +using Bit.Core.Dirt.Enums; using Bit.Core.Settings; -namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; +namespace Bit.Core.Dirt.Models.Data.EventIntegrations; public class DatadogListenerConfiguration(GlobalSettings globalSettings) : ListenerConfiguration(globalSettings), IIntegrationListenerConfiguration diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/HecIntegration.cs b/src/Core/Dirt/Models/Data/EventIntegrations/HecIntegration.cs similarity index 58% rename from src/Core/AdminConsole/Models/Data/EventIntegrations/HecIntegration.cs rename to src/Core/Dirt/Models/Data/EventIntegrations/HecIntegration.cs index 33ae5dadbe..df943e0bfc 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/HecIntegration.cs +++ b/src/Core/Dirt/Models/Data/EventIntegrations/HecIntegration.cs @@ -1,3 +1,3 @@ -namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; +namespace Bit.Core.Dirt.Models.Data.EventIntegrations; public record HecIntegration(Uri Uri, string Scheme, string Token, string? Service = null); diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/HecListenerConfiguration.cs b/src/Core/Dirt/Models/Data/EventIntegrations/HecListenerConfiguration.cs similarity index 91% rename from src/Core/AdminConsole/Models/Data/EventIntegrations/HecListenerConfiguration.cs rename to src/Core/Dirt/Models/Data/EventIntegrations/HecListenerConfiguration.cs index 37a0d68beb..5ceb42be64 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/HecListenerConfiguration.cs +++ b/src/Core/Dirt/Models/Data/EventIntegrations/HecListenerConfiguration.cs @@ -1,7 +1,7 @@ -using Bit.Core.Enums; +using Bit.Core.Dirt.Enums; using Bit.Core.Settings; -namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; +namespace Bit.Core.Dirt.Models.Data.EventIntegrations; public class HecListenerConfiguration(GlobalSettings globalSettings) : ListenerConfiguration(globalSettings), IIntegrationListenerConfiguration diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/IEventListenerConfiguration.cs b/src/Core/Dirt/Models/Data/EventIntegrations/IEventListenerConfiguration.cs similarity index 80% rename from src/Core/AdminConsole/Models/Data/EventIntegrations/IEventListenerConfiguration.cs rename to src/Core/Dirt/Models/Data/EventIntegrations/IEventListenerConfiguration.cs index 7df1459941..206dc2cc0b 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/IEventListenerConfiguration.cs +++ b/src/Core/Dirt/Models/Data/EventIntegrations/IEventListenerConfiguration.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; +namespace Bit.Core.Dirt.Models.Data.EventIntegrations; public interface IEventListenerConfiguration { diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/IIntegrationListenerConfiguration.cs b/src/Core/Dirt/Models/Data/EventIntegrations/IIntegrationListenerConfiguration.cs similarity index 86% rename from src/Core/AdminConsole/Models/Data/EventIntegrations/IIntegrationListenerConfiguration.cs rename to src/Core/Dirt/Models/Data/EventIntegrations/IIntegrationListenerConfiguration.cs index 30401bb072..1fbfefa420 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/IIntegrationListenerConfiguration.cs +++ b/src/Core/Dirt/Models/Data/EventIntegrations/IIntegrationListenerConfiguration.cs @@ -1,6 +1,6 @@ -using Bit.Core.Enums; +using Bit.Core.Dirt.Enums; -namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; +namespace Bit.Core.Dirt.Models.Data.EventIntegrations; public interface IIntegrationListenerConfiguration : IEventListenerConfiguration { diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/IIntegrationMessage.cs b/src/Core/Dirt/Models/Data/EventIntegrations/IIntegrationMessage.cs similarity index 77% rename from src/Core/AdminConsole/Models/Data/EventIntegrations/IIntegrationMessage.cs rename to src/Core/Dirt/Models/Data/EventIntegrations/IIntegrationMessage.cs index 5b6bfe2e53..2d333dfee4 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/IIntegrationMessage.cs +++ b/src/Core/Dirt/Models/Data/EventIntegrations/IIntegrationMessage.cs @@ -1,6 +1,6 @@ -using Bit.Core.Enums; +using Bit.Core.Dirt.Enums; -namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; +namespace Bit.Core.Dirt.Models.Data.EventIntegrations; public interface IIntegrationMessage { diff --git a/src/Core/Dirt/Models/Data/EventIntegrations/IntegrationFailureCategory.cs b/src/Core/Dirt/Models/Data/EventIntegrations/IntegrationFailureCategory.cs new file mode 100644 index 0000000000..f9d8f2ab68 --- /dev/null +++ b/src/Core/Dirt/Models/Data/EventIntegrations/IntegrationFailureCategory.cs @@ -0,0 +1,37 @@ +namespace Bit.Core.Dirt.Models.Data.EventIntegrations; + +/// +/// Categories of event integration failures used for classification and retry logic. +/// +public enum IntegrationFailureCategory +{ + /// + /// Service is temporarily unavailable (503, upstream outage, maintenance). + /// + ServiceUnavailable, + + /// + /// Authentication failed (401, 403, invalid_auth, token issues). + /// + AuthenticationFailed, + + /// + /// Configuration error (invalid config, channel_not_found, etc.). + /// + ConfigurationError, + + /// + /// Rate limited (429, rate_limited). + /// + RateLimited, + + /// + /// Transient error (timeouts, 500, network errors). + /// + TransientError, + + /// + /// Permanent failure unrelated to authentication/config (e.g., unrecoverable payload/format issue). + /// + PermanentFailure +} diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationFilterGroup.cs b/src/Core/Dirt/Models/Data/EventIntegrations/IntegrationFilterGroup.cs similarity index 76% rename from src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationFilterGroup.cs rename to src/Core/Dirt/Models/Data/EventIntegrations/IntegrationFilterGroup.cs index 276ca3a14b..0c129883cf 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationFilterGroup.cs +++ b/src/Core/Dirt/Models/Data/EventIntegrations/IntegrationFilterGroup.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; +namespace Bit.Core.Dirt.Models.Data.EventIntegrations; public class IntegrationFilterGroup { diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationFilterOperation.cs b/src/Core/Dirt/Models/Data/EventIntegrations/IntegrationFilterOperation.cs similarity index 61% rename from src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationFilterOperation.cs rename to src/Core/Dirt/Models/Data/EventIntegrations/IntegrationFilterOperation.cs index fddf630e26..d98ab1e13e 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationFilterOperation.cs +++ b/src/Core/Dirt/Models/Data/EventIntegrations/IntegrationFilterOperation.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; +namespace Bit.Core.Dirt.Models.Data.EventIntegrations; public enum IntegrationFilterOperation { diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationFilterRule.cs b/src/Core/Dirt/Models/Data/EventIntegrations/IntegrationFilterRule.cs similarity index 76% rename from src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationFilterRule.cs rename to src/Core/Dirt/Models/Data/EventIntegrations/IntegrationFilterRule.cs index b5f90f5e63..9ac3ef753e 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationFilterRule.cs +++ b/src/Core/Dirt/Models/Data/EventIntegrations/IntegrationFilterRule.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; +namespace Bit.Core.Dirt.Models.Data.EventIntegrations; public class IntegrationFilterRule { diff --git a/src/Core/Dirt/Models/Data/EventIntegrations/IntegrationHandlerResult.cs b/src/Core/Dirt/Models/Data/EventIntegrations/IntegrationHandlerResult.cs new file mode 100644 index 0000000000..bbdce50ec0 --- /dev/null +++ b/src/Core/Dirt/Models/Data/EventIntegrations/IntegrationHandlerResult.cs @@ -0,0 +1,84 @@ +namespace Bit.Core.Dirt.Models.Data.EventIntegrations; + +/// +/// Represents the result of an integration handler operation, including success status, +/// failure categorization, and retry metadata. Use the factory method +/// for successful operations or for failures with automatic retry-ability +/// determination based on the failure category. +/// +public class IntegrationHandlerResult +{ + /// + /// True if the integration send succeeded, false otherwise. + /// + public bool Success { get; } + + /// + /// The integration message that was processed. + /// + public IIntegrationMessage Message { get; } + + /// + /// Optional UTC date/time indicating when a failed operation should be retried. + /// Will be used by the retry queue to delay re-sending the message. + /// Usually set based on the Retry-After header from rate-limited responses. + /// + public DateTime? DelayUntilDate { get; private init; } + + /// + /// Category of the failure. Null for successful results. + /// + public IntegrationFailureCategory? Category { get; private init; } + + /// + /// Detailed failure reason or error message. Empty for successful results. + /// + public string? FailureReason { get; private init; } + + /// + /// Indicates whether the operation is retryable. + /// Computed from the failure category. + /// + public bool Retryable => Category switch + { + IntegrationFailureCategory.RateLimited => true, + IntegrationFailureCategory.TransientError => true, + IntegrationFailureCategory.ServiceUnavailable => true, + IntegrationFailureCategory.AuthenticationFailed => false, + IntegrationFailureCategory.ConfigurationError => false, + IntegrationFailureCategory.PermanentFailure => false, + null => false, + _ => false + }; + + /// + /// Creates a successful result. + /// + public static IntegrationHandlerResult Succeed(IIntegrationMessage message) + { + return new IntegrationHandlerResult(success: true, message: message); + } + + /// + /// Creates a failed result with a failure category and reason. + /// + public static IntegrationHandlerResult Fail( + IIntegrationMessage message, + IntegrationFailureCategory category, + string failureReason, + DateTime? delayUntil = null) + { + return new IntegrationHandlerResult(success: false, message: message) + { + Category = category, + FailureReason = failureReason, + DelayUntilDate = delayUntil + }; + } + + private IntegrationHandlerResult(bool success, IIntegrationMessage message) + { + Success = success; + Message = message; + } +} diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationMessage.cs b/src/Core/Dirt/Models/Data/EventIntegrations/IntegrationMessage.cs similarity index 93% rename from src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationMessage.cs rename to src/Core/Dirt/Models/Data/EventIntegrations/IntegrationMessage.cs index b0fc2161ba..edf31a2a1f 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationMessage.cs +++ b/src/Core/Dirt/Models/Data/EventIntegrations/IntegrationMessage.cs @@ -1,7 +1,7 @@ using System.Text.Json; -using Bit.Core.Enums; +using Bit.Core.Dirt.Enums; -namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; +namespace Bit.Core.Dirt.Models.Data.EventIntegrations; public class IntegrationMessage : IIntegrationMessage { diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationOAuthState.cs b/src/Core/Dirt/Models/Data/EventIntegrations/IntegrationOAuthState.cs similarity index 95% rename from src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationOAuthState.cs rename to src/Core/Dirt/Models/Data/EventIntegrations/IntegrationOAuthState.cs index 3b29bbebb4..d75780d6c6 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationOAuthState.cs +++ b/src/Core/Dirt/Models/Data/EventIntegrations/IntegrationOAuthState.cs @@ -1,8 +1,8 @@ using System.Security.Cryptography; using System.Text; -using Bit.Core.AdminConsole.Entities; +using Bit.Core.Dirt.Entities; -namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; +namespace Bit.Core.Dirt.Models.Data.EventIntegrations; public class IntegrationOAuthState { diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationTemplateContext.cs b/src/Core/Dirt/Models/Data/EventIntegrations/IntegrationTemplateContext.cs similarity index 80% rename from src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationTemplateContext.cs rename to src/Core/Dirt/Models/Data/EventIntegrations/IntegrationTemplateContext.cs index fe33c45156..3b527469fa 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationTemplateContext.cs +++ b/src/Core/Dirt/Models/Data/EventIntegrations/IntegrationTemplateContext.cs @@ -1,10 +1,10 @@ using System.Text.Json; using Bit.Core.AdminConsole.Entities; -using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Data; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; -namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; +namespace Bit.Core.Dirt.Models.Data.EventIntegrations; public class IntegrationTemplateContext(EventMessage eventMessage) { @@ -36,13 +36,18 @@ public class IntegrationTemplateContext(EventMessage eventMessage) public string DateIso8601 => Date.ToString("o"); public string EventMessage => JsonSerializer.Serialize(Event); - public User? User { get; set; } + public OrganizationUserUserDetails? User { get; set; } public string? UserName => User?.Name; public string? UserEmail => User?.Email; + public OrganizationUserType? UserType => User?.Type; - public User? ActingUser { get; set; } + public OrganizationUserUserDetails? ActingUser { get; set; } public string? ActingUserName => ActingUser?.Name; public string? ActingUserEmail => ActingUser?.Email; + public OrganizationUserType? ActingUserType => ActingUser?.Type; + + public Group? Group { get; set; } + public string? GroupName => Group?.Name; public Organization? Organization { get; set; } public string? OrganizationName => Organization?.DisplayName(); diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/ListenerConfiguration.cs b/src/Core/Dirt/Models/Data/EventIntegrations/ListenerConfiguration.cs similarity index 94% rename from src/Core/AdminConsole/Models/Data/EventIntegrations/ListenerConfiguration.cs rename to src/Core/Dirt/Models/Data/EventIntegrations/ListenerConfiguration.cs index 40eb2b3e77..2a970ce670 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/ListenerConfiguration.cs +++ b/src/Core/Dirt/Models/Data/EventIntegrations/ListenerConfiguration.cs @@ -1,6 +1,6 @@ using Bit.Core.Settings; -namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; +namespace Bit.Core.Dirt.Models.Data.EventIntegrations; public abstract class ListenerConfiguration { diff --git a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationIntegrationConfigurationDetails.cs b/src/Core/Dirt/Models/Data/EventIntegrations/OrganizationIntegrationConfigurationDetails.cs similarity index 95% rename from src/Core/AdminConsole/Models/Data/Organizations/OrganizationIntegrationConfigurationDetails.cs rename to src/Core/Dirt/Models/Data/EventIntegrations/OrganizationIntegrationConfigurationDetails.cs index 5fdc760c90..6517ceccf0 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationIntegrationConfigurationDetails.cs +++ b/src/Core/Dirt/Models/Data/EventIntegrations/OrganizationIntegrationConfigurationDetails.cs @@ -1,9 +1,8 @@ using System.Text.Json.Nodes; +using Bit.Core.Dirt.Enums; using Bit.Core.Enums; -#nullable enable - -namespace Bit.Core.Models.Data.Organizations; +namespace Bit.Core.Dirt.Models.Data.EventIntegrations; public class OrganizationIntegrationConfigurationDetails { diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/RepositoryListenerConfiguration.cs b/src/Core/Dirt/Models/Data/EventIntegrations/RepositoryListenerConfiguration.cs similarity index 87% rename from src/Core/AdminConsole/Models/Data/EventIntegrations/RepositoryListenerConfiguration.cs rename to src/Core/Dirt/Models/Data/EventIntegrations/RepositoryListenerConfiguration.cs index 118b3a17fe..20299dd651 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/RepositoryListenerConfiguration.cs +++ b/src/Core/Dirt/Models/Data/EventIntegrations/RepositoryListenerConfiguration.cs @@ -1,6 +1,6 @@ using Bit.Core.Settings; -namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; +namespace Bit.Core.Dirt.Models.Data.EventIntegrations; public class RepositoryListenerConfiguration(GlobalSettings globalSettings) : ListenerConfiguration(globalSettings), IEventListenerConfiguration diff --git a/src/Core/Dirt/Models/Data/EventIntegrations/SlackIntegration.cs b/src/Core/Dirt/Models/Data/EventIntegrations/SlackIntegration.cs new file mode 100644 index 0000000000..fcfd07f574 --- /dev/null +++ b/src/Core/Dirt/Models/Data/EventIntegrations/SlackIntegration.cs @@ -0,0 +1,3 @@ +namespace Bit.Core.Dirt.Models.Data.EventIntegrations; + +public record SlackIntegration(string Token); diff --git a/src/Core/Dirt/Models/Data/EventIntegrations/SlackIntegrationConfiguration.cs b/src/Core/Dirt/Models/Data/EventIntegrations/SlackIntegrationConfiguration.cs new file mode 100644 index 0000000000..164a132e8c --- /dev/null +++ b/src/Core/Dirt/Models/Data/EventIntegrations/SlackIntegrationConfiguration.cs @@ -0,0 +1,3 @@ +namespace Bit.Core.Dirt.Models.Data.EventIntegrations; + +public record SlackIntegrationConfiguration(string ChannelId); diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackIntegrationConfigurationDetails.cs b/src/Core/Dirt/Models/Data/EventIntegrations/SlackIntegrationConfigurationDetails.cs similarity index 56% rename from src/Core/AdminConsole/Models/Data/EventIntegrations/SlackIntegrationConfigurationDetails.cs rename to src/Core/Dirt/Models/Data/EventIntegrations/SlackIntegrationConfigurationDetails.cs index d22f43bb92..b81617118d 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackIntegrationConfigurationDetails.cs +++ b/src/Core/Dirt/Models/Data/EventIntegrations/SlackIntegrationConfigurationDetails.cs @@ -1,3 +1,3 @@ -namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; +namespace Bit.Core.Dirt.Models.Data.EventIntegrations; public record SlackIntegrationConfigurationDetails(string ChannelId, string Token); diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackListenerConfiguration.cs b/src/Core/Dirt/Models/Data/EventIntegrations/SlackListenerConfiguration.cs similarity index 91% rename from src/Core/AdminConsole/Models/Data/EventIntegrations/SlackListenerConfiguration.cs rename to src/Core/Dirt/Models/Data/EventIntegrations/SlackListenerConfiguration.cs index 7dd834f51e..ef2cf83837 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackListenerConfiguration.cs +++ b/src/Core/Dirt/Models/Data/EventIntegrations/SlackListenerConfiguration.cs @@ -1,7 +1,7 @@ -using Bit.Core.Enums; +using Bit.Core.Dirt.Enums; using Bit.Core.Settings; -namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; +namespace Bit.Core.Dirt.Models.Data.EventIntegrations; public class SlackListenerConfiguration(GlobalSettings globalSettings) : ListenerConfiguration(globalSettings), IIntegrationListenerConfiguration diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/TeamsIntegration.cs b/src/Core/Dirt/Models/Data/EventIntegrations/TeamsIntegration.cs similarity index 71% rename from src/Core/AdminConsole/Models/Data/EventIntegrations/TeamsIntegration.cs rename to src/Core/Dirt/Models/Data/EventIntegrations/TeamsIntegration.cs index 8390022839..fcb42a5261 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/TeamsIntegration.cs +++ b/src/Core/Dirt/Models/Data/EventIntegrations/TeamsIntegration.cs @@ -1,6 +1,6 @@ -using Bit.Core.Models.Teams; +using Bit.Core.Dirt.Models.Data.Teams; -namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; +namespace Bit.Core.Dirt.Models.Data.EventIntegrations; public record TeamsIntegration( string TenantId, diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/TeamsIntegrationConfigurationDetails.cs b/src/Core/Dirt/Models/Data/EventIntegrations/TeamsIntegrationConfigurationDetails.cs similarity index 56% rename from src/Core/AdminConsole/Models/Data/EventIntegrations/TeamsIntegrationConfigurationDetails.cs rename to src/Core/Dirt/Models/Data/EventIntegrations/TeamsIntegrationConfigurationDetails.cs index 66fe558dff..a890f553f5 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/TeamsIntegrationConfigurationDetails.cs +++ b/src/Core/Dirt/Models/Data/EventIntegrations/TeamsIntegrationConfigurationDetails.cs @@ -1,3 +1,3 @@ -namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; +namespace Bit.Core.Dirt.Models.Data.EventIntegrations; public record TeamsIntegrationConfigurationDetails(string ChannelId, Uri ServiceUrl); diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/TeamsListenerConfiguration.cs b/src/Core/Dirt/Models/Data/EventIntegrations/TeamsListenerConfiguration.cs similarity index 91% rename from src/Core/AdminConsole/Models/Data/EventIntegrations/TeamsListenerConfiguration.cs rename to src/Core/Dirt/Models/Data/EventIntegrations/TeamsListenerConfiguration.cs index 24cf674648..4111c96601 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/TeamsListenerConfiguration.cs +++ b/src/Core/Dirt/Models/Data/EventIntegrations/TeamsListenerConfiguration.cs @@ -1,7 +1,7 @@ -using Bit.Core.Enums; +using Bit.Core.Dirt.Enums; using Bit.Core.Settings; -namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; +namespace Bit.Core.Dirt.Models.Data.EventIntegrations; public class TeamsListenerConfiguration(GlobalSettings globalSettings) : ListenerConfiguration(globalSettings), IIntegrationListenerConfiguration diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegration.cs b/src/Core/Dirt/Models/Data/EventIntegrations/WebhookIntegration.cs similarity index 57% rename from src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegration.cs rename to src/Core/Dirt/Models/Data/EventIntegrations/WebhookIntegration.cs index dcda4caa92..d12ea16ee1 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegration.cs +++ b/src/Core/Dirt/Models/Data/EventIntegrations/WebhookIntegration.cs @@ -1,3 +1,3 @@ -namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; +namespace Bit.Core.Dirt.Models.Data.EventIntegrations; public record WebhookIntegration(Uri Uri, string? Scheme = null, string? Token = null); diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegrationConfiguration.cs b/src/Core/Dirt/Models/Data/EventIntegrations/WebhookIntegrationConfiguration.cs similarity index 60% rename from src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegrationConfiguration.cs rename to src/Core/Dirt/Models/Data/EventIntegrations/WebhookIntegrationConfiguration.cs index 851bd3f411..8d7bf90e2c 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegrationConfiguration.cs +++ b/src/Core/Dirt/Models/Data/EventIntegrations/WebhookIntegrationConfiguration.cs @@ -1,3 +1,3 @@ -namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; +namespace Bit.Core.Dirt.Models.Data.EventIntegrations; public record WebhookIntegrationConfiguration(Uri Uri, string? Scheme = null, string? Token = null); diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegrationConfigurationDetails.cs b/src/Core/Dirt/Models/Data/EventIntegrations/WebhookIntegrationConfigurationDetails.cs similarity index 62% rename from src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegrationConfigurationDetails.cs rename to src/Core/Dirt/Models/Data/EventIntegrations/WebhookIntegrationConfigurationDetails.cs index dba9b1714d..49508f8454 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegrationConfigurationDetails.cs +++ b/src/Core/Dirt/Models/Data/EventIntegrations/WebhookIntegrationConfigurationDetails.cs @@ -1,3 +1,3 @@ -namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; +namespace Bit.Core.Dirt.Models.Data.EventIntegrations; public record WebhookIntegrationConfigurationDetails(Uri Uri, string? Scheme = null, string? Token = null); diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookListenerConfiguration.cs b/src/Core/Dirt/Models/Data/EventIntegrations/WebhookListenerConfiguration.cs similarity index 91% rename from src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookListenerConfiguration.cs rename to src/Core/Dirt/Models/Data/EventIntegrations/WebhookListenerConfiguration.cs index 9d5bf811c7..9afc26168c 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookListenerConfiguration.cs +++ b/src/Core/Dirt/Models/Data/EventIntegrations/WebhookListenerConfiguration.cs @@ -1,7 +1,7 @@ -using Bit.Core.Enums; +using Bit.Core.Dirt.Enums; using Bit.Core.Settings; -namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; +namespace Bit.Core.Dirt.Models.Data.EventIntegrations; public class WebhookListenerConfiguration(GlobalSettings globalSettings) : ListenerConfiguration(globalSettings), IIntegrationListenerConfiguration diff --git a/src/Core/AdminConsole/Models/Data/EventMessage.cs b/src/Core/Dirt/Models/Data/EventMessage.cs similarity index 100% rename from src/Core/AdminConsole/Models/Data/EventMessage.cs rename to src/Core/Dirt/Models/Data/EventMessage.cs diff --git a/src/Core/AdminConsole/Models/Data/EventTableEntity.cs b/src/Core/Dirt/Models/Data/EventTableEntity.cs similarity index 100% rename from src/Core/AdminConsole/Models/Data/EventTableEntity.cs rename to src/Core/Dirt/Models/Data/EventTableEntity.cs diff --git a/src/Core/AdminConsole/Models/Data/IEvent.cs b/src/Core/Dirt/Models/Data/IEvent.cs similarity index 100% rename from src/Core/AdminConsole/Models/Data/IEvent.cs rename to src/Core/Dirt/Models/Data/IEvent.cs diff --git a/src/Core/AdminConsole/Models/Slack/SlackApiResponse.cs b/src/Core/Dirt/Models/Data/Slack/SlackApiResponse.cs similarity index 97% rename from src/Core/AdminConsole/Models/Slack/SlackApiResponse.cs rename to src/Core/Dirt/Models/Data/Slack/SlackApiResponse.cs index 3c811e2b28..a70e623ae3 100644 --- a/src/Core/AdminConsole/Models/Slack/SlackApiResponse.cs +++ b/src/Core/Dirt/Models/Data/Slack/SlackApiResponse.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace Bit.Core.Models.Slack; +namespace Bit.Core.Dirt.Models.Data.Slack; public abstract class SlackApiResponse { diff --git a/src/Core/AdminConsole/Models/Teams/TeamsApiResponse.cs b/src/Core/Dirt/Models/Data/Teams/TeamsApiResponse.cs similarity index 97% rename from src/Core/AdminConsole/Models/Teams/TeamsApiResponse.cs rename to src/Core/Dirt/Models/Data/Teams/TeamsApiResponse.cs index 131e45264f..b4b6a2542d 100644 --- a/src/Core/AdminConsole/Models/Teams/TeamsApiResponse.cs +++ b/src/Core/Dirt/Models/Data/Teams/TeamsApiResponse.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace Bit.Core.Models.Teams; +namespace Bit.Core.Dirt.Models.Data.Teams; /// Represents the response returned by the Microsoft OAuth 2.0 token endpoint. /// See Microsoft identity platform and OAuth 2.0 diff --git a/src/Core/AdminConsole/Models/Teams/TeamsBotCredentialProvider.cs b/src/Core/Dirt/Models/Data/Teams/TeamsBotCredentialProvider.cs similarity index 94% rename from src/Core/AdminConsole/Models/Teams/TeamsBotCredentialProvider.cs rename to src/Core/Dirt/Models/Data/Teams/TeamsBotCredentialProvider.cs index eeb17131a3..d8740f9e90 100644 --- a/src/Core/AdminConsole/Models/Teams/TeamsBotCredentialProvider.cs +++ b/src/Core/Dirt/Models/Data/Teams/TeamsBotCredentialProvider.cs @@ -1,6 +1,6 @@ using Microsoft.Bot.Connector.Authentication; -namespace Bit.Core.AdminConsole.Models.Teams; +namespace Bit.Core.Dirt.Models.Data.Teams; public class TeamsBotCredentialProvider(string clientId, string clientSecret) : ICredentialProvider { diff --git a/src/Core/AdminConsole/Repositories/IEventRepository.cs b/src/Core/Dirt/Repositories/IEventRepository.cs similarity index 100% rename from src/Core/AdminConsole/Repositories/IEventRepository.cs rename to src/Core/Dirt/Repositories/IEventRepository.cs diff --git a/src/Core/Dirt/Repositories/IOrganizationIntegrationConfigurationRepository.cs b/src/Core/Dirt/Repositories/IOrganizationIntegrationConfigurationRepository.cs new file mode 100644 index 0000000000..f6f90c7c9f --- /dev/null +++ b/src/Core/Dirt/Repositories/IOrganizationIntegrationConfigurationRepository.cs @@ -0,0 +1,32 @@ +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Enums; +using Bit.Core.Dirt.Models.Data.EventIntegrations; +using Bit.Core.Enums; +using Bit.Core.Repositories; + +namespace Bit.Core.Dirt.Repositories; + +public interface IOrganizationIntegrationConfigurationRepository : IRepository +{ + /// + /// Retrieve the list of available configuration details for a specific event for the organization and + /// integration type.
    + ///
    + /// Note: This returns all configurations that match the event type explicitly and + /// all the configurations that have a null event type - null event type is considered a + /// wildcard that matches all events. + /// + ///
    + /// The specific event type + /// The id of the organization + /// The integration type + /// A List of that match + Task> GetManyByEventTypeOrganizationIdIntegrationType( + EventType eventType, + Guid organizationId, + IntegrationType integrationType); + + Task> GetAllConfigurationDetailsAsync(); + + Task> GetManyByIntegrationAsync(Guid organizationIntegrationId); +} diff --git a/src/Core/AdminConsole/Repositories/IOrganizationIntegrationRepository.cs b/src/Core/Dirt/Repositories/IOrganizationIntegrationRepository.cs similarity index 74% rename from src/Core/AdminConsole/Repositories/IOrganizationIntegrationRepository.cs rename to src/Core/Dirt/Repositories/IOrganizationIntegrationRepository.cs index 1d8b8be0ec..03775e8d20 100644 --- a/src/Core/AdminConsole/Repositories/IOrganizationIntegrationRepository.cs +++ b/src/Core/Dirt/Repositories/IOrganizationIntegrationRepository.cs @@ -1,6 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +using Bit.Core.Dirt.Entities; +using Bit.Core.Repositories; -namespace Bit.Core.Repositories; +namespace Bit.Core.Dirt.Repositories; public interface IOrganizationIntegrationRepository : IRepository { diff --git a/src/Core/AdminConsole/Repositories/TableStorage/EventRepository.cs b/src/Core/Dirt/Repositories/TableStorage/EventRepository.cs similarity index 100% rename from src/Core/AdminConsole/Repositories/TableStorage/EventRepository.cs rename to src/Core/Dirt/Repositories/TableStorage/EventRepository.cs diff --git a/src/Core/AdminConsole/Services/IAzureServiceBusService.cs b/src/Core/Dirt/Services/IAzureServiceBusService.cs similarity index 77% rename from src/Core/AdminConsole/Services/IAzureServiceBusService.cs rename to src/Core/Dirt/Services/IAzureServiceBusService.cs index 75864255c2..6b425511ab 100644 --- a/src/Core/AdminConsole/Services/IAzureServiceBusService.cs +++ b/src/Core/Dirt/Services/IAzureServiceBusService.cs @@ -1,7 +1,7 @@ using Azure.Messaging.ServiceBus; -using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using Bit.Core.Dirt.Models.Data.EventIntegrations; -namespace Bit.Core.Services; +namespace Bit.Core.Dirt.Services; public interface IAzureServiceBusService : IEventIntegrationPublisher, IAsyncDisposable { diff --git a/src/Core/AdminConsole/Services/IEventIntegrationPublisher.cs b/src/Core/Dirt/Services/IEventIntegrationPublisher.cs similarity index 67% rename from src/Core/AdminConsole/Services/IEventIntegrationPublisher.cs rename to src/Core/Dirt/Services/IEventIntegrationPublisher.cs index 4d95707e90..583c2448fe 100644 --- a/src/Core/AdminConsole/Services/IEventIntegrationPublisher.cs +++ b/src/Core/Dirt/Services/IEventIntegrationPublisher.cs @@ -1,6 +1,6 @@ -using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using Bit.Core.Dirt.Models.Data.EventIntegrations; -namespace Bit.Core.Services; +namespace Bit.Core.Dirt.Services; public interface IEventIntegrationPublisher : IAsyncDisposable { diff --git a/src/Core/AdminConsole/Services/IEventMessageHandler.cs b/src/Core/Dirt/Services/IEventMessageHandler.cs similarity index 85% rename from src/Core/AdminConsole/Services/IEventMessageHandler.cs rename to src/Core/Dirt/Services/IEventMessageHandler.cs index 83c5e33ecb..9b1385129b 100644 --- a/src/Core/AdminConsole/Services/IEventMessageHandler.cs +++ b/src/Core/Dirt/Services/IEventMessageHandler.cs @@ -1,6 +1,6 @@ using Bit.Core.Models.Data; -namespace Bit.Core.Services; +namespace Bit.Core.Dirt.Services; public interface IEventMessageHandler { diff --git a/src/Core/AdminConsole/Services/IEventWriteService.cs b/src/Core/Dirt/Services/IEventWriteService.cs similarity index 100% rename from src/Core/AdminConsole/Services/IEventWriteService.cs rename to src/Core/Dirt/Services/IEventWriteService.cs diff --git a/src/Core/AdminConsole/Services/IIntegrationFilterService.cs b/src/Core/Dirt/Services/IIntegrationFilterService.cs similarity index 67% rename from src/Core/AdminConsole/Services/IIntegrationFilterService.cs rename to src/Core/Dirt/Services/IIntegrationFilterService.cs index 5bc035d468..f46ab83f54 100644 --- a/src/Core/AdminConsole/Services/IIntegrationFilterService.cs +++ b/src/Core/Dirt/Services/IIntegrationFilterService.cs @@ -1,9 +1,9 @@ #nullable enable -using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using Bit.Core.Dirt.Models.Data.EventIntegrations; using Bit.Core.Models.Data; -namespace Bit.Core.Services; +namespace Bit.Core.Dirt.Services; public interface IIntegrationFilterService { diff --git a/src/Core/Dirt/Services/IIntegrationHandler.cs b/src/Core/Dirt/Services/IIntegrationHandler.cs new file mode 100644 index 0000000000..81103b453d --- /dev/null +++ b/src/Core/Dirt/Services/IIntegrationHandler.cs @@ -0,0 +1,115 @@ +using System.Globalization; +using System.Net; +using Bit.Core.Dirt.Models.Data.EventIntegrations; + +namespace Bit.Core.Dirt.Services; + +public interface IIntegrationHandler +{ + Task HandleAsync(string json); +} + +public interface IIntegrationHandler : IIntegrationHandler +{ + Task HandleAsync(IntegrationMessage message); +} + +public abstract class IntegrationHandlerBase : IIntegrationHandler +{ + public async Task HandleAsync(string json) + { + var message = IntegrationMessage.FromJson(json); + return await HandleAsync(message ?? throw new ArgumentException("IntegrationMessage was null when created from the provided JSON")); + } + + public abstract Task HandleAsync(IntegrationMessage message); + + protected IntegrationHandlerResult ResultFromHttpResponse( + HttpResponseMessage response, + IntegrationMessage message, + TimeProvider timeProvider) + { + if (response.IsSuccessStatusCode) + { + return IntegrationHandlerResult.Succeed(message); + } + + var category = ClassifyHttpStatusCode(response.StatusCode); + var failureReason = response.ReasonPhrase ?? $"Failure with status code {(int)response.StatusCode}"; + + if (category is not (IntegrationFailureCategory.RateLimited + or IntegrationFailureCategory.TransientError + or IntegrationFailureCategory.ServiceUnavailable) || + !response.Headers.TryGetValues("Retry-After", out var values) + ) + { + return IntegrationHandlerResult.Fail(message: message, category: category, failureReason: failureReason); + } + + // Handle Retry-After header for rate-limited and retryable errors + DateTime? delayUntil = null; + var value = values.FirstOrDefault(); + if (int.TryParse(value, out var seconds)) + { + // Retry-after was specified in seconds + delayUntil = timeProvider.GetUtcNow().AddSeconds(seconds).UtcDateTime; + } + else if (DateTimeOffset.TryParseExact(value, + "r", // "r" is the round-trip format: RFC1123 + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, + out var retryDate)) + { + // Retry-after was specified as a date + delayUntil = retryDate.UtcDateTime; + } + + return IntegrationHandlerResult.Fail( + message, + category, + failureReason, + delayUntil + ); + } + + /// + /// Classifies an as an to drive + /// retry behavior and operator-facing failure reporting. + /// + /// The HTTP status code. + /// The corresponding . + protected static IntegrationFailureCategory ClassifyHttpStatusCode(HttpStatusCode statusCode) + { + var explicitCategory = statusCode switch + { + HttpStatusCode.Unauthorized => IntegrationFailureCategory.AuthenticationFailed, + HttpStatusCode.Forbidden => IntegrationFailureCategory.AuthenticationFailed, + HttpStatusCode.NotFound => IntegrationFailureCategory.ConfigurationError, + HttpStatusCode.Gone => IntegrationFailureCategory.ConfigurationError, + HttpStatusCode.MovedPermanently => IntegrationFailureCategory.ConfigurationError, + HttpStatusCode.TemporaryRedirect => IntegrationFailureCategory.ConfigurationError, + HttpStatusCode.PermanentRedirect => IntegrationFailureCategory.ConfigurationError, + HttpStatusCode.TooManyRequests => IntegrationFailureCategory.RateLimited, + HttpStatusCode.RequestTimeout => IntegrationFailureCategory.TransientError, + HttpStatusCode.InternalServerError => IntegrationFailureCategory.TransientError, + HttpStatusCode.BadGateway => IntegrationFailureCategory.TransientError, + HttpStatusCode.GatewayTimeout => IntegrationFailureCategory.TransientError, + HttpStatusCode.ServiceUnavailable => IntegrationFailureCategory.ServiceUnavailable, + HttpStatusCode.NotImplemented => IntegrationFailureCategory.PermanentFailure, + _ => (IntegrationFailureCategory?)null + }; + + if (explicitCategory is not null) + { + return explicitCategory.Value; + } + + return (int)statusCode switch + { + >= 300 and <= 399 => IntegrationFailureCategory.ConfigurationError, + >= 400 and <= 499 => IntegrationFailureCategory.ConfigurationError, + >= 500 and <= 599 => IntegrationFailureCategory.ServiceUnavailable, + _ => IntegrationFailureCategory.ServiceUnavailable + }; + } +} diff --git a/src/Core/Dirt/Services/IOrganizationIntegrationConfigurationValidator.cs b/src/Core/Dirt/Services/IOrganizationIntegrationConfigurationValidator.cs new file mode 100644 index 0000000000..4a3a089f26 --- /dev/null +++ b/src/Core/Dirt/Services/IOrganizationIntegrationConfigurationValidator.cs @@ -0,0 +1,17 @@ +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Enums; + +namespace Bit.Core.Dirt.Services; + +public interface IOrganizationIntegrationConfigurationValidator +{ + /// + /// Validates that the configuration is valid for the given integration type. The configuration must + /// include a Configuration that is valid for the type, valid Filters, and a non-empty Template + /// to pass validation. + /// + /// The type of integration + /// The OrganizationIntegrationConfiguration to validate + /// True if valid, false otherwise + bool ValidateConfiguration(IntegrationType integrationType, OrganizationIntegrationConfiguration configuration); +} diff --git a/src/Core/AdminConsole/Services/IRabbitMqService.cs b/src/Core/Dirt/Services/IRabbitMqService.cs similarity index 89% rename from src/Core/AdminConsole/Services/IRabbitMqService.cs rename to src/Core/Dirt/Services/IRabbitMqService.cs index 12c40c3b98..b9f824506f 100644 --- a/src/Core/AdminConsole/Services/IRabbitMqService.cs +++ b/src/Core/Dirt/Services/IRabbitMqService.cs @@ -1,8 +1,8 @@ -using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using Bit.Core.Dirt.Models.Data.EventIntegrations; using RabbitMQ.Client; using RabbitMQ.Client.Events; -namespace Bit.Core.Services; +namespace Bit.Core.Dirt.Services; public interface IRabbitMqService : IEventIntegrationPublisher { diff --git a/src/Core/AdminConsole/Services/ISlackService.cs b/src/Core/Dirt/Services/ISlackService.cs similarity index 97% rename from src/Core/AdminConsole/Services/ISlackService.cs rename to src/Core/Dirt/Services/ISlackService.cs index 60d3da8af4..111fcb5440 100644 --- a/src/Core/AdminConsole/Services/ISlackService.cs +++ b/src/Core/Dirt/Services/ISlackService.cs @@ -1,6 +1,7 @@ -using Bit.Core.Models.Slack; +using Bit.Core.Dirt.Models.Data.Slack; +using Bit.Core.Dirt.Services.Implementations; -namespace Bit.Core.Services; +namespace Bit.Core.Dirt.Services; /// Defines operations for interacting with Slack, including OAuth authentication, channel discovery, /// and sending messages. diff --git a/src/Core/AdminConsole/Services/ITeamsService.cs b/src/Core/Dirt/Services/ITeamsService.cs similarity index 95% rename from src/Core/AdminConsole/Services/ITeamsService.cs rename to src/Core/Dirt/Services/ITeamsService.cs index e3757987c3..30a324f9a4 100644 --- a/src/Core/AdminConsole/Services/ITeamsService.cs +++ b/src/Core/Dirt/Services/ITeamsService.cs @@ -1,6 +1,7 @@ -using Bit.Core.Models.Teams; +using Bit.Core.Dirt.Models.Data.Teams; +using Bit.Core.Dirt.Services.Implementations; -namespace Bit.Core.Services; +namespace Bit.Core.Dirt.Services; /// /// Service that provides functionality relating to the Microsoft Teams integration including OAuth, diff --git a/src/Core/AdminConsole/Services/Implementations/AzureQueueEventWriteService.cs b/src/Core/Dirt/Services/Implementations/AzureQueueEventWriteService.cs similarity index 93% rename from src/Core/AdminConsole/Services/Implementations/AzureQueueEventWriteService.cs rename to src/Core/Dirt/Services/Implementations/AzureQueueEventWriteService.cs index f81175f7b5..4f48b64b5a 100644 --- a/src/Core/AdminConsole/Services/Implementations/AzureQueueEventWriteService.cs +++ b/src/Core/Dirt/Services/Implementations/AzureQueueEventWriteService.cs @@ -8,7 +8,7 @@ namespace Bit.Core.Services; public class AzureQueueEventWriteService : AzureQueueService, IEventWriteService { public AzureQueueEventWriteService(GlobalSettings globalSettings) : base( - new QueueClient(globalSettings.Events.ConnectionString, "event"), + new QueueClient(globalSettings.Events.ConnectionString, globalSettings.Events.QueueName), JsonHelpers.IgnoreWritingNull) { } diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/AzureServiceBusEventListenerService.cs b/src/Core/Dirt/Services/Implementations/AzureServiceBusEventListenerService.cs similarity index 89% rename from src/Core/AdminConsole/Services/Implementations/EventIntegrations/AzureServiceBusEventListenerService.cs rename to src/Core/Dirt/Services/Implementations/AzureServiceBusEventListenerService.cs index a589211687..6175374e2f 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/AzureServiceBusEventListenerService.cs +++ b/src/Core/Dirt/Services/Implementations/AzureServiceBusEventListenerService.cs @@ -1,9 +1,9 @@ using System.Text; using Azure.Messaging.ServiceBus; -using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using Bit.Core.Dirt.Models.Data.EventIntegrations; using Microsoft.Extensions.Logging; -namespace Bit.Core.Services; +namespace Bit.Core.Dirt.Services.Implementations; public class AzureServiceBusEventListenerService : EventLoggingListenerService where TConfiguration : IEventListenerConfiguration @@ -42,7 +42,7 @@ public class AzureServiceBusEventListenerService : EventLoggingL private static ILogger CreateLogger(ILoggerFactory loggerFactory, TConfiguration configuration) { return loggerFactory.CreateLogger( - categoryName: $"Bit.Core.Services.AzureServiceBusEventListenerService.{configuration.EventSubscriptionName}"); + categoryName: $"Bit.Core.Dirt.Services.Implementations.AzureServiceBusEventListenerService.{configuration.EventSubscriptionName}"); } internal Task ProcessErrorAsync(ProcessErrorEventArgs args) diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/AzureServiceBusIntegrationListenerService.cs b/src/Core/Dirt/Services/Implementations/AzureServiceBusIntegrationListenerService.cs similarity index 80% rename from src/Core/AdminConsole/Services/Implementations/EventIntegrations/AzureServiceBusIntegrationListenerService.cs rename to src/Core/Dirt/Services/Implementations/AzureServiceBusIntegrationListenerService.cs index 633a53296b..32132ddb37 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/AzureServiceBusIntegrationListenerService.cs +++ b/src/Core/Dirt/Services/Implementations/AzureServiceBusIntegrationListenerService.cs @@ -1,9 +1,9 @@ using Azure.Messaging.ServiceBus; -using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using Bit.Core.Dirt.Models.Data.EventIntegrations; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -namespace Bit.Core.Services; +namespace Bit.Core.Dirt.Services.Implementations; public class AzureServiceBusIntegrationListenerService : BackgroundService where TConfiguration : IIntegrationListenerConfiguration @@ -23,7 +23,7 @@ public class AzureServiceBusIntegrationListenerService : Backgro { _handler = handler; _logger = loggerFactory.CreateLogger( - categoryName: $"Bit.Core.Services.AzureServiceBusIntegrationListenerService.{configuration.IntegrationSubscriptionName}"); + categoryName: $"Bit.Core.Dirt.Services.Implementations.AzureServiceBusIntegrationListenerService.{configuration.IntegrationSubscriptionName}"); _maxRetries = configuration.MaxRetries; _serviceBusService = serviceBusService; @@ -85,6 +85,17 @@ public class AzureServiceBusIntegrationListenerService : Backgro { // Non-recoverable failure or exceeded the max number of retries // Return false to indicate this message should be dead-lettered + _logger.LogWarning( + "Integration failure - non-recoverable error or max retries exceeded. " + + "MessageId: {MessageId}, IntegrationType: {IntegrationType}, OrganizationId: {OrgId}, " + + "FailureCategory: {Category}, Reason: {Reason}, RetryCount: {RetryCount}, MaxRetries: {MaxRetries}", + message.MessageId, + message.IntegrationType, + message.OrganizationId, + result.Category, + result.FailureReason, + message.RetryCount, + _maxRetries); return false; } } diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/AzureServiceBusService.cs b/src/Core/Dirt/Services/Implementations/AzureServiceBusService.cs similarity index 94% rename from src/Core/AdminConsole/Services/Implementations/EventIntegrations/AzureServiceBusService.cs rename to src/Core/Dirt/Services/Implementations/AzureServiceBusService.cs index 953a9bb56e..7b87850fe3 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/AzureServiceBusService.cs +++ b/src/Core/Dirt/Services/Implementations/AzureServiceBusService.cs @@ -1,9 +1,9 @@ using Azure.Messaging.ServiceBus; -using Bit.Core.AdminConsole.Models.Data.EventIntegrations; -using Bit.Core.Enums; +using Bit.Core.Dirt.Enums; +using Bit.Core.Dirt.Models.Data.EventIntegrations; using Bit.Core.Settings; -namespace Bit.Core.Services; +namespace Bit.Core.Dirt.Services.Implementations; public class AzureServiceBusService : IAzureServiceBusService { diff --git a/src/Core/AdminConsole/Services/Implementations/AzureTableStorageEventHandler.cs b/src/Core/Dirt/Services/Implementations/AzureTableStorageEventHandler.cs similarity index 84% rename from src/Core/AdminConsole/Services/Implementations/AzureTableStorageEventHandler.cs rename to src/Core/Dirt/Services/Implementations/AzureTableStorageEventHandler.cs index 578dde9485..73d22b21a7 100644 --- a/src/Core/AdminConsole/Services/Implementations/AzureTableStorageEventHandler.cs +++ b/src/Core/Dirt/Services/Implementations/AzureTableStorageEventHandler.cs @@ -1,9 +1,8 @@ -#nullable enable - -using Bit.Core.Models.Data; +using Bit.Core.Models.Data; +using Bit.Core.Services; using Microsoft.Extensions.DependencyInjection; -namespace Bit.Core.Services; +namespace Bit.Core.Dirt.Services.Implementations; public class AzureTableStorageEventHandler( [FromKeyedServices("persistent")] IEventWriteService eventWriteService) diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/DatadogIntegrationHandler.cs b/src/Core/Dirt/Services/Implementations/DatadogIntegrationHandler.cs similarity index 90% rename from src/Core/AdminConsole/Services/Implementations/EventIntegrations/DatadogIntegrationHandler.cs rename to src/Core/Dirt/Services/Implementations/DatadogIntegrationHandler.cs index 45bb5b6d7d..e5c684ceec 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/DatadogIntegrationHandler.cs +++ b/src/Core/Dirt/Services/Implementations/DatadogIntegrationHandler.cs @@ -1,7 +1,7 @@ using System.Text; -using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using Bit.Core.Dirt.Models.Data.EventIntegrations; -namespace Bit.Core.Services; +namespace Bit.Core.Dirt.Services.Implementations; public class DatadogIntegrationHandler( IHttpClientFactory httpClientFactory, diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationEventWriteService.cs b/src/Core/Dirt/Services/Implementations/EventIntegrationEventWriteService.cs similarity index 94% rename from src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationEventWriteService.cs rename to src/Core/Dirt/Services/Implementations/EventIntegrationEventWriteService.cs index 4ac97df763..44e0513ee0 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationEventWriteService.cs +++ b/src/Core/Dirt/Services/Implementations/EventIntegrationEventWriteService.cs @@ -1,7 +1,8 @@ using System.Text.Json; using Bit.Core.Models.Data; +using Bit.Core.Services; -namespace Bit.Core.Services; +namespace Bit.Core.Dirt.Services.Implementations; public class EventIntegrationEventWriteService : IEventWriteService, IAsyncDisposable { private readonly IEventIntegrationPublisher _eventIntegrationPublisher; diff --git a/src/Core/Dirt/Services/Implementations/EventIntegrationHandler.cs b/src/Core/Dirt/Services/Implementations/EventIntegrationHandler.cs new file mode 100644 index 0000000000..bcd1f1dd8c --- /dev/null +++ b/src/Core/Dirt/Services/Implementations/EventIntegrationHandler.cs @@ -0,0 +1,165 @@ +using System.Text.Json; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.AdminConsole.Utilities; +using Bit.Core.Dirt.Enums; +using Bit.Core.Dirt.Models.Data.EventIntegrations; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Models.Data; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; +using Bit.Core.Repositories; +using Bit.Core.Utilities; +using Microsoft.Extensions.Logging; +using ZiggyCreatures.Caching.Fusion; + +namespace Bit.Core.Dirt.Services.Implementations; + +public class EventIntegrationHandler( + IntegrationType integrationType, + IEventIntegrationPublisher eventIntegrationPublisher, + IIntegrationFilterService integrationFilterService, + IFusionCache cache, + IOrganizationIntegrationConfigurationRepository configurationRepository, + IGroupRepository groupRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + ILogger> logger) + : IEventMessageHandler +{ + public async Task HandleEventAsync(EventMessage eventMessage) + { + foreach (var configuration in await GetConfigurationDetailsListAsync(eventMessage)) + { + try + { + if (configuration.Filters is string filterJson) + { + // Evaluate filters - if false, then discard and do not process + var filters = JsonSerializer.Deserialize(filterJson) + ?? throw new InvalidOperationException($"Failed to deserialize Filters to FilterGroup"); + if (!integrationFilterService.EvaluateFilterGroup(filters, eventMessage)) + { + continue; + } + } + + // Valid filter - assemble message and publish to Integration topic/exchange + var template = configuration.Template ?? string.Empty; + var context = await BuildContextAsync(eventMessage, template); + var renderedTemplate = IntegrationTemplateProcessor.ReplaceTokens(template, context); + var messageId = eventMessage.IdempotencyId ?? Guid.NewGuid(); + var config = configuration.MergedConfiguration.Deserialize() + ?? throw new InvalidOperationException($"Failed to deserialize to {typeof(T).Name} - bad Configuration"); + + var message = new IntegrationMessage + { + IntegrationType = integrationType, + MessageId = messageId.ToString(), + OrganizationId = eventMessage.OrganizationId?.ToString(), + Configuration = config, + RenderedTemplate = renderedTemplate, + RetryCount = 0, + DelayUntilDate = null + }; + + await eventIntegrationPublisher.PublishAsync(message); + } + catch (Exception exception) + { + logger.LogError( + exception, + "Failed to publish Integration Message for {Type}, check Id {RecordId} for error in Configuration or Filters", + typeof(T).Name, + configuration.Id); + } + } + } + + public async Task HandleManyEventsAsync(IEnumerable eventMessages) + { + foreach (var eventMessage in eventMessages) + { + await HandleEventAsync(eventMessage); + } + } + + internal async Task BuildContextAsync(EventMessage eventMessage, string template) + { + // Note: All of these cache calls use the default options, including TTL of 30 minutes + + var context = new IntegrationTemplateContext(eventMessage); + + if (IntegrationTemplateProcessor.TemplateRequiresGroup(template) && eventMessage.GroupId.HasValue) + { + context.Group = await cache.GetOrSetAsync( + key: EventIntegrationsCacheConstants.BuildCacheKeyForGroup(eventMessage.GroupId.Value), + factory: async _ => await groupRepository.GetByIdAsync(eventMessage.GroupId.Value) + ); + } + + if (eventMessage.OrganizationId is not Guid organizationId) + { + return context; + } + + if (IntegrationTemplateProcessor.TemplateRequiresUser(template) && eventMessage.UserId.HasValue) + { + context.User = await GetUserFromCacheAsync(organizationId, eventMessage.UserId.Value); + } + + if (IntegrationTemplateProcessor.TemplateRequiresActingUser(template) && eventMessage.ActingUserId.HasValue) + { + context.ActingUser = await GetUserFromCacheAsync(organizationId, eventMessage.ActingUserId.Value); + } + + if (IntegrationTemplateProcessor.TemplateRequiresOrganization(template)) + { + context.Organization = await cache.GetOrSetAsync( + key: EventIntegrationsCacheConstants.BuildCacheKeyForOrganization(organizationId), + factory: async _ => await organizationRepository.GetByIdAsync(organizationId) + ); + } + + return context; + } + + private async Task> GetConfigurationDetailsListAsync(EventMessage eventMessage) + { + if (eventMessage.OrganizationId is not Guid organizationId) + { + return []; + } + + List configurations = []; + + var integrationTag = EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration( + organizationId, + integrationType + ); + + configurations.AddRange(await cache.GetOrSetAsync>( + key: EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails( + organizationId: organizationId, + integrationType: integrationType, + eventType: eventMessage.Type), + factory: async _ => await configurationRepository.GetManyByEventTypeOrganizationIdIntegrationType( + eventType: eventMessage.Type, + organizationId: organizationId, + integrationType: integrationType), + options: new FusionCacheEntryOptions( + duration: EventIntegrationsCacheConstants.DurationForOrganizationIntegrationConfigurationDetails), + tags: [integrationTag] + )); + + return configurations; + } + + private async Task GetUserFromCacheAsync(Guid organizationId, Guid userId) => + await cache.GetOrSetAsync( + key: EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationUser(organizationId, userId), + factory: async _ => await organizationUserRepository.GetDetailsByOrganizationIdUserIdAsync( + organizationId: organizationId, + userId: userId + ) + ); +} diff --git a/src/Core/AdminConsole/Services/EventLoggingListenerService.cs b/src/Core/Dirt/Services/Implementations/EventLoggingListenerService.cs similarity index 97% rename from src/Core/AdminConsole/Services/EventLoggingListenerService.cs rename to src/Core/Dirt/Services/Implementations/EventLoggingListenerService.cs index 84a862ce94..29e3f8dec3 100644 --- a/src/Core/AdminConsole/Services/EventLoggingListenerService.cs +++ b/src/Core/Dirt/Services/Implementations/EventLoggingListenerService.cs @@ -1,11 +1,9 @@ -#nullable enable - -using System.Text.Json; +using System.Text.Json; using Bit.Core.Models.Data; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -namespace Bit.Core.Services; +namespace Bit.Core.Dirt.Services.Implementations; public abstract class EventLoggingListenerService : BackgroundService { diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventRepositoryHandler.cs b/src/Core/Dirt/Services/Implementations/EventRepositoryHandler.cs similarity index 87% rename from src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventRepositoryHandler.cs rename to src/Core/Dirt/Services/Implementations/EventRepositoryHandler.cs index ee3a2d5db2..32173b8da0 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventRepositoryHandler.cs +++ b/src/Core/Dirt/Services/Implementations/EventRepositoryHandler.cs @@ -1,7 +1,8 @@ using Bit.Core.Models.Data; +using Bit.Core.Services; using Microsoft.Extensions.DependencyInjection; -namespace Bit.Core.Services; +namespace Bit.Core.Dirt.Services.Implementations; public class EventRepositoryHandler( [FromKeyedServices("persistent")] IEventWriteService eventWriteService) diff --git a/src/Core/AdminConsole/Services/Implementations/EventService.cs b/src/Core/Dirt/Services/Implementations/EventService.cs similarity index 100% rename from src/Core/AdminConsole/Services/Implementations/EventService.cs rename to src/Core/Dirt/Services/Implementations/EventService.cs diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/IntegrationFilterFactory.cs b/src/Core/Dirt/Services/Implementations/IntegrationFilterFactory.cs similarity index 97% rename from src/Core/AdminConsole/Services/Implementations/EventIntegrations/IntegrationFilterFactory.cs rename to src/Core/Dirt/Services/Implementations/IntegrationFilterFactory.cs index d28ac910b7..8c25c80208 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/IntegrationFilterFactory.cs +++ b/src/Core/Dirt/Services/Implementations/IntegrationFilterFactory.cs @@ -1,7 +1,7 @@ using System.Linq.Expressions; using Bit.Core.Models.Data; -namespace Bit.Core.Services; +namespace Bit.Core.Dirt.Services.Implementations; public delegate bool IntegrationFilter(EventMessage message, object? value); diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/IntegrationFilterService.cs b/src/Core/Dirt/Services/Implementations/IntegrationFilterService.cs similarity index 97% rename from src/Core/AdminConsole/Services/Implementations/EventIntegrations/IntegrationFilterService.cs rename to src/Core/Dirt/Services/Implementations/IntegrationFilterService.cs index 1c8fae4000..7d56b7c7ce 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/IntegrationFilterService.cs +++ b/src/Core/Dirt/Services/Implementations/IntegrationFilterService.cs @@ -1,8 +1,8 @@ using System.Text.Json; -using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using Bit.Core.Dirt.Models.Data.EventIntegrations; using Bit.Core.Models.Data; -namespace Bit.Core.Services; +namespace Bit.Core.Dirt.Services.Implementations; public class IntegrationFilterService : IIntegrationFilterService { diff --git a/src/Core/Dirt/Services/Implementations/OrganizationIntegrationConfigurationValidator.cs b/src/Core/Dirt/Services/Implementations/OrganizationIntegrationConfigurationValidator.cs new file mode 100644 index 0000000000..7b6ab320b8 --- /dev/null +++ b/src/Core/Dirt/Services/Implementations/OrganizationIntegrationConfigurationValidator.cs @@ -0,0 +1,76 @@ +using System.Text.Json; +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Enums; +using Bit.Core.Dirt.Models.Data.EventIntegrations; + +namespace Bit.Core.Dirt.Services.Implementations; + +public class OrganizationIntegrationConfigurationValidator : IOrganizationIntegrationConfigurationValidator +{ + public bool ValidateConfiguration(IntegrationType integrationType, + OrganizationIntegrationConfiguration configuration) + { + // Validate template is present + if (string.IsNullOrWhiteSpace(configuration.Template)) + { + return false; + } + // If Filters are present, they must be valid + if (!IsFiltersValid(configuration.Filters)) + { + return false; + } + + switch (integrationType) + { + case IntegrationType.CloudBillingSync or IntegrationType.Scim: + return false; + case IntegrationType.Slack: + return IsConfigurationValid(configuration.Configuration); + case IntegrationType.Webhook: + return IsConfigurationValid(configuration.Configuration); + case IntegrationType.Hec: + case IntegrationType.Datadog: + case IntegrationType.Teams: + return configuration.Configuration is null; + default: + return false; + } + } + + private static bool IsConfigurationValid(string? configuration) + { + if (string.IsNullOrWhiteSpace(configuration)) + { + return false; + } + + try + { + var config = JsonSerializer.Deserialize(configuration); + return config is not null; + } + catch + { + return false; + } + } + + private static bool IsFiltersValid(string? filters) + { + if (filters is null) + { + return true; + } + + try + { + var filterGroup = JsonSerializer.Deserialize(filters); + return filterGroup is not null; + } + catch + { + return false; + } + } +} diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqEventListenerService.cs b/src/Core/Dirt/Services/Implementations/RabbitMqEventListenerService.cs similarity index 91% rename from src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqEventListenerService.cs rename to src/Core/Dirt/Services/Implementations/RabbitMqEventListenerService.cs index 430540a2f7..ca7cd5ef16 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqEventListenerService.cs +++ b/src/Core/Dirt/Services/Implementations/RabbitMqEventListenerService.cs @@ -1,10 +1,10 @@ using System.Text; -using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using Bit.Core.Dirt.Models.Data.EventIntegrations; using Microsoft.Extensions.Logging; using RabbitMQ.Client; using RabbitMQ.Client.Events; -namespace Bit.Core.Services; +namespace Bit.Core.Dirt.Services.Implementations; public class RabbitMqEventListenerService : EventLoggingListenerService where TConfiguration : IEventListenerConfiguration @@ -69,6 +69,6 @@ public class RabbitMqEventListenerService : EventLoggingListener private static ILogger CreateLogger(ILoggerFactory loggerFactory, TConfiguration configuration) { return loggerFactory.CreateLogger( - categoryName: $"Bit.Core.Services.RabbitMqEventListenerService.{configuration.EventQueueName}"); + categoryName: $"Bit.Core.Dirt.Services.Implementations.RabbitMqEventListenerService.{configuration.EventQueueName}"); } } diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqIntegrationListenerService.cs b/src/Core/Dirt/Services/Implementations/RabbitMqIntegrationListenerService.cs similarity index 80% rename from src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqIntegrationListenerService.cs rename to src/Core/Dirt/Services/Implementations/RabbitMqIntegrationListenerService.cs index b426032c92..eced9131bb 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqIntegrationListenerService.cs +++ b/src/Core/Dirt/Services/Implementations/RabbitMqIntegrationListenerService.cs @@ -1,12 +1,12 @@ using System.Text; using System.Text.Json; -using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using Bit.Core.Dirt.Models.Data.EventIntegrations; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using RabbitMQ.Client; using RabbitMQ.Client.Events; -namespace Bit.Core.Services; +namespace Bit.Core.Dirt.Services.Implementations; public class RabbitMqIntegrationListenerService : BackgroundService where TConfiguration : IIntegrationListenerConfiguration @@ -37,7 +37,7 @@ public class RabbitMqIntegrationListenerService : BackgroundServ _timeProvider = timeProvider; _lazyChannel = new Lazy>(() => _rabbitMqService.CreateChannelAsync()); _logger = loggerFactory.CreateLogger( - categoryName: $"Bit.Core.Services.RabbitMqIntegrationListenerService.{configuration.IntegrationQueueName}"); ; + categoryName: $"Bit.Core.Dirt.Services.Implementations.RabbitMqIntegrationListenerService.{configuration.IntegrationQueueName}"); ; } public override async Task StartAsync(CancellationToken cancellationToken) @@ -106,14 +106,32 @@ public class RabbitMqIntegrationListenerService : BackgroundServ { // Exceeded the max number of retries; fail and send to dead letter queue await _rabbitMqService.PublishToDeadLetterAsync(channel, message, cancellationToken); - _logger.LogWarning("Max retry attempts reached. Sent to DLQ."); + _logger.LogWarning( + "Integration failure - max retries exceeded. " + + "MessageId: {MessageId}, IntegrationType: {IntegrationType}, OrganizationId: {OrgId}, " + + "FailureCategory: {Category}, Reason: {Reason}, RetryCount: {RetryCount}, MaxRetries: {MaxRetries}", + message.MessageId, + message.IntegrationType, + message.OrganizationId, + result.Category, + result.FailureReason, + message.RetryCount, + _maxRetries); } } else { // Fatal error (i.e. not retryable) occurred. Send message to dead letter queue without any retries await _rabbitMqService.PublishToDeadLetterAsync(channel, message, cancellationToken); - _logger.LogWarning("Non-retryable failure. Sent to DLQ."); + _logger.LogWarning( + "Integration failure - non-retryable. " + + "MessageId: {MessageId}, IntegrationType: {IntegrationType}, OrganizationId: {OrgId}, " + + "FailureCategory: {Category}, Reason: {Reason}", + message.MessageId, + message.IntegrationType, + message.OrganizationId, + result.Category, + result.FailureReason); } // Message has been sent to retry or dead letter queues. diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqService.cs b/src/Core/Dirt/Services/Implementations/RabbitMqService.cs similarity index 98% rename from src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqService.cs rename to src/Core/Dirt/Services/Implementations/RabbitMqService.cs index 8976530cf4..c27fb37d08 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqService.cs +++ b/src/Core/Dirt/Services/Implementations/RabbitMqService.cs @@ -1,11 +1,11 @@ using System.Text; -using Bit.Core.AdminConsole.Models.Data.EventIntegrations; -using Bit.Core.Enums; +using Bit.Core.Dirt.Enums; +using Bit.Core.Dirt.Models.Data.EventIntegrations; using Bit.Core.Settings; using RabbitMQ.Client; using RabbitMQ.Client.Events; -namespace Bit.Core.Services; +namespace Bit.Core.Dirt.Services.Implementations; public class RabbitMqService : IRabbitMqService { diff --git a/src/Core/Services/Implementations/RepositoryEventWriteService.cs b/src/Core/Dirt/Services/Implementations/RepositoryEventWriteService.cs similarity index 100% rename from src/Core/Services/Implementations/RepositoryEventWriteService.cs rename to src/Core/Dirt/Services/Implementations/RepositoryEventWriteService.cs diff --git a/src/Core/Dirt/Services/Implementations/SlackIntegrationHandler.cs b/src/Core/Dirt/Services/Implementations/SlackIntegrationHandler.cs new file mode 100644 index 0000000000..6c6a4dd356 --- /dev/null +++ b/src/Core/Dirt/Services/Implementations/SlackIntegrationHandler.cs @@ -0,0 +1,76 @@ +using Bit.Core.Dirt.Models.Data.EventIntegrations; + +namespace Bit.Core.Dirt.Services.Implementations; + +public class SlackIntegrationHandler( + ISlackService slackService) + : IntegrationHandlerBase +{ + public override async Task HandleAsync(IntegrationMessage message) + { + var slackResponse = await slackService.SendSlackMessageByChannelIdAsync( + message.Configuration.Token, + message.RenderedTemplate, + message.Configuration.ChannelId + ); + + if (slackResponse is null) + { + return IntegrationHandlerResult.Fail( + message, + IntegrationFailureCategory.TransientError, + "Slack response was null" + ); + } + + if (slackResponse.Ok) + { + return IntegrationHandlerResult.Succeed(message); + } + + var category = ClassifySlackError(slackResponse.Error); + return IntegrationHandlerResult.Fail( + message, + category, + slackResponse.Error + ); + } + + /// + /// Classifies a Slack API error code string as an to drive + /// retry behavior and operator-facing failure reporting. + /// + /// + /// + /// Slack responses commonly return an error string when ok is false. This method maps + /// known Slack error codes to failure categories. + /// + /// + /// Any unrecognized error codes default to to avoid + /// incorrectly marking new/unknown Slack failures as non-retryable. + /// + /// + /// The Slack error code string (e.g. invalid_auth, rate_limited). + /// The corresponding . + private static IntegrationFailureCategory ClassifySlackError(string error) + { + return error switch + { + "invalid_auth" => IntegrationFailureCategory.AuthenticationFailed, + "access_denied" => IntegrationFailureCategory.AuthenticationFailed, + "token_expired" => IntegrationFailureCategory.AuthenticationFailed, + "token_revoked" => IntegrationFailureCategory.AuthenticationFailed, + "account_inactive" => IntegrationFailureCategory.AuthenticationFailed, + "not_authed" => IntegrationFailureCategory.AuthenticationFailed, + "channel_not_found" => IntegrationFailureCategory.ConfigurationError, + "is_archived" => IntegrationFailureCategory.ConfigurationError, + "rate_limited" => IntegrationFailureCategory.RateLimited, + "ratelimited" => IntegrationFailureCategory.RateLimited, + "message_limit_exceeded" => IntegrationFailureCategory.RateLimited, + "internal_error" => IntegrationFailureCategory.TransientError, + "service_unavailable" => IntegrationFailureCategory.ServiceUnavailable, + "fatal_error" => IntegrationFailureCategory.ServiceUnavailable, + _ => IntegrationFailureCategory.TransientError + }; + } +} diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackService.cs b/src/Core/Dirt/Services/Implementations/SlackService.cs similarity index 98% rename from src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackService.cs rename to src/Core/Dirt/Services/Implementations/SlackService.cs index 7eec2ec374..7683f718b5 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackService.cs +++ b/src/Core/Dirt/Services/Implementations/SlackService.cs @@ -2,11 +2,11 @@ using System.Net.Http.Json; using System.Text.Json; using System.Web; -using Bit.Core.Models.Slack; +using Bit.Core.Dirt.Models.Data.Slack; using Bit.Core.Settings; using Microsoft.Extensions.Logging; -namespace Bit.Core.Services; +namespace Bit.Core.Dirt.Services.Implementations; public class SlackService( IHttpClientFactory httpClientFactory, diff --git a/src/Core/Dirt/Services/Implementations/TeamsIntegrationHandler.cs b/src/Core/Dirt/Services/Implementations/TeamsIntegrationHandler.cs new file mode 100644 index 0000000000..7aaed6c647 --- /dev/null +++ b/src/Core/Dirt/Services/Implementations/TeamsIntegrationHandler.cs @@ -0,0 +1,66 @@ +using System.Text.Json; +using Bit.Core.Dirt.Models.Data.EventIntegrations; +using Microsoft.Rest; + +namespace Bit.Core.Dirt.Services.Implementations; + +public class TeamsIntegrationHandler( + ITeamsService teamsService) + : IntegrationHandlerBase +{ + public override async Task HandleAsync( + IntegrationMessage message) + { + try + { + await teamsService.SendMessageToChannelAsync( + serviceUri: message.Configuration.ServiceUrl, + message: message.RenderedTemplate, + channelId: message.Configuration.ChannelId + ); + + return IntegrationHandlerResult.Succeed(message); + } + catch (HttpOperationException ex) + { + var category = ClassifyHttpStatusCode(ex.Response.StatusCode); + return IntegrationHandlerResult.Fail( + message, + category, + ex.Message + ); + } + catch (ArgumentException ex) + { + return IntegrationHandlerResult.Fail( + message, + IntegrationFailureCategory.ConfigurationError, + ex.Message + ); + } + catch (UriFormatException ex) + { + return IntegrationHandlerResult.Fail( + message, + IntegrationFailureCategory.ConfigurationError, + ex.Message + ); + } + catch (JsonException ex) + { + return IntegrationHandlerResult.Fail( + message, + IntegrationFailureCategory.PermanentFailure, + ex.Message + ); + } + catch (Exception ex) + { + return IntegrationHandlerResult.Fail( + message, + IntegrationFailureCategory.TransientError, + ex.Message + ); + } + } +} diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/TeamsService.cs b/src/Core/Dirt/Services/Implementations/TeamsService.cs similarity index 96% rename from src/Core/AdminConsole/Services/Implementations/EventIntegrations/TeamsService.cs rename to src/Core/Dirt/Services/Implementations/TeamsService.cs index f9911760bb..edb43bf85e 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/TeamsService.cs +++ b/src/Core/Dirt/Services/Implementations/TeamsService.cs @@ -2,9 +2,9 @@ using System.Net.Http.Json; using System.Text.Json; using System.Web; -using Bit.Core.AdminConsole.Models.Data.EventIntegrations; -using Bit.Core.Models.Teams; -using Bit.Core.Repositories; +using Bit.Core.Dirt.Models.Data.EventIntegrations; +using Bit.Core.Dirt.Models.Data.Teams; +using Bit.Core.Dirt.Repositories; using Bit.Core.Settings; using Microsoft.Bot.Builder; using Microsoft.Bot.Builder.Teams; @@ -12,9 +12,9 @@ using Microsoft.Bot.Connector; using Microsoft.Bot.Connector.Authentication; using Microsoft.Bot.Schema; using Microsoft.Extensions.Logging; -using TeamInfo = Bit.Core.Models.Teams.TeamInfo; +using TeamInfo = Bit.Core.Dirt.Models.Data.Teams.TeamInfo; -namespace Bit.Core.Services; +namespace Bit.Core.Dirt.Services.Implementations; public class TeamsService( IHttpClientFactory httpClientFactory, diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/WebhookIntegrationHandler.cs b/src/Core/Dirt/Services/Implementations/WebhookIntegrationHandler.cs similarity index 92% rename from src/Core/AdminConsole/Services/Implementations/EventIntegrations/WebhookIntegrationHandler.cs rename to src/Core/Dirt/Services/Implementations/WebhookIntegrationHandler.cs index 0599f6e9d4..6caa1b9a6e 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/WebhookIntegrationHandler.cs +++ b/src/Core/Dirt/Services/Implementations/WebhookIntegrationHandler.cs @@ -1,8 +1,8 @@ using System.Net.Http.Headers; using System.Text; -using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using Bit.Core.Dirt.Models.Data.EventIntegrations; -namespace Bit.Core.Services; +namespace Bit.Core.Dirt.Services.Implementations; public class WebhookIntegrationHandler( IHttpClientFactory httpClientFactory, diff --git a/src/Core/AdminConsole/Services/NoopImplementations/NoopEventService.cs b/src/Core/Dirt/Services/NoopImplementations/NoopEventService.cs similarity index 100% rename from src/Core/AdminConsole/Services/NoopImplementations/NoopEventService.cs rename to src/Core/Dirt/Services/NoopImplementations/NoopEventService.cs diff --git a/src/Core/AdminConsole/Services/NoopImplementations/NoopEventWriteService.cs b/src/Core/Dirt/Services/NoopImplementations/NoopEventWriteService.cs similarity index 100% rename from src/Core/AdminConsole/Services/NoopImplementations/NoopEventWriteService.cs rename to src/Core/Dirt/Services/NoopImplementations/NoopEventWriteService.cs diff --git a/src/Core/AdminConsole/Services/NoopImplementations/NoopSlackService.cs b/src/Core/Dirt/Services/NoopImplementations/NoopSlackService.cs similarity index 88% rename from src/Core/AdminConsole/Services/NoopImplementations/NoopSlackService.cs rename to src/Core/Dirt/Services/NoopImplementations/NoopSlackService.cs index a54df94814..30b68186bc 100644 --- a/src/Core/AdminConsole/Services/NoopImplementations/NoopSlackService.cs +++ b/src/Core/Dirt/Services/NoopImplementations/NoopSlackService.cs @@ -1,7 +1,6 @@ -using Bit.Core.Models.Slack; -using Bit.Core.Services; +using Bit.Core.Dirt.Models.Data.Slack; -namespace Bit.Core.AdminConsole.Services.NoopImplementations; +namespace Bit.Core.Dirt.Services.NoopImplementations; public class NoopSlackService : ISlackService { diff --git a/src/Core/AdminConsole/Services/NoopImplementations/NoopTeamsService.cs b/src/Core/Dirt/Services/NoopImplementations/NoopTeamsService.cs similarity index 83% rename from src/Core/AdminConsole/Services/NoopImplementations/NoopTeamsService.cs rename to src/Core/Dirt/Services/NoopImplementations/NoopTeamsService.cs index fafb23f570..3ebd58d996 100644 --- a/src/Core/AdminConsole/Services/NoopImplementations/NoopTeamsService.cs +++ b/src/Core/Dirt/Services/NoopImplementations/NoopTeamsService.cs @@ -1,7 +1,6 @@ -using Bit.Core.Models.Teams; -using Bit.Core.Services; +using Bit.Core.Dirt.Models.Data.Teams; -namespace Bit.Core.AdminConsole.Services.NoopImplementations; +namespace Bit.Core.Dirt.Services.NoopImplementations; public class NoopTeamsService : ITeamsService { diff --git a/src/Core/Entities/PlayItem.cs b/src/Core/Entities/PlayItem.cs new file mode 100644 index 0000000000..cf2f5c946b --- /dev/null +++ b/src/Core/Entities/PlayItem.cs @@ -0,0 +1,60 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Utilities; + +namespace Bit.Core.Entities; + +/// +/// PlayItem is a join table tracking entities created during automated testing. +/// A `PlayId` is supplied by the clients in the `x-play-id` header to inform the server +/// that any data created should be associated with the play, and therefore cleaned up with it. +/// +public class PlayItem : ITableObject +{ + public Guid Id { get; set; } + [MaxLength(256)] + public required string PlayId { get; init; } + public Guid? UserId { get; init; } + public Guid? OrganizationId { get; init; } + public DateTime CreationDate { get; init; } + + /// + /// Generates and sets a new COMB GUID for the Id property. + /// + public void SetNewId() + { + Id = CoreHelpers.GenerateComb(); + } + + /// + /// Creates a new PlayItem record associated with a User. + /// + /// The user entity created during the play. + /// The play identifier from the x-play-id header. + /// A new PlayItem instance tracking the user. + public static PlayItem Create(User user, string playId) + { + return new PlayItem + { + PlayId = playId, + UserId = user.Id, + CreationDate = DateTime.UtcNow + }; + } + + /// + /// Creates a new PlayItem record associated with an Organization. + /// + /// The organization entity created during the play. + /// The play identifier from the x-play-id header. + /// A new PlayItem instance tracking the organization. + public static PlayItem Create(Organization organization, string playId) + { + return new PlayItem + { + PlayId = playId, + OrganizationId = organization.Id, + CreationDate = DateTime.UtcNow + }; + } +} diff --git a/src/Core/Entities/User.cs b/src/Core/Entities/User.cs index fec9b80d8e..669e32bcbe 100644 --- a/src/Core/Entities/User.cs +++ b/src/Core/Entities/User.cs @@ -69,6 +69,11 @@ public class User : ITableObject, IStorableSubscriber, IRevisable, ITwoFac /// The security state is a signed object attesting to the version of the user's account. /// public string? SecurityState { get; set; } + /// + /// 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. + /// public bool Premium { get; set; } public DateTime? PremiumExpirationDate { get; set; } public DateTime? RenewalReminderDate { get; set; } @@ -200,11 +205,6 @@ public class User : ITableObject, IStorableSubscriber, IRevisable, ITwoFac return Id; } - public bool GetPremium() - { - return Premium; - } - public int GetSecurityVersion() { // If no security version is set, it is version 1. The minimum initialized version is 2. diff --git a/src/Core/KeyManagement/Authorization/KeyConnectorAuthorizationHandler.cs b/src/Core/KeyManagement/Authorization/KeyConnectorAuthorizationHandler.cs new file mode 100644 index 0000000000..7937390a8c --- /dev/null +++ b/src/Core/KeyManagement/Authorization/KeyConnectorAuthorizationHandler.cs @@ -0,0 +1,52 @@ +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Microsoft.AspNetCore.Authorization; + +namespace Bit.Core.KeyManagement.Authorization; + +public class KeyConnectorAuthorizationHandler : AuthorizationHandler +{ + private readonly ICurrentContext _currentContext; + + public KeyConnectorAuthorizationHandler(ICurrentContext currentContext) + { + _currentContext = currentContext; + } + + protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, + KeyConnectorOperationsRequirement requirement, + User user) + { + var authorized = requirement switch + { + not null when requirement == KeyConnectorOperations.Use => CanUse(user), + _ => throw new ArgumentException("Unsupported operation requirement type provided.", nameof(requirement)) + }; + + if (authorized) + { + context.Succeed(requirement); + } + + return Task.CompletedTask; + } + + private bool CanUse(User user) + { + // User cannot use Key Connector if they already use it + if (user.UsesKeyConnector) + { + return false; + } + + // User cannot use Key Connector if they are an owner or admin of any organization + if (_currentContext.Organizations.Any(u => + u.Type is OrganizationUserType.Owner or OrganizationUserType.Admin)) + { + return false; + } + + return true; + } +} diff --git a/src/Core/KeyManagement/Authorization/KeyConnectorOperations.cs b/src/Core/KeyManagement/Authorization/KeyConnectorOperations.cs new file mode 100644 index 0000000000..a8d09a6ac7 --- /dev/null +++ b/src/Core/KeyManagement/Authorization/KeyConnectorOperations.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Authorization.Infrastructure; + +namespace Bit.Core.KeyManagement.Authorization; + +public class KeyConnectorOperationsRequirement : OperationAuthorizationRequirement +{ + public KeyConnectorOperationsRequirement(string name) + { + Name = name; + } +} + +public static class KeyConnectorOperations +{ + public static readonly KeyConnectorOperationsRequirement Use = new(nameof(Use)); +} diff --git a/src/Core/KeyManagement/Commands/Interfaces/ISetKeyConnectorKeyCommand.cs b/src/Core/KeyManagement/Commands/Interfaces/ISetKeyConnectorKeyCommand.cs new file mode 100644 index 0000000000..65f6cddeb5 --- /dev/null +++ b/src/Core/KeyManagement/Commands/Interfaces/ISetKeyConnectorKeyCommand.cs @@ -0,0 +1,13 @@ +using Bit.Core.Entities; +using Bit.Core.KeyManagement.Models.Data; + +namespace Bit.Core.KeyManagement.Commands.Interfaces; + +/// +/// Creates the user key and account cryptographic state for a new user registering +/// with Key Connector SSO configuration. +/// +public interface ISetKeyConnectorKeyCommand +{ + Task SetKeyConnectorKeyForUserAsync(User user, KeyConnectorKeysData keyConnectorKeysData); +} diff --git a/src/Core/KeyManagement/Commands/SetKeyConnectorKeyCommand.cs b/src/Core/KeyManagement/Commands/SetKeyConnectorKeyCommand.cs new file mode 100644 index 0000000000..a96042de30 --- /dev/null +++ b/src/Core/KeyManagement/Commands/SetKeyConnectorKeyCommand.cs @@ -0,0 +1,60 @@ +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.KeyManagement.Authorization; +using Bit.Core.KeyManagement.Commands.Interfaces; +using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Microsoft.AspNetCore.Authorization; + +namespace Bit.Core.KeyManagement.Commands; + +public class SetKeyConnectorKeyCommand : ISetKeyConnectorKeyCommand +{ + private readonly IAuthorizationService _authorizationService; + private readonly ICurrentContext _currentContext; + private readonly IEventService _eventService; + private readonly IAcceptOrgUserCommand _acceptOrgUserCommand; + private readonly IUserService _userService; + private readonly IUserRepository _userRepository; + + public SetKeyConnectorKeyCommand( + IAuthorizationService authorizationService, + ICurrentContext currentContext, + IEventService eventService, + IAcceptOrgUserCommand acceptOrgUserCommand, + IUserService userService, + IUserRepository userRepository) + { + _authorizationService = authorizationService; + _currentContext = currentContext; + _eventService = eventService; + _acceptOrgUserCommand = acceptOrgUserCommand; + _userService = userService; + _userRepository = userRepository; + } + + public async Task SetKeyConnectorKeyForUserAsync(User user, KeyConnectorKeysData keyConnectorKeysData) + { + var authorizationResult = await _authorizationService.AuthorizeAsync(_currentContext.HttpContext.User, user, + KeyConnectorOperations.Use); + if (!authorizationResult.Succeeded) + { + throw new BadRequestException("Cannot use Key Connector"); + } + + var setKeyConnectorUserKeyTask = + _userRepository.SetKeyConnectorUserKey(user.Id, keyConnectorKeysData.KeyConnectorKeyWrappedUserKey); + + await _userRepository.SetV2AccountCryptographicStateAsync(user.Id, + keyConnectorKeysData.AccountKeys.ToAccountKeysData(), [setKeyConnectorUserKeyTask]); + + await _eventService.LogUserEventAsync(user.Id, EventType.User_MigratedKeyToKeyConnector); + + await _acceptOrgUserCommand.AcceptOrgUserByOrgSsoIdAsync(keyConnectorKeysData.OrgIdentifier, user, + _userService); + } +} diff --git a/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs b/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs index 0e551c5d0e..96f990c299 100644 --- a/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs +++ b/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs @@ -1,9 +1,11 @@ -using Bit.Core.KeyManagement.Commands; +using Bit.Core.KeyManagement.Authorization; +using Bit.Core.KeyManagement.Commands; using Bit.Core.KeyManagement.Commands.Interfaces; using Bit.Core.KeyManagement.Kdf; using Bit.Core.KeyManagement.Kdf.Implementations; using Bit.Core.KeyManagement.Queries; using Bit.Core.KeyManagement.Queries.Interfaces; +using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.DependencyInjection; namespace Bit.Core.KeyManagement; @@ -12,19 +14,27 @@ public static class KeyManagementServiceCollectionExtensions { public static void AddKeyManagementServices(this IServiceCollection services) { + services.AddKeyManagementAuthorizationHandlers(); services.AddKeyManagementCommands(); services.AddKeyManagementQueries(); services.AddSendPasswordServices(); } + private static void AddKeyManagementAuthorizationHandlers(this IServiceCollection services) + { + services.AddScoped(); + } + private static void AddKeyManagementCommands(this IServiceCollection services) { services.AddScoped(); services.AddScoped(); + services.AddScoped(); } private static void AddKeyManagementQueries(this IServiceCollection services) { services.AddScoped(); + services.AddScoped(); } } diff --git a/src/Api/KeyManagement/Models/Requests/AccountKeysRequestModel.cs b/src/Core/KeyManagement/Models/Api/Request/AccountKeysRequestModel.cs similarity index 92% rename from src/Api/KeyManagement/Models/Requests/AccountKeysRequestModel.cs rename to src/Core/KeyManagement/Models/Api/Request/AccountKeysRequestModel.cs index b64e826911..bdf538e6d8 100644 --- a/src/Api/KeyManagement/Models/Requests/AccountKeysRequestModel.cs +++ b/src/Core/KeyManagement/Models/Api/Request/AccountKeysRequestModel.cs @@ -1,8 +1,7 @@ -using Bit.Core.KeyManagement.Models.Api.Request; -using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.KeyManagement.Models.Data; using Bit.Core.Utilities; -namespace Bit.Api.KeyManagement.Models.Requests; +namespace Bit.Core.KeyManagement.Models.Api.Request; public class AccountKeysRequestModel { diff --git a/src/Api/KeyManagement/Models/Requests/PublicKeyEncryptionKeyPairRequestModel.cs b/src/Core/KeyManagement/Models/Api/Request/PublicKeyEncryptionKeyPairRequestModel.cs similarity index 91% rename from src/Api/KeyManagement/Models/Requests/PublicKeyEncryptionKeyPairRequestModel.cs rename to src/Core/KeyManagement/Models/Api/Request/PublicKeyEncryptionKeyPairRequestModel.cs index 24c1e6a946..f9b009f7e2 100644 --- a/src/Api/KeyManagement/Models/Requests/PublicKeyEncryptionKeyPairRequestModel.cs +++ b/src/Core/KeyManagement/Models/Api/Request/PublicKeyEncryptionKeyPairRequestModel.cs @@ -1,7 +1,7 @@ using Bit.Core.KeyManagement.Models.Data; using Bit.Core.Utilities; -namespace Bit.Api.KeyManagement.Models.Requests; +namespace Bit.Core.KeyManagement.Models.Api.Request; public class PublicKeyEncryptionKeyPairRequestModel { diff --git a/src/Api/KeyManagement/Models/Requests/SignatureKeyPairRequestModel.cs b/src/Core/KeyManagement/Models/Api/Request/SignatureKeyPairRequestModel.cs similarity index 93% rename from src/Api/KeyManagement/Models/Requests/SignatureKeyPairRequestModel.cs rename to src/Core/KeyManagement/Models/Api/Request/SignatureKeyPairRequestModel.cs index 3cdb4f53f1..a569bc70ab 100644 --- a/src/Api/KeyManagement/Models/Requests/SignatureKeyPairRequestModel.cs +++ b/src/Core/KeyManagement/Models/Api/Request/SignatureKeyPairRequestModel.cs @@ -1,7 +1,7 @@ using Bit.Core.KeyManagement.Models.Data; using Bit.Core.Utilities; -namespace Bit.Api.KeyManagement.Models.Requests; +namespace Bit.Core.KeyManagement.Models.Api.Request; public class SignatureKeyPairRequestModel { diff --git a/src/Core/KeyManagement/Models/Data/KeyConnectorConfirmationDetails.cs b/src/Core/KeyManagement/Models/Data/KeyConnectorConfirmationDetails.cs new file mode 100644 index 0000000000..3821831bad --- /dev/null +++ b/src/Core/KeyManagement/Models/Data/KeyConnectorConfirmationDetails.cs @@ -0,0 +1,6 @@ +namespace Bit.Core.KeyManagement.Models.Data; + +public class KeyConnectorConfirmationDetails +{ + public required string OrganizationName { get; set; } +} diff --git a/src/Core/KeyManagement/Models/Data/KeyConnectorKeysData.cs b/src/Core/KeyManagement/Models/Data/KeyConnectorKeysData.cs new file mode 100644 index 0000000000..5675c6bc96 --- /dev/null +++ b/src/Core/KeyManagement/Models/Data/KeyConnectorKeysData.cs @@ -0,0 +1,12 @@ +using Bit.Core.KeyManagement.Models.Api.Request; + +namespace Bit.Core.KeyManagement.Models.Data; + +public class KeyConnectorKeysData +{ + public required string KeyConnectorKeyWrappedUserKey { get; set; } + + public required AccountKeysRequestModel AccountKeys { get; set; } + + public required string OrgIdentifier { get; init; } +} diff --git a/src/Core/KeyManagement/Models/Data/UserAccountKeysData.cs b/src/Core/KeyManagement/Models/Data/UserAccountKeysData.cs index cabdca59ea..3d552a10de 100644 --- a/src/Core/KeyManagement/Models/Data/UserAccountKeysData.cs +++ b/src/Core/KeyManagement/Models/Data/UserAccountKeysData.cs @@ -1,9 +1,34 @@ namespace Bit.Core.KeyManagement.Models.Data; - +/// +/// Represents an expanded account cryptographic state for a user. Expanded here means +/// that it does not only contain the (wrapped) private / signing key, but also the public +/// key / verifying key. The client side only needs a subset of this data to unlock +/// their vault and the public parts can be derived. +/// public class UserAccountKeysData { public required PublicKeyEncryptionKeyPairData PublicKeyEncryptionKeyPairData { get; set; } public SignatureKeyPairData? SignatureKeyPairData { get; set; } public SecurityStateData? SecurityStateData { get; set; } + + /// + /// Checks whether the account cryptographic state is for a V1 encryption user or a V2 encryption user. + /// Throws if the state is invalid + /// + public bool IsV2Encryption() + { + if (PublicKeyEncryptionKeyPairData.SignedPublicKey != null && SignatureKeyPairData != null && SecurityStateData != null) + { + return true; + } + else if (PublicKeyEncryptionKeyPairData.SignedPublicKey == null && SignatureKeyPairData == null && SecurityStateData == null) + { + return false; + } + else + { + throw new InvalidOperationException("Invalid account cryptographic state: V2 encryption fields must be either all present or all absent."); + } + } } diff --git a/src/Core/KeyManagement/Queries/Interfaces/IKeyConnectorConfirmationDetailsQuery.cs b/src/Core/KeyManagement/Queries/Interfaces/IKeyConnectorConfirmationDetailsQuery.cs new file mode 100644 index 0000000000..60b78c03f4 --- /dev/null +++ b/src/Core/KeyManagement/Queries/Interfaces/IKeyConnectorConfirmationDetailsQuery.cs @@ -0,0 +1,8 @@ +using Bit.Core.KeyManagement.Models.Data; + +namespace Bit.Core.KeyManagement.Queries.Interfaces; + +public interface IKeyConnectorConfirmationDetailsQuery +{ + public Task Run(string orgSsoIdentifier, Guid userId); +} diff --git a/src/Core/KeyManagement/Queries/KeyConnectorConfirmationDetailsQuery.cs b/src/Core/KeyManagement/Queries/KeyConnectorConfirmationDetailsQuery.cs new file mode 100644 index 0000000000..0c210e2fd1 --- /dev/null +++ b/src/Core/KeyManagement/Queries/KeyConnectorConfirmationDetailsQuery.cs @@ -0,0 +1,35 @@ +using Bit.Core.Exceptions; +using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.KeyManagement.Queries.Interfaces; +using Bit.Core.Repositories; + +namespace Bit.Core.KeyManagement.Queries; + +public class KeyConnectorConfirmationDetailsQuery : IKeyConnectorConfirmationDetailsQuery +{ + private readonly IOrganizationRepository _organizationRepository; + private readonly IOrganizationUserRepository _organizationUserRepository; + + public KeyConnectorConfirmationDetailsQuery(IOrganizationRepository organizationRepository, IOrganizationUserRepository organizationUserRepository) + { + _organizationRepository = organizationRepository; + _organizationUserRepository = organizationUserRepository; + } + + public async Task Run(string orgSsoIdentifier, Guid userId) + { + var org = await _organizationRepository.GetByIdentifierAsync(orgSsoIdentifier); + if (org is not { UseKeyConnector: true }) + { + throw new NotFoundException(); + } + + var orgUser = await _organizationUserRepository.GetByOrganizationAsync(org.Id, userId); + if (orgUser == null) + { + throw new NotFoundException(); + } + + return new KeyConnectorConfirmationDetails { OrganizationName = org.Name, }; + } +} diff --git a/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmailv2.html.hbs b/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmailv2.html.hbs index f9cc04f73e..7d30fdcbe4 100644 --- a/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmailv2.html.hbs +++ b/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmailv2.html.hbs @@ -378,12 +378,12 @@ - + -
    +
    - +
    @@ -471,8 +471,8 @@ - -
    - + +
    @@ -488,13 +488,13 @@
    - +
    + - @@ -511,13 +511,13 @@ -
    + - +
    - +
    + - @@ -534,13 +534,13 @@ -
    + - +
    - +
    + - @@ -557,13 +557,13 @@ -
    + - +
    - +
    + - @@ -580,13 +580,13 @@ -
    + - +
    - +
    + - @@ -603,13 +603,13 @@ -
    + - +
    - +
    + - @@ -626,13 +626,13 @@ -
    + - +
    - +
    + - @@ -653,15 +653,15 @@ diff --git a/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-family-user.html.hbs b/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-family-user.html.hbs index 3cbc9446c8..b2b957f849 100644 --- a/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-family-user.html.hbs +++ b/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-family-user.html.hbs @@ -30,6 +30,14 @@ + + + + + + - - + @@ -156,7 +169,7 @@

    - Let's get set up to autofill. + Let’s get you set up to autofill.

    @@ -176,7 +189,7 @@ -
    + - +
    -

    +

    © 2025 Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa Barbara, CA, USA

    Always confirm you are on a trusted Bitwarden domain before logging in:
    - bitwarden.com | - Learn why we include this + bitwarden.com | + Learn why we include this

    + @@ -256,7 +269,7 @@ @@ -342,15 +355,7 @@ - - - - @@ -424,12 +429,12 @@ @@ -437,15 +442,7 @@ - - - - @@ -519,12 +516,12 @@ @@ -532,15 +529,7 @@ - - - - @@ -602,12 +591,12 @@
    -
    A {{OrganizationName}} administrator will approve you +
    An administrator from {{OrganizationName}} will approve you before you can share passwords. While you wait for approval, get started with Bitwarden Password Manager:
    @@ -329,12 +342,12 @@
    - +
    -
    With the Bitwarden extension, you can fill passwords with one click.
    - -
    - -
    +
    With the Bitwarden extension, you can fill passwords with one click.
    - +
    -
    Quickly transfer existing passwords to Bitwarden using the importer.
    - -
    - -
    +
    Quickly transfer existing passwords to Bitwarden using the importer.
    - +
    -
    Take your passwords with you anywhere.
    - -
    - -
    +
    Take your passwords with you anywhere.
    - + -
    +
    - +
    @@ -643,7 +632,7 @@ -
    @@ -622,10 +611,10 @@

    - Learn more about Bitwarden -

    - Find user guides, product documentation, and videos on the - Bitwarden Help Center.
    + Learn more about Bitwarden +

    + Find user guides, product documentation, and videos on the + Bitwarden Help Center.
    +