diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 9f3048a340..5399bed391 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -90,6 +90,9 @@ src/Admin/Views/Tools @bitwarden/team-billing-dev .github/workflows/test-database.yml @bitwarden/team-platform-dev .github/workflows/test.yml @bitwarden/team-platform-dev **/*Platform* @bitwarden/team-platform-dev +**/.dockerignore @bitwarden/team-platform-dev +**/Dockerfile @bitwarden/team-platform-dev +**/entrypoint.sh @bitwarden/team-platform-dev # Multiple owners - DO NOT REMOVE (BRE) **/packages.lock.json diff --git a/.github/renovate.json5 b/.github/renovate.json5 index ac34903c1b..5c1b259539 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -15,8 +15,7 @@ matchManagers: ["github-actions"], matchFileNames: [ ".github/workflows/publish.yml", - ".github/workflows/release.yml", - ".github/workflows/repository-management.yml" + ".github/workflows/release.yml" ], commitMessagePrefix: "[deps] BRE:", reviewers: ["team:dept-bre"], @@ -134,8 +133,8 @@ reviewers: ["team:dept-dbops"], }, { - matchPackageNames: ["CommandDotNet", "YamlDotNet"], - description: "DevOps owned dependencies", + matchPackageNames: ["YamlDotNet"], + description: "BRE owned dependencies", commitMessagePrefix: "[deps] BRE:", reviewers: ["team:dept-bre"], }, diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5077f1ba32..e6ab8d44d6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,7 +19,7 @@ env: jobs: lint: name: Lint - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 steps: - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -33,115 +33,15 @@ jobs: run: dotnet format --verify-no-changes build-artifacts: - name: Build artifacts - runs-on: ubuntu-22.04 + name: Build Docker images + runs-on: ubuntu-24.04 needs: - lint outputs: has_secrets: ${{ steps.check-secrets.outputs.has_secrets }} - strategy: - fail-fast: false - matrix: - include: - - project_name: Admin - base_path: ./src - node: true - - project_name: Api - base_path: ./src - - project_name: Billing - base_path: ./src - - project_name: Events - base_path: ./src - - project_name: EventsProcessor - base_path: ./src - - project_name: Icons - base_path: ./src - - project_name: Identity - base_path: ./src - - project_name: MsSqlMigratorUtility - base_path: ./util - dotnet: true - - project_name: Notifications - base_path: ./src - - project_name: Scim - base_path: ./bitwarden_license/src - dotnet: true - - project_name: Server - base_path: ./util - - project_name: Setup - base_path: ./util - - project_name: Sso - base_path: ./bitwarden_license/src - node: true - steps: - - name: Check secrets - id: check-secrets - env: - AZURE_KV_CI_SERVICE_PRINCIPAL: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} - run: | - has_secrets=${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL != '' }} - echo "has_secrets=$has_secrets" >> $GITHUB_OUTPUT - - - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - ref: ${{ github.event.pull_request.head.sha }} - - - name: Set up .NET - uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0 - - - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 - with: - cache: "npm" - cache-dependency-path: "**/package-lock.json" - node-version: "16" - - - name: Print environment - run: | - whoami - dotnet --info - node --version - npm --version - echo "GitHub ref: $GITHUB_REF" - echo "GitHub event: $GITHUB_EVENT" - - - name: Build node - if: ${{ matrix.node }} - working-directory: ${{ matrix.base_path }}/${{ matrix.project_name }} - run: | - npm ci - npm run build - - - name: Publish project - working-directory: ${{ matrix.base_path }}/${{ matrix.project_name }} - run: | - echo "Publish" - dotnet publish -c "Release" -o obj/build-output/publish - - cd obj/build-output/publish - zip -r ${{ matrix.project_name }}.zip . - mv ${{ matrix.project_name }}.zip ../../../ - - pwd - ls -atlh ../../../ - - - name: Upload project artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: ${{ matrix.project_name }}.zip - path: ${{ matrix.base_path }}/${{ matrix.project_name }}/${{ matrix.project_name }}.zip - if-no-files-found: error - - build-docker: - name: Build Docker images - runs-on: ubuntu-22.04 permissions: security-events: write id-token: write - needs: - - build-artifacts - if: ${{ needs.build-artifacts.outputs.has_secrets == 'true' }} strategy: fail-fast: false matrix: @@ -149,6 +49,7 @@ jobs: - project_name: Admin base_path: ./src dotnet: true + node: true - project_name: Api base_path: ./src dotnet: true @@ -182,9 +83,6 @@ jobs: - project_name: Scim base_path: ./bitwarden_license/src dotnet: true - - project_name: Server - base_path: ./util - dotnet: true - project_name: Setup base_path: ./util dotnet: true @@ -192,6 +90,14 @@ jobs: base_path: ./bitwarden_license/src dotnet: true steps: + - name: Check secrets + id: check-secrets + env: + AZURE_KV_CI_SERVICE_PRINCIPAL: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} + run: | + has_secrets=${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL != '' }} + echo "has_secrets=$has_secrets" >> $GITHUB_OUTPUT + - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: @@ -203,13 +109,67 @@ jobs: id: publish-branch-check run: | IFS="," read -a publish_branches <<< $PUBLISH_BRANCHES - if [[ " ${publish_branches[*]} " =~ " ${GITHUB_REF:11} " ]]; then echo "is_publish_branch=true" >> $GITHUB_ENV else echo "is_publish_branch=false" >> $GITHUB_ENV fi + - name: Set up .NET + uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0 + + - name: Set up Node + uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + with: + cache: "npm" + cache-dependency-path: "**/package-lock.json" + node-version: "16" + + - name: Print environment + run: | + whoami + dotnet --info + node --version + npm --version + echo "GitHub ref: $GITHUB_REF" + echo "GitHub event: $GITHUB_EVENT" + + - name: Build node + if: ${{ matrix.node }} + working-directory: ${{ matrix.base_path }}/${{ matrix.project_name }} + run: | + npm ci + npm run build + + - name: Publish project + working-directory: ${{ matrix.base_path }}/${{ matrix.project_name }} + if: ${{ matrix.dotnet }} + run: | + echo "Publish" + dotnet publish -c "Release" -o obj/build-output/publish + + cd obj/build-output/publish + zip -r ${{ matrix.project_name }}.zip . + mv ${{ matrix.project_name }}.zip ../../../ + + pwd + ls -atlh ../../../ + + - name: Upload project artifact + uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + if: ${{ matrix.dotnet }} + with: + name: ${{ matrix.project_name }}.zip + path: ${{ matrix.base_path }}/${{ matrix.project_name }}/${{ matrix.project_name }}.zip + if-no-files-found: error + + ########## Set up Docker ########## + - name: Set up QEMU emulators + uses: docker/setup-qemu-action@68827325e0b33c7199eb31dd4e31fbe9023e06e3 # v3.0.0 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0 + ########## ACRs ########## - name: Log in to Azure - production subscription uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 @@ -277,26 +237,24 @@ jobs: fi echo "tags=$TAGS" >> $GITHUB_OUTPUT - - name: Get build artifact - if: ${{ matrix.dotnet }} - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 - with: - name: ${{ matrix.project_name }}.zip - - - name: Set up build artifact - if: ${{ matrix.dotnet }} - run: | - mkdir -p ${{ matrix.base_path}}/${{ matrix.project_name }}/obj/build-output/publish - unzip ${{ matrix.project_name }}.zip \ - -d ${{ matrix.base_path }}/${{ matrix.project_name }}/obj/build-output/publish + - name: Generate image full name + id: cache-name + env: + PROJECT_NAME: ${{ steps.setup.outputs.project_name }} + run: echo "name=${_AZ_REGISTRY}/${PROJECT_NAME}:buildcache" >> $GITHUB_OUTPUT - name: Build Docker image - id: build-docker + id: build-artifacts uses: docker/build-push-action@67a2d409c0a876cbe6b11854e3e25193efe4e62d # v6.12.0 with: - context: ${{ matrix.base_path }}/${{ matrix.project_name }} + cache-from: type=registry,ref=${{ steps.cache-name.outputs.name }} + cache-to: type=registry,ref=${{ steps.cache-name.outputs.name}},mode=max + context: . file: ${{ matrix.base_path }}/${{ matrix.project_name }}/Dockerfile - platforms: linux/amd64 + platforms: | + linux/amd64, + linux/arm/v7, + linux/arm64 push: true tags: ${{ steps.image-tags.outputs.tags }} secrets: | @@ -309,7 +267,7 @@ jobs: - name: Sign image with Cosign if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' env: - DIGEST: ${{ steps.build-docker.outputs.digest }} + DIGEST: ${{ steps.build-artifacts.outputs.digest }} TAGS: ${{ steps.image-tags.outputs.tags }} run: | IFS="," read -a tags <<< "${TAGS}" @@ -336,8 +294,8 @@ jobs: upload: name: Upload - runs-on: ubuntu-22.04 - needs: build-docker + runs-on: ubuntu-24.04 + needs: build-artifacts steps: - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -377,9 +335,9 @@ jobs: # Run setup docker run -i --rm --name setup -v $STUB_OUTPUT/US:/bitwarden $SETUP_IMAGE \ - dotnet Setup.dll -stub 1 -install 1 -domain bitwarden.example.com -os lin -cloud-region US + /app/Setup -stub 1 -install 1 -domain bitwarden.example.com -os lin -cloud-region US docker run -i --rm --name setup -v $STUB_OUTPUT/EU:/bitwarden $SETUP_IMAGE \ - dotnet Setup.dll -stub 1 -install 1 -domain bitwarden.example.com -os lin -cloud-region EU + /app/Setup -stub 1 -install 1 -domain bitwarden.example.com -os lin -cloud-region EU sudo chown -R $(whoami):$(whoami) $STUB_OUTPUT @@ -512,7 +470,7 @@ jobs: build-mssqlmigratorutility: name: Build MSSQL migrator utility - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 needs: - lint defaults: @@ -568,9 +526,9 @@ jobs: 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 + runs-on: ubuntu-24.04 needs: - - build-docker + - build-artifacts steps: - name: Log in to Azure - CI subscription uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 @@ -604,7 +562,7 @@ jobs: if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' runs-on: ubuntu-22.04 needs: - - build-docker + - build-artifacts steps: - name: Log in to Azure - CI subscription uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 @@ -636,7 +594,8 @@ jobs: setup-ephemeral-environment: name: Setup Ephemeral Environment - needs: build-docker + needs: + - build-artifacts if: | needs.build-artifacts.outputs.has_secrets == 'true' && github.event_name == 'pull_request' @@ -654,7 +613,6 @@ jobs: needs: - lint - build-artifacts - - build-docker - upload - build-mssqlmigratorutility - self-host-build diff --git a/.github/workflows/build_target.yml b/.github/workflows/build_target.yml index 313446c949..d825721a7d 100644 --- a/.github/workflows/build_target.yml +++ b/.github/workflows/build_target.yml @@ -2,7 +2,9 @@ name: Build on PR Target on: pull_request_target: - types: [opened, synchronize] + types: [opened, synchronize, reopened] + branches: + - "main" defaults: run: diff --git a/.github/workflows/code-references.yml b/.github/workflows/code-references.yml index 30fbff32ed..359e64eb57 100644 --- a/.github/workflows/code-references.yml +++ b/.github/workflows/code-references.yml @@ -44,6 +44,7 @@ jobs: with: accessToken: ${{ secrets.LD_ACCESS_TOKEN }} projKey: default + allowTags: true - name: Add label if: steps.collect.outputs.any-changed == 'true' diff --git a/.github/workflows/scan.yml b/.github/workflows/scan.yml index fe88782e35..f24a0973fd 100644 --- a/.github/workflows/scan.yml +++ b/.github/workflows/scan.yml @@ -7,8 +7,14 @@ on: - "main" - "rc" - "hotfix-rc" + pull_request: + types: [opened, synchronize, reopened] + branches-ignore: + - main pull_request_target: - types: [opened, synchronize] + types: [opened, synchronize, reopened] + branches: + - "main" jobs: check-run: diff --git a/Directory.Build.props b/Directory.Build.props index 60d61e5e26..b369d6574d 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,7 +3,7 @@ net8.0 - 2025.5.0 + 2025.6.0 Bit.$(MSBuildProjectName) enable @@ -69,5 +69,4 @@ - \ No newline at end of file diff --git a/README.md b/README.md index 73992785d7..c817931c67 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,6 @@ Github Workflow build on main - - DockerHub - gitter chat @@ -26,12 +23,12 @@ Please refer to the [Server Setup Guide](https://contributing.bitwarden.com/gett ## Deploy

- + docker

-You can deploy Bitwarden using Docker containers on Windows, macOS, and Linux distributions. Use the provided PowerShell and Bash scripts to get started quickly. Find all of the Bitwarden images on [Docker Hub](https://hub.docker.com/u/bitwarden/). +You can deploy Bitwarden using Docker containers on Windows, macOS, and Linux distributions. Use the provided PowerShell and Bash scripts to get started quickly. Find all of the Bitwarden images on [GitHub Container Registry](https://github.com/orgs/bitwarden/packages). Full documentation for deploying Bitwarden with Docker can be found in our help center at: https://help.bitwarden.com/article/install-on-premise/ diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/CreateProviderCommand.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/CreateProviderCommand.cs index 36a5f2c0a9..8dee75c7c2 100644 --- a/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/CreateProviderCommand.cs +++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/CreateProviderCommand.cs @@ -3,9 +3,9 @@ using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; -using Bit.Core.Billing.Entities; using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Repositories; +using Bit.Core.Billing.Providers.Entities; +using Bit.Core.Billing.Providers.Repositories; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs index 9a62be8dd5..4af0e12e64 100644 --- a/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs +++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs @@ -7,13 +7,12 @@ using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Providers.Services; using Bit.Core.Billing.Services; -using Bit.Core.Billing.Services.Implementations.AutomaticTax; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.Services; -using Microsoft.Extensions.DependencyInjection; using Stripe; namespace Bit.Commercial.Core.AdminConsole.Providers; @@ -23,7 +22,6 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv private readonly IEventService _eventService; private readonly IMailService _mailService; private readonly IOrganizationRepository _organizationRepository; - private readonly IOrganizationService _organizationService; private readonly IProviderOrganizationRepository _providerOrganizationRepository; private readonly IStripeAdapter _stripeAdapter; private readonly IFeatureService _featureService; @@ -31,26 +29,22 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv private readonly ISubscriberService _subscriberService; private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery; private readonly IPricingClient _pricingClient; - private readonly IAutomaticTaxStrategy _automaticTaxStrategy; public RemoveOrganizationFromProviderCommand( IEventService eventService, IMailService mailService, IOrganizationRepository organizationRepository, - IOrganizationService organizationService, IProviderOrganizationRepository providerOrganizationRepository, IStripeAdapter stripeAdapter, IFeatureService featureService, IProviderBillingService providerBillingService, ISubscriberService subscriberService, IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery, - IPricingClient pricingClient, - [FromKeyedServices(AutomaticTaxFactory.BusinessUse)] IAutomaticTaxStrategy automaticTaxStrategy) + IPricingClient pricingClient) { _eventService = eventService; _mailService = mailService; _organizationRepository = organizationRepository; - _organizationService = organizationService; _providerOrganizationRepository = providerOrganizationRepository; _stripeAdapter = stripeAdapter; _featureService = featureService; @@ -58,7 +52,6 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv _subscriberService = subscriberService; _hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery; _pricingClient = pricingClient; - _automaticTaxStrategy = automaticTaxStrategy; } public async Task RemoveOrganizationFromProvider( @@ -76,7 +69,7 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv if (!await _hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync( providerOrganization.OrganizationId, - Array.Empty(), + [], includeProvider: false)) { throw new BadRequestException("Organization must have at least one confirmed owner."); @@ -101,7 +94,7 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv /// /// When a client organization is unlinked from a provider, we have to check if they're Stripe-enabled /// and, if they are, we remove their MSP discount and set their Subscription to `send_invoice`. This is because - /// the provider's payment method will be removed from their Stripe customer causing ensuing charges to fail. Lastly, + /// the provider's payment method will be removed from their Stripe customer, causing ensuing charges to fail. Lastly, /// we email the organization owners letting them know they need to add a new payment method. /// private async Task ResetOrganizationBillingAsync( @@ -141,15 +134,18 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv Items = [new SubscriptionItemOptions { Price = plan.PasswordManager.StripeSeatPlanId, Quantity = organization.Seats }] }; - if (_featureService.IsEnabled(FeatureFlagKeys.PM19147_AutomaticTaxImprovements)) + var setNonUSBusinessUseToReverseCharge = _featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge); + + if (setNonUSBusinessUseToReverseCharge) { - _automaticTaxStrategy.SetCreateOptions(subscriptionCreateOptions, customer); + subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }; } - else + else if (customer.HasRecognizedTaxLocation()) { - subscriptionCreateOptions.AutomaticTax ??= new SubscriptionAutomaticTaxOptions + subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { - Enabled = true + Enabled = customer.Address.Country == "US" || + customer.TaxIds.Any() }; } @@ -186,7 +182,7 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv await _mailService.SendProviderUpdatePaymentMethod( organization.Id, organization.Name, - provider.Name, + provider.Name!, organizationOwnerEmails); } } diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs index 2fc44937a7..3c75be756a 100644 --- a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs +++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs @@ -5,12 +5,13 @@ 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.OrganizationFeatures.Organizations; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Models; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Services; +using Bit.Core.Billing.Providers.Services; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; @@ -53,6 +54,7 @@ public class ProviderService : IProviderService private readonly IApplicationCacheService _applicationCacheService; private readonly IProviderBillingService _providerBillingService; private readonly IPricingClient _pricingClient; + private readonly IProviderClientOrganizationSignUpCommand _providerClientOrganizationSignUpCommand; public ProviderService(IProviderRepository providerRepository, IProviderUserRepository providerUserRepository, IProviderOrganizationRepository providerOrganizationRepository, IUserRepository userRepository, @@ -61,7 +63,8 @@ public class ProviderService : IProviderService IOrganizationRepository organizationRepository, GlobalSettings globalSettings, ICurrentContext currentContext, IStripeAdapter stripeAdapter, IFeatureService featureService, IDataProtectorTokenFactory providerDeleteTokenDataFactory, - IApplicationCacheService applicationCacheService, IProviderBillingService providerBillingService, IPricingClient pricingClient) + IApplicationCacheService applicationCacheService, IProviderBillingService providerBillingService, IPricingClient pricingClient, + IProviderClientOrganizationSignUpCommand providerClientOrganizationSignUpCommand) { _providerRepository = providerRepository; _providerUserRepository = providerUserRepository; @@ -81,6 +84,7 @@ public class ProviderService : IProviderService _applicationCacheService = applicationCacheService; _providerBillingService = providerBillingService; _pricingClient = pricingClient; + _providerClientOrganizationSignUpCommand = providerClientOrganizationSignUpCommand; } public async Task CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TaxInfo taxInfo, TokenizedPaymentSource tokenizedPaymentSource = null) @@ -283,11 +287,10 @@ public class ProviderService : IProviderService foreach (var user in users) { - if (!keyedFilteredUsers.ContainsKey(user.Id)) + if (!keyedFilteredUsers.TryGetValue(user.Id, out var providerUser)) { continue; } - var providerUser = keyedFilteredUsers[user.Id]; try { if (providerUser.Status != ProviderUserStatusType.Accepted || providerUser.ProviderId != providerId) @@ -560,12 +563,12 @@ public class ProviderService : IProviderService ThrowOnInvalidPlanType(provider.Type, organizationSignup.Plan); - var (organization, _, defaultCollection) = await _organizationService.SignupClientAsync(organizationSignup); + var signUpResponse = await _providerClientOrganizationSignUpCommand.SignUpClientOrganizationAsync(organizationSignup); var providerOrganization = new ProviderOrganization { ProviderId = providerId, - OrganizationId = organization.Id, + OrganizationId = signUpResponse.Organization.Id, Key = organizationSignup.OwnerKey, }; @@ -574,12 +577,12 @@ public class ProviderService : IProviderService // Give the owner Can Manage access over the default collection // The orgUser is not available when the org is created so we have to do it here as part of the invite - var defaultOwnerAccess = defaultCollection != null + var defaultOwnerAccess = signUpResponse.DefaultCollection != null ? [ new CollectionAccessSelection { - Id = defaultCollection.Id, + Id = signUpResponse.DefaultCollection.Id, HidePasswords = false, ReadOnly = false, Manage = true @@ -587,7 +590,7 @@ public class ProviderService : IProviderService ] : Array.Empty(); - await _organizationService.InviteUsersAsync(organization.Id, user.Id, systemUser: null, + await _organizationService.InviteUsersAsync(signUpResponse.Organization.Id, user.Id, systemUser: null, new (OrganizationUserInvite, string)[] { ( diff --git a/bitwarden_license/src/Commercial.Core/Billing/Models/ProviderClientInvoiceReportRow.cs b/bitwarden_license/src/Commercial.Core/Billing/Providers/Models/ProviderClientInvoiceReportRow.cs similarity index 91% rename from bitwarden_license/src/Commercial.Core/Billing/Models/ProviderClientInvoiceReportRow.cs rename to bitwarden_license/src/Commercial.Core/Billing/Providers/Models/ProviderClientInvoiceReportRow.cs index c78e213c34..eea40577ad 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/Models/ProviderClientInvoiceReportRow.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/Providers/Models/ProviderClientInvoiceReportRow.cs @@ -1,8 +1,8 @@ using System.Globalization; -using Bit.Core.Billing.Entities; +using Bit.Core.Billing.Providers.Entities; using CsvHelper.Configuration.Attributes; -namespace Bit.Commercial.Core.Billing.Models; +namespace Bit.Commercial.Core.Billing.Providers.Models; public class ProviderClientInvoiceReportRow { diff --git a/bitwarden_license/src/Commercial.Core/Billing/BusinessUnitConverter.cs b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/BusinessUnitConverter.cs similarity index 98% rename from bitwarden_license/src/Commercial.Core/Billing/BusinessUnitConverter.cs rename to bitwarden_license/src/Commercial.Core/Billing/Providers/Services/BusinessUnitConverter.cs index 97d9377cd6..8e8a89ae58 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/BusinessUnitConverter.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/BusinessUnitConverter.cs @@ -1,17 +1,17 @@ #nullable enable using System.Diagnostics.CodeAnalysis; -using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing; using Bit.Core.Billing.Constants; -using Bit.Core.Billing.Entities; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Repositories; +using Bit.Core.Billing.Providers.Entities; +using Bit.Core.Billing.Providers.Repositories; +using Bit.Core.Billing.Providers.Services; using Bit.Core.Billing.Services; using Bit.Core.Entities; using Bit.Core.Enums; @@ -24,9 +24,8 @@ using Microsoft.Extensions.Logging; using OneOf; using Stripe; -namespace Bit.Commercial.Core.Billing; +namespace Bit.Commercial.Core.Billing.Providers.Services; -[RequireFeature(FeatureFlagKeys.PM18770_EnableOrganizationBusinessUnitConversion)] public class BusinessUnitConverter( IDataProtectionProvider dataProtectionProvider, GlobalSettings globalSettings, @@ -67,6 +66,7 @@ public class BusinessUnitConverter( organization.MaxStorageGb = updatedPlan.PasswordManager.BaseStorageGb; organization.UsePolicies = updatedPlan.HasPolicies; organization.UseSso = updatedPlan.HasSso; + organization.UseOrganizationDomains = updatedPlan.HasOrganizationDomains; organization.UseGroups = updatedPlan.HasGroups; organization.UseEvents = updatedPlan.HasEvents; organization.UseDirectory = updatedPlan.HasDirectory; diff --git a/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs similarity index 94% rename from bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs rename to bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs index f049d6c8df..8c90d778bc 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs @@ -1,5 +1,5 @@ using System.Globalization; -using Bit.Commercial.Core.Billing.Models; +using Bit.Commercial.Core.Billing.Providers.Models; using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; @@ -8,15 +8,17 @@ using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing; using Bit.Core.Billing.Caches; using Bit.Core.Billing.Constants; -using Bit.Core.Billing.Entities; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Models; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Repositories; +using Bit.Core.Billing.Providers.Entities; +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.Services.Contracts; -using Bit.Core.Billing.Services.Implementations.AutomaticTax; +using Bit.Core.Billing.Tax.Models; +using Bit.Core.Billing.Tax.Services; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Business; @@ -25,15 +27,13 @@ using Bit.Core.Services; using Bit.Core.Settings; using Braintree; using CsvHelper; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Stripe; - using static Bit.Core.Billing.Utilities; using Customer = Stripe.Customer; using Subscription = Stripe.Subscription; -namespace Bit.Commercial.Core.Billing; +namespace Bit.Commercial.Core.Billing.Providers.Services; public class ProviderBillingService( IBraintreeGateway braintreeGateway, @@ -50,8 +50,7 @@ public class ProviderBillingService( ISetupIntentCache setupIntentCache, IStripeAdapter stripeAdapter, ISubscriberService subscriberService, - ITaxService taxService, - [FromKeyedServices(AutomaticTaxFactory.BusinessUse)] IAutomaticTaxStrategy automaticTaxStrategy) + ITaxService taxService) : IProviderBillingService { public async Task AddExistingOrganization( @@ -97,6 +96,7 @@ public class ProviderBillingService( organization.MaxStorageGb = plan.PasswordManager.BaseStorageGb; organization.UsePolicies = plan.HasPolicies; organization.UseSso = plan.HasSso; + organization.UseOrganizationDomains = plan.HasOrganizationDomains; organization.UseGroups = plan.HasGroups; organization.UseEvents = plan.HasEvents; organization.UseDirectory = plan.HasDirectory; @@ -125,7 +125,7 @@ public class ProviderBillingService( /* * We have to scale the provider's seats before the ProviderOrganization - * row is inserted so the added organization's seats don't get double counted. + * row is inserted so the added organization's seats don't get double-counted. */ await ScaleSeats(provider, organization.PlanType, organization.Seats!.Value); @@ -233,7 +233,7 @@ public class ProviderBillingService( var providerCustomer = await subscriberService.GetCustomerOrThrow(provider, new CustomerGetOptions { - Expand = ["tax_ids"] + Expand = ["tax", "tax_ids"] }); var providerTaxId = providerCustomer.TaxIds.FirstOrDefault(); @@ -281,6 +281,13 @@ public class ProviderBillingService( ] }; + var setNonUSBusinessUseToReverseCharge = featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge); + + if (setNonUSBusinessUseToReverseCharge && providerCustomer.Address is not { Country: "US" }) + { + customerCreateOptions.TaxExempt = StripeConstants.TaxExempt.Reverse; + } + var customer = await stripeAdapter.CustomerCreateAsync(customerCreateOptions); organization.GatewayCustomerId = customer.Id; @@ -517,6 +524,13 @@ public class ProviderBillingService( } }; + var setNonUSBusinessUseToReverseCharge = featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge); + + if (setNonUSBusinessUseToReverseCharge && taxInfo.BillingAddressCountry != "US") + { + options.TaxExempt = StripeConstants.TaxExempt.Reverse; + } + if (!string.IsNullOrEmpty(taxInfo.TaxIdNumber)) { var taxIdType = taxService.GetStripeTaxCode( @@ -528,6 +542,7 @@ public class ProviderBillingService( logger.LogWarning("Could not infer tax ID type in country '{Country}' with tax ID '{TaxID}'.", taxInfo.BillingAddressCountry, taxInfo.TaxIdNumber); + throw new BadRequestException("billingTaxIdTypeInferenceError"); } @@ -692,6 +707,13 @@ public class ProviderBillingService( customer.Metadata.ContainsKey(BraintreeCustomerIdKey) || setupIntent.IsUnverifiedBankAccount()); + int? trialPeriodDays = provider.Type switch + { + ProviderType.Msp when usePaymentMethod => 14, + ProviderType.BusinessUnit when usePaymentMethod => 4, + _ => null + }; + var subscriptionCreateOptions = new SubscriptionCreateOptions { CollectionMethod = usePaymentMethod ? @@ -705,17 +727,24 @@ public class ProviderBillingService( }, OffSession = true, ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations, - TrialPeriodDays = usePaymentMethod ? 14 : null + TrialPeriodDays = trialPeriodDays }; - if (featureService.IsEnabled(FeatureFlagKeys.PM19147_AutomaticTaxImprovements)) - { - automaticTaxStrategy.SetCreateOptions(subscriptionCreateOptions, customer); - } - else + var setNonUSBusinessUseToReverseCharge = + featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge); + + if (setNonUSBusinessUseToReverseCharge) { subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }; } + else if (customer.HasRecognizedTaxLocation()) + { + subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions + { + Enabled = customer.Address.Country == "US" || + customer.TaxIds.Any() + }; + } try { diff --git a/bitwarden_license/src/Commercial.Core/Billing/ProviderPriceAdapter.cs b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderPriceAdapter.cs similarity index 99% rename from bitwarden_license/src/Commercial.Core/Billing/ProviderPriceAdapter.cs rename to bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderPriceAdapter.cs index a9dbb6febf..8c55d31f2c 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/ProviderPriceAdapter.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderPriceAdapter.cs @@ -6,7 +6,7 @@ using Bit.Core.Billing; using Bit.Core.Billing.Enums; using Stripe; -namespace Bit.Commercial.Core.Billing; +namespace Bit.Commercial.Core.Billing.Providers.Services; public static class ProviderPriceAdapter { diff --git a/bitwarden_license/src/Commercial.Core/SecretsManager/Queries/Projects/MaxProjectsQuery.cs b/bitwarden_license/src/Commercial.Core/SecretsManager/Queries/Projects/MaxProjectsQuery.cs index 394e8aa9bc..7e8857e5d7 100644 --- a/bitwarden_license/src/Commercial.Core/SecretsManager/Queries/Projects/MaxProjectsQuery.cs +++ b/bitwarden_license/src/Commercial.Core/SecretsManager/Queries/Projects/MaxProjectsQuery.cs @@ -1,13 +1,9 @@ -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.Enums; using Bit.Core.Billing.Pricing; using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.SecretsManager.Queries.Projects.Interfaces; using Bit.Core.SecretsManager.Repositories; -using Bit.Core.Services; using Bit.Core.Settings; namespace Bit.Commercial.Core.SecretsManager.Queries.Projects; @@ -17,72 +13,42 @@ public class MaxProjectsQuery : IMaxProjectsQuery private readonly IOrganizationRepository _organizationRepository; private readonly IProjectRepository _projectRepository; private readonly IGlobalSettings _globalSettings; - private readonly ILicensingService _licensingService; private readonly IPricingClient _pricingClient; public MaxProjectsQuery( IOrganizationRepository organizationRepository, IProjectRepository projectRepository, IGlobalSettings globalSettings, - ILicensingService licensingService, IPricingClient pricingClient) { _organizationRepository = organizationRepository; _projectRepository = projectRepository; _globalSettings = globalSettings; - _licensingService = licensingService; _pricingClient = pricingClient; } public async Task<(short? max, bool? overMax)> GetByOrgIdAsync(Guid organizationId, int projectsToAdd) { + // "MaxProjects" only applies to free 2-person organizations, which can't be self-hosted. + if (_globalSettings.SelfHosted) + { + return (null, null); + } + var org = await _organizationRepository.GetByIdAsync(organizationId); if (org == null) { throw new NotFoundException(); } - var (planType, maxProjects) = await GetPlanTypeAndMaxProjectsAsync(org); + var plan = await _pricingClient.GetPlan(org.PlanType); - if (planType != PlanType.Free) + if (plan is not { SecretsManager: not null, Type: PlanType.Free }) { return (null, null); } var projects = await _projectRepository.GetProjectCountByOrganizationIdAsync(organizationId); - return ((short? max, bool? overMax))(projects + projectsToAdd > maxProjects ? (maxProjects, true) : (maxProjects, false)); - } - - private async Task<(PlanType planType, int maxProjects)> GetPlanTypeAndMaxProjectsAsync(Organization organization) - { - if (_globalSettings.SelfHosted) - { - var license = await _licensingService.ReadOrganizationLicenseAsync(organization); - - if (license == null) - { - throw new BadRequestException("License not found."); - } - - var claimsPrincipal = _licensingService.GetClaimsPrincipalFromLicense(license); - var maxProjects = claimsPrincipal.GetValue(OrganizationLicenseConstants.SmMaxProjects); - - if (!maxProjects.HasValue) - { - throw new BadRequestException("License does not contain a value for max Secrets Manager projects"); - } - - var planType = claimsPrincipal.GetValue(OrganizationLicenseConstants.PlanType); - return (planType, maxProjects.Value); - } - - var plan = await _pricingClient.GetPlan(organization.PlanType); - - if (plan is { SupportsSecretsManager: true }) - { - return (plan.Type, plan.SecretsManager.MaxProjects); - } - - throw new BadRequestException("Existing plan not found."); + return ((short? max, bool? overMax))(projects + projectsToAdd > plan.SecretsManager.MaxProjects ? (plan.SecretsManager.MaxProjects, true) : (plan.SecretsManager.MaxProjects, false)); } } diff --git a/bitwarden_license/src/Commercial.Core/Utilities/ServiceCollectionExtensions.cs b/bitwarden_license/src/Commercial.Core/Utilities/ServiceCollectionExtensions.cs index 7f8c82e2c9..34f49e0ccc 100644 --- a/bitwarden_license/src/Commercial.Core/Utilities/ServiceCollectionExtensions.cs +++ b/bitwarden_license/src/Commercial.Core/Utilities/ServiceCollectionExtensions.cs @@ -1,9 +1,9 @@ using Bit.Commercial.Core.AdminConsole.Providers; using Bit.Commercial.Core.AdminConsole.Services; -using Bit.Commercial.Core.Billing; +using Bit.Commercial.Core.Billing.Providers.Services; using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Services; -using Bit.Core.Billing.Services; +using Bit.Core.Billing.Providers.Services; using Microsoft.Extensions.DependencyInjection; namespace Bit.Commercial.Core.Utilities; diff --git a/bitwarden_license/src/Scim/.dockerignore b/bitwarden_license/src/Scim/.dockerignore deleted file mode 100644 index fc12f25146..0000000000 --- a/bitwarden_license/src/Scim/.dockerignore +++ /dev/null @@ -1,4 +0,0 @@ -* -!obj/build-output/publish/* -!obj/Docker/empty/ -!entrypoint.sh diff --git a/bitwarden_license/src/Scim/Dockerfile b/bitwarden_license/src/Scim/Dockerfile index 6970dfa7bb..a0c5c88e49 100644 --- a/bitwarden_license/src/Scim/Dockerfile +++ b/bitwarden_license/src/Scim/Dockerfile @@ -1,6 +1,50 @@ +############################################### +# Build stage # +############################################### +FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0 AS build + +# Docker buildx supplies the value for this arg +ARG TARGETPLATFORM + +# Determine proper runtime value for .NET +# We put the value in a file to be read by later layers. +RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \ + RID=linux-x64 ; \ + elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \ + RID=linux-arm64 ; \ + elif [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then \ + RID=linux-arm ; \ + fi \ + && echo "RID=$RID" > /tmp/rid.txt + +# Copy required project files +WORKDIR /source +COPY . ./ + +# Restore project dependencies and tools +WORKDIR /source/bitwarden_license/src/Scim +RUN . /tmp/rid.txt && dotnet restore -r $RID + +# Build project +RUN . /tmp/rid.txt && dotnet publish \ + -c release \ + --no-restore \ + --self-contained \ + /p:PublishSingleFile=true \ + -r $RID \ + -o out + +############################################### +# App stage # +############################################### FROM mcr.microsoft.com/dotnet/aspnet:8.0 +ARG TARGETPLATFORM LABEL com.bitwarden.product="bitwarden" +ENV ASPNETCORE_ENVIRONMENT=Production +ENV ASPNETCORE_URLS=http://+:5000 +ENV SSL_CERT_DIR=/etc/bitwarden/ca-certificates +EXPOSE 5000 RUN apt-get update \ && apt-get install -y --no-install-recommends \ @@ -9,11 +53,10 @@ RUN apt-get update \ krb5-user \ && rm -rf /var/lib/apt/lists/* -ENV ASPNETCORE_URLS http://+:5000 +# Copy app from the build stage WORKDIR /app -EXPOSE 5000 -COPY obj/build-output/publish . -COPY entrypoint.sh / +COPY --from=build /source/bitwarden_license/src/Scim/out /app +COPY ./bitwarden_license/src/Scim/entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh HEALTHCHECK CMD curl -f http://localhost:5000/alive || exit 1 diff --git a/bitwarden_license/src/Scim/Program.cs b/bitwarden_license/src/Scim/Program.cs index 5d7d505aac..92f12f59dd 100644 --- a/bitwarden_license/src/Scim/Program.cs +++ b/bitwarden_license/src/Scim/Program.cs @@ -16,8 +16,8 @@ public class Program { var context = e.Properties["SourceContext"].ToString(); - if (e.Properties.ContainsKey("RequestPath") && - !string.IsNullOrWhiteSpace(e.Properties["RequestPath"]?.ToString()) && + if (e.Properties.TryGetValue("RequestPath", out var requestPath) && + !string.IsNullOrWhiteSpace(requestPath?.ToString()) && (context.Contains(".Server.Kestrel") || context.Contains(".Core.IISHttpServer"))) { return false; diff --git a/bitwarden_license/src/Scim/entrypoint.sh b/bitwarden_license/src/Scim/entrypoint.sh index edc3bbe14a..41930504d3 100644 --- a/bitwarden_license/src/Scim/entrypoint.sh +++ b/bitwarden_license/src/Scim/entrypoint.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # Setup @@ -19,31 +19,42 @@ then LGID=65534 fi -# Create user and group +if [ "$(id -u)" = "0" ] +then + # Create user and group -groupadd -o -g $LGID $GROUPNAME >/dev/null 2>&1 || -groupmod -o -g $LGID $GROUPNAME >/dev/null 2>&1 -useradd -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 || -usermod -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 -mkhomedir_helper $USERNAME + groupadd -o -g $LGID $GROUPNAME >/dev/null 2>&1 || + groupmod -o -g $LGID $GROUPNAME >/dev/null 2>&1 + useradd -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 || + usermod -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 + mkhomedir_helper $USERNAME -# The rest... + # The rest... -chown -R $USERNAME:$GROUPNAME /app -mkdir -p /etc/bitwarden/core -mkdir -p /etc/bitwarden/logs -mkdir -p /etc/bitwarden/ca-certificates -chown -R $USERNAME:$GROUPNAME /etc/bitwarden + chown -R $USERNAME:$GROUPNAME /app + mkdir -p /etc/bitwarden/core + mkdir -p /etc/bitwarden/logs + mkdir -p /etc/bitwarden/ca-certificates + chown -R $USERNAME:$GROUPNAME /etc/bitwarden -if [[ $globalSettings__selfHosted == "true" ]]; then - cp /etc/bitwarden/ca-certificates/*.crt /usr/local/share/ca-certificates/ >/dev/null 2>&1 \ - && update-ca-certificates + if [[ -f "/etc/bitwarden/kerberos/bitwarden.keytab" && -f "/etc/bitwarden/kerberos/krb5.conf" ]]; then + chown -R $USERNAME:$GROUPNAME /etc/bitwarden/kerberos + fi + + gosu_cmd="gosu $USERNAME:$GROUPNAME" +else + gosu_cmd="" fi if [[ -f "/etc/bitwarden/kerberos/bitwarden.keytab" && -f "/etc/bitwarden/kerberos/krb5.conf" ]]; then - chown -R $USERNAME:$GROUPNAME /etc/bitwarden/kerberos - cp -f /etc/bitwarden/kerberos/krb5.conf /etc/krb5.conf - gosu $USERNAME:$GROUPNAME kinit $globalSettings__kerberosUser -k -t /etc/bitwarden/kerberos/bitwarden.keytab + cp -f /etc/bitwarden/kerberos/krb5.conf /etc/krb5.conf + $gosu_cmd kinit $globalSettings__kerberosUser -k -t /etc/bitwarden/kerberos/bitwarden.keytab fi -exec gosu $USERNAME:$GROUPNAME dotnet /app/Scim.dll +if [[ $globalSettings__selfHosted == "true" ]]; then + if [[ -z $globalSettings__identityServer__certificateLocation ]]; then + export globalSettings__identityServer__certificateLocation=/etc/bitwarden/identity/identity.pfx + fi +fi + +exec $gosu_cmd /app/Scim diff --git a/bitwarden_license/src/Sso/Controllers/AccountController.cs b/bitwarden_license/src/Sso/Controllers/AccountController.cs index f41d2d3c65..5c03ba0017 100644 --- a/bitwarden_license/src/Sso/Controllers/AccountController.cs +++ b/bitwarden_license/src/Sso/Controllers/AccountController.cs @@ -370,8 +370,8 @@ public class AccountController : Controller // for the user identifier. static bool nameIdIsNotTransient(Claim c) => c.Type == ClaimTypes.NameIdentifier && (c.Properties == null - || !c.Properties.ContainsKey(SamlPropertyKeys.ClaimFormat) - || c.Properties[SamlPropertyKeys.ClaimFormat] != SamlNameIdFormats.Transient); + || !c.Properties.TryGetValue(SamlPropertyKeys.ClaimFormat, out var claimFormat) + || claimFormat != SamlNameIdFormats.Transient); // Try to determine the unique id of the external user (issued by the provider) // the most common claim type for that are the sub claim and the NameIdentifier diff --git a/bitwarden_license/src/Sso/Dockerfile b/bitwarden_license/src/Sso/Dockerfile index 6970dfa7bb..d5d012b416 100644 --- a/bitwarden_license/src/Sso/Dockerfile +++ b/bitwarden_license/src/Sso/Dockerfile @@ -1,6 +1,50 @@ +############################################### +# Build stage # +############################################### +FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0 AS build + +# Docker buildx supplies the value for this arg +ARG TARGETPLATFORM + +# Determine proper runtime value for .NET +# We put the value in a file to be read by later layers. +RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \ + RID=linux-x64 ; \ + elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \ + RID=linux-arm64 ; \ + elif [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then \ + RID=linux-arm ; \ + fi \ + && echo "RID=$RID" > /tmp/rid.txt + +# Copy required project files +WORKDIR /source +COPY . ./ + +# Restore project dependencies and tools +WORKDIR /source/bitwarden_license/src/Sso +RUN . /tmp/rid.txt && dotnet restore -r $RID + +# Build project +RUN . /tmp/rid.txt && dotnet publish \ + -c release \ + --no-restore \ + --self-contained \ + /p:PublishSingleFile=true \ + -r $RID \ + -o out + +############################################### +# App stage # +############################################### FROM mcr.microsoft.com/dotnet/aspnet:8.0 +ARG TARGETPLATFORM LABEL com.bitwarden.product="bitwarden" +ENV ASPNETCORE_ENVIRONMENT=Production +ENV ASPNETCORE_URLS=http://+:5000 +ENV SSL_CERT_DIR=/etc/bitwarden/ca-certificates +EXPOSE 5000 RUN apt-get update \ && apt-get install -y --no-install-recommends \ @@ -9,11 +53,10 @@ RUN apt-get update \ krb5-user \ && rm -rf /var/lib/apt/lists/* -ENV ASPNETCORE_URLS http://+:5000 +# Copy app from the build stage WORKDIR /app -EXPOSE 5000 -COPY obj/build-output/publish . -COPY entrypoint.sh / +COPY --from=build /source/bitwarden_license/src/Sso/out /app +COPY ./bitwarden_license/src/Sso/entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh HEALTHCHECK CMD curl -f http://localhost:5000/alive || exit 1 diff --git a/bitwarden_license/src/Sso/Program.cs b/bitwarden_license/src/Sso/Program.cs index 051caca9c2..1a8ce6eb88 100644 --- a/bitwarden_license/src/Sso/Program.cs +++ b/bitwarden_license/src/Sso/Program.cs @@ -17,8 +17,8 @@ public class Program logging.AddSerilog(hostingContext, (e, globalSettings) => { var context = e.Properties["SourceContext"].ToString(); - if (e.Properties.ContainsKey("RequestPath") && - !string.IsNullOrWhiteSpace(e.Properties["RequestPath"]?.ToString()) && + if (e.Properties.TryGetValue("RequestPath", out var requestPath) && + !string.IsNullOrWhiteSpace(requestPath?.ToString()) && (context.Contains(".Server.Kestrel") || context.Contains(".Core.IISHttpServer"))) { return false; diff --git a/bitwarden_license/src/Sso/Utilities/OpenIdConnectOptionsExtensions.cs b/bitwarden_license/src/Sso/Utilities/OpenIdConnectOptionsExtensions.cs index 9221877a04..825ed74dc8 100644 --- a/bitwarden_license/src/Sso/Utilities/OpenIdConnectOptionsExtensions.cs +++ b/bitwarden_license/src/Sso/Utilities/OpenIdConnectOptionsExtensions.cs @@ -46,9 +46,9 @@ public static class OpenIdConnectOptionsExtensions // Handle State if we've gotten that back var decodedState = options.StateDataFormat.Unprotect(state); - if (decodedState != null && decodedState.Items.ContainsKey("scheme")) + if (decodedState != null && decodedState.Items.TryGetValue("scheme", out var stateScheme)) { - return decodedState.Items["scheme"] == scheme; + return stateScheme == scheme; } } catch diff --git a/bitwarden_license/src/Sso/entrypoint.sh b/bitwarden_license/src/Sso/entrypoint.sh index 2c7bd18b84..c762659fb3 100644 --- a/bitwarden_license/src/Sso/entrypoint.sh +++ b/bitwarden_license/src/Sso/entrypoint.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # Setup @@ -19,37 +19,42 @@ then LGID=65534 fi -# Create user and group +if [ "$(id -u)" = "0" ] +then + # Create user and group -groupadd -o -g $LGID $GROUPNAME >/dev/null 2>&1 || -groupmod -o -g $LGID $GROUPNAME >/dev/null 2>&1 -useradd -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 || -usermod -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 -mkhomedir_helper $USERNAME + groupadd -o -g $LGID $GROUPNAME >/dev/null 2>&1 || + groupmod -o -g $LGID $GROUPNAME >/dev/null 2>&1 + useradd -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 || + usermod -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 + mkhomedir_helper $USERNAME -# The rest... + # The rest... -mkdir -p /etc/bitwarden/identity -mkdir -p /etc/bitwarden/core -mkdir -p /etc/bitwarden/logs -mkdir -p /etc/bitwarden/ca-certificates -chown -R $USERNAME:$GROUPNAME /etc/bitwarden + chown -R $USERNAME:$GROUPNAME /app + mkdir -p /etc/bitwarden/core + mkdir -p /etc/bitwarden/logs + mkdir -p /etc/bitwarden/ca-certificates + chown -R $USERNAME:$GROUPNAME /etc/bitwarden -if [[ $globalSettings__selfHosted == "true" ]]; then - cp /etc/bitwarden/identity/identity.pfx /app/identity.pfx -fi + if [[ -f "/etc/bitwarden/kerberos/bitwarden.keytab" && -f "/etc/bitwarden/kerberos/krb5.conf" ]]; then + chown -R $USERNAME:$GROUPNAME /etc/bitwarden/kerberos + fi -chown -R $USERNAME:$GROUPNAME /app - -if [[ $globalSettings__selfHosted == "true" ]]; then - cp /etc/bitwarden/ca-certificates/*.crt /usr/local/share/ca-certificates/ >/dev/null 2>&1 \ - && update-ca-certificates + gosu_cmd="gosu $USERNAME:$GROUPNAME" +else + gosu_cmd="" fi if [[ -f "/etc/bitwarden/kerberos/bitwarden.keytab" && -f "/etc/bitwarden/kerberos/krb5.conf" ]]; then - chown -R $USERNAME:$GROUPNAME /etc/bitwarden/kerberos - cp -f /etc/bitwarden/kerberos/krb5.conf /etc/krb5.conf - gosu $USERNAME:$GROUPNAME kinit $globalSettings__kerberosUser -k -t /etc/bitwarden/kerberos/bitwarden.keytab + cp -f /etc/bitwarden/kerberos/krb5.conf /etc/krb5.conf + $gosu_cmd kinit $globalSettings__kerberosUser -k -t /etc/bitwarden/kerberos/bitwarden.keytab fi -exec gosu $USERNAME:$GROUPNAME dotnet /app/Sso.dll +if [[ $globalSettings__selfHosted == "true" ]]; then + if [[ -z $globalSettings__identityServer__certificateLocation ]]; then + export globalSettings__identityServer__certificateLocation=/etc/bitwarden/identity/identity.pfx + fi +fi + +exec $gosu_cmd /app/Sso diff --git a/bitwarden_license/src/Sso/package-lock.json b/bitwarden_license/src/Sso/package-lock.json index 0b861365bc..636d6317a1 100644 --- a/bitwarden_license/src/Sso/package-lock.json +++ b/bitwarden_license/src/Sso/package-lock.json @@ -9,17 +9,17 @@ "version": "0.0.0", "license": "-", "dependencies": { - "bootstrap": "5.3.3", + "bootstrap": "5.3.6", "font-awesome": "4.7.0", "jquery": "3.7.1" }, "devDependencies": { "css-loader": "7.1.2", - "expose-loader": "5.0.0", + "expose-loader": "5.0.1", "mini-css-extract-plugin": "2.9.2", - "sass": "1.85.0", - "sass-loader": "16.0.4", - "webpack": "5.97.1", + "sass": "1.88.0", + "sass-loader": "16.0.5", + "webpack": "5.99.8", "webpack-cli": "5.1.4" } }, @@ -455,13 +455,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.13.14", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.14.tgz", - "integrity": "sha512-Zs/Ollc1SJ8nKUAgc7ivOEdIBM8JAKgrqqUYi2J997JuKO7/tpQC+WCetQ1sypiKCQWHdvdg9wBNpUPEWZae7w==", + "version": "22.15.21", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.21.tgz", + "integrity": "sha512-EV/37Td6c+MgKAbkcLG6vqZ2zEYHD7bvSrzqqs2RIhbA6w3x+Dqz8MZM3sP6kGTeLrdoOgKZe+Xja7tUB2DNkQ==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.20.0" + "undici-types": "~6.21.0" } }, "node_modules/@webassemblyjs/ast": { @@ -748,9 +748,9 @@ } }, "node_modules/bootstrap": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.3.tgz", - "integrity": "sha512-8HLCdWgyoMguSO9o+aH+iuZ+aht+mzW0u3HIMzVu7Srrpv7EBBxTnrFlSCskwdY1+EOFQSm7uMJhNQHkdPcmjg==", + "version": "5.3.6", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.6.tgz", + "integrity": "sha512-jX0GAcRzvdwISuvArXn3m7KZscWWFAf1MKBcnzaN02qWMb3jpMoUX4/qgeiGzqyIb4ojulRzs89UCUmGcFSzTA==", "funding": [ { "type": "github", @@ -781,9 +781,9 @@ } }, "node_modules/browserslist": { - "version": "4.24.4", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz", - "integrity": "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==", + "version": "4.24.5", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.5.tgz", + "integrity": "sha512-FDToo4Wo82hIdgc1CQ+NQD0hEhmpPjrZ3hiUgwgOG6IuTdlpr8jdjyG24P6cNP1yJpTLzS5OcGgSw0xmDU1/Tw==", "dev": true, "funding": [ { @@ -801,10 +801,10 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001688", - "electron-to-chromium": "^1.5.73", + "caniuse-lite": "^1.0.30001716", + "electron-to-chromium": "^1.5.149", "node-releases": "^2.0.19", - "update-browserslist-db": "^1.1.1" + "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" @@ -821,9 +821,9 @@ "license": "MIT" }, "node_modules/caniuse-lite": { - "version": "1.0.30001707", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001707.tgz", - "integrity": "sha512-3qtRjw/HQSMlDWf+X79N206fepf4SOOU6SQLMaq/0KkZLmSjPxAkBOQQ+FxbHKfHmYLZFfdWsO3KA90ceHPSnw==", + "version": "1.0.30001718", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001718.tgz", + "integrity": "sha512-AflseV1ahcSunK53NfEs9gFWgOEmzr0f+kaMFA4xiLZlr9Hzt7HxcSpIFcnNCUkz6R6dWKa54rUz3HUmI3nVcw==", "dev": true, "funding": [ { @@ -975,9 +975,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.128", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.128.tgz", - "integrity": "sha512-bo1A4HH/NS522Ws0QNFIzyPcyUUNV/yyy70Ho1xqfGYzPUme2F/xr4tlEOuM6/A538U1vDA7a4XfCd1CKRegKQ==", + "version": "1.5.155", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.155.tgz", + "integrity": "sha512-ps5KcGGmwL8VaeJlvlDlu4fORQpv3+GIcF5I3f9tUKUlJ/wsysh6HU8P5L1XWRYeXfA0oJd4PyM8ds8zTFf6Ng==", "dev": true, "license": "ISC" }, @@ -1009,9 +1009,9 @@ } }, "node_modules/es-module-lexer": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.6.0.tgz", - "integrity": "sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==", + "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==", "dev": true, "license": "MIT" }, @@ -1083,9 +1083,9 @@ } }, "node_modules/expose-loader": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/expose-loader/-/expose-loader-5.0.0.tgz", - "integrity": "sha512-BtUqYRmvx1bEY5HN6eK2I9URUZgNmN0x5UANuocaNjXSgfoDlkXt+wyEMe7i5DzDNh2BKJHPc5F4rBwEdSQX6w==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/expose-loader/-/expose-loader-5.0.1.tgz", + "integrity": "sha512-5YPZuszN/eWND/B+xuq5nIpb/l5TV1HYmdO6SubYtHv+HenVw9/6bn33Mm5reY8DNid7AVtbARvyUD34edfCtg==", "dev": true, "license": "MIT", "engines": { @@ -1106,13 +1106,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, "node_modules/fast-uri": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", @@ -1248,9 +1241,9 @@ } }, "node_modules/immutable": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.1.tgz", - "integrity": "sha512-3jatXi9ObIsPGr3N5hGw/vWWcTkq6hUYhpQz4k0wLC+owqWi/LiugIw9x0EdNZ2yGedKN/HzePiBvaJRXa0Ujg==", + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.2.tgz", + "integrity": "sha512-qHKXW1q6liAk1Oys6umoaZbDRqjcjgSrbnrifHsfsttza7zcvRAsL7mMV6xWcyhwQy7Xj5v4hhbr6b+iDYwlmQ==", "dev": true, "license": "MIT" }, @@ -1754,16 +1747,6 @@ "dev": true, "license": "MIT" }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -1877,9 +1860,9 @@ "license": "MIT" }, "node_modules/sass": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.85.0.tgz", - "integrity": "sha512-3ToiC1xZ1Y8aU7+CkgCI/tqyuPXEmYGJXO7H4uqp0xkLXUqp88rQQ4j1HmP37xSJLbCJPaIiv+cT1y+grssrww==", + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.88.0.tgz", + "integrity": "sha512-sF6TWQqjFvr4JILXzG4ucGOLELkESHL+I5QJhh7CNaE+Yge0SI+ehCatsXhJ7ymU1hAFcIS3/PBpjdIbXoyVbg==", "dev": true, "license": "MIT", "dependencies": { @@ -1898,9 +1881,9 @@ } }, "node_modules/sass-loader": { - "version": "16.0.4", - "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-16.0.4.tgz", - "integrity": "sha512-LavLbgbBGUt3wCiYzhuLLu65+fWXaXLmq7YxivLhEqmiupCFZ5sKUAipK3do6V80YSU0jvSxNhEdT13IXNr3rg==", + "version": "16.0.5", + "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-16.0.5.tgz", + "integrity": "sha512-oL+CMBXrj6BZ/zOq4os+UECPL+bWqt6OAC6DWS8Ln8GZRcMDjlJ4JC3FBDuHJdYaFWIdKNIBYmtZtK2MaMkNIw==", "dev": true, "license": "MIT", "dependencies": { @@ -1939,9 +1922,9 @@ } }, "node_modules/schema-utils": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.0.tgz", - "integrity": "sha512-Gf9qqc58SpCA/xdziiHz35F4GNIWYWZrEshUc/G/r5BnLph6xpKuLeoJoQuj5WfBIx/eQLf+hmVPYHaxJu7V2g==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz", + "integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1959,9 +1942,9 @@ } }, "node_modules/semver": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", - "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", "dev": true, "license": "ISC", "bin": { @@ -2078,9 +2061,9 @@ } }, "node_modules/tapable": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", - "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.2.tgz", + "integrity": "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==", "dev": true, "license": "MIT", "engines": { @@ -2088,14 +2071,14 @@ } }, "node_modules/terser": { - "version": "5.39.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.39.0.tgz", - "integrity": "sha512-LBAhFyLho16harJoWMg/nZsQYgTrg5jXOn2nCYjRUcZZEdE3qa2zb8QEDRUGVZBW4rlazf2fxkg8tztybTaqWw==", + "version": "5.39.2", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.39.2.tgz", + "integrity": "sha512-yEPUmWve+VA78bI71BW70Dh0TuV4HHd+I5SHOAfS1+QBOmvmCiiffgjR8ryyEd3KIfvPGFqoADt8LdQ6XpXIvg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.8.2", + "acorn": "^8.14.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, @@ -2156,9 +2139,9 @@ } }, "node_modules/undici-types": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", - "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" }, @@ -2193,16 +2176,6 @@ "browserslist": ">= 4.21.0" } }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -2211,9 +2184,9 @@ "license": "MIT" }, "node_modules/watchpack": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz", - "integrity": "sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw==", + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz", + "integrity": "sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==", "dev": true, "license": "MIT", "dependencies": { @@ -2225,14 +2198,15 @@ } }, "node_modules/webpack": { - "version": "5.97.1", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.97.1.tgz", - "integrity": "sha512-EksG6gFY3L1eFMROS/7Wzgrii5mBAFe4rIr3r2BTfo7bcc+DWwFZ4OJ/miOuHJO/A85HwyI4eQ0F6IKXesO7Fg==", + "version": "5.99.8", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.99.8.tgz", + "integrity": "sha512-lQ3CPiSTpfOnrEGeXDwoq5hIGzSjmwD72GdfVzF7CQAI7t47rJG9eDWvcEkEn3CUQymAElVvDg3YNTlCYj+qUQ==", "dev": true, "license": "MIT", "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", @@ -2249,9 +2223,9 @@ "loader-runner": "^4.2.0", "mime-types": "^2.1.27", "neo-async": "^2.6.2", - "schema-utils": "^3.2.0", + "schema-utils": "^4.3.2", "tapable": "^2.1.1", - "terser-webpack-plugin": "^5.3.10", + "terser-webpack-plugin": "^5.3.11", "watchpack": "^2.4.1", "webpack-sources": "^3.2.3" }, @@ -2352,59 +2326,6 @@ "node": ">=10.13.0" } }, - "node_modules/webpack/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/webpack/node_modules/ajv-keywords": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "ajv": "^6.9.1" - } - }, - "node_modules/webpack/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/webpack/node_modules/schema-utils": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", - "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/json-schema": "^7.0.8", - "ajv": "^6.12.5", - "ajv-keywords": "^3.5.2" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/bitwarden_license/src/Sso/package.json b/bitwarden_license/src/Sso/package.json index d9aefafef3..137f86680c 100644 --- a/bitwarden_license/src/Sso/package.json +++ b/bitwarden_license/src/Sso/package.json @@ -8,17 +8,17 @@ "build": "webpack" }, "dependencies": { - "bootstrap": "5.3.3", + "bootstrap": "5.3.6", "font-awesome": "4.7.0", "jquery": "3.7.1" }, "devDependencies": { "css-loader": "7.1.2", - "expose-loader": "5.0.0", + "expose-loader": "5.0.1", "mini-css-extract-plugin": "2.9.2", - "sass": "1.85.0", - "sass-loader": "16.0.4", - "webpack": "5.97.1", + "sass": "1.88.0", + "sass-loader": "16.0.5", + "webpack": "5.99.8", "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 48eda094e8..5be18116c0 100644 --- a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs @@ -1,4 +1,5 @@ using Bit.Commercial.Core.AdminConsole.Providers; +using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; @@ -7,6 +8,7 @@ using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Providers.Services; using Bit.Core.Billing.Services; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -223,31 +225,115 @@ public class RemoveOrganizationFromProviderCommandTests var stripeAdapter = sutProvider.GetDependency(); + stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, Arg.Is(options => + options.Description == string.Empty && + options.Email == organization.BillingEmail && + options.Expand[0] == "tax" && + options.Expand[1] == "tax_ids")).Returns(new Customer + { + Id = "customer_id", + Address = new Address + { + Country = "US" + } + }); + stripeAdapter.SubscriptionCreateAsync(Arg.Any()).Returns(new Subscription { Id = "subscription_id" }); - sutProvider.GetDependency() - .When(x => x.SetCreateOptions( - Arg.Is(options => - options.Customer == organization.GatewayCustomerId && - options.CollectionMethod == StripeConstants.CollectionMethod.SendInvoice && - options.DaysUntilDue == 30 && - options.Metadata["organizationId"] == organization.Id.ToString() && - options.OffSession == true && - options.ProrationBehavior == StripeConstants.ProrationBehavior.CreateProrations && - options.Items.First().Price == teamsMonthlyPlan.PasswordManager.StripeSeatPlanId && - options.Items.First().Quantity == organization.Seats) - , Arg.Any())) - .Do(x => + await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization); + + await stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Is(options => + options.Customer == organization.GatewayCustomerId && + options.CollectionMethod == StripeConstants.CollectionMethod.SendInvoice && + options.DaysUntilDue == 30 && + options.AutomaticTax.Enabled == true && + options.Metadata["organizationId"] == organization.Id.ToString() && + options.OffSession == true && + options.ProrationBehavior == StripeConstants.ProrationBehavior.CreateProrations && + options.Items.First().Price == teamsMonthlyPlan.PasswordManager.StripeSeatPlanId && + options.Items.First().Quantity == organization.Seats)); + + await sutProvider.GetDependency().Received(1) + .ScaleSeats(provider, organization.PlanType, -organization.Seats ?? 0); + + await organizationRepository.Received(1).ReplaceAsync(Arg.Is( + org => + org.BillingEmail == "a@example.com" && + org.GatewaySubscriptionId == "subscription_id" && + org.Status == OrganizationStatusType.Created)); + + await sutProvider.GetDependency().Received(1) + .DeleteAsync(providerOrganization); + + await sutProvider.GetDependency().Received(1) + .LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_Removed); + + await sutProvider.GetDependency().Received(1) + .SendProviderUpdatePaymentMethod( + organization.Id, + organization.Name, + provider.Name, + Arg.Is>(emails => emails.FirstOrDefault() == "a@example.com")); + } + + [Theory, BitAutoData] + public async Task RemoveOrganizationFromProvider_OrganizationStripeEnabled_ConsolidatedBilling_ReverseCharge_MakesCorrectInvocations( + Provider provider, + ProviderOrganization providerOrganization, + Organization organization, + SutProvider sutProvider) + { + provider.Status = ProviderStatusType.Billable; + + providerOrganization.ProviderId = provider.Id; + + organization.Status = OrganizationStatusType.Managed; + + organization.PlanType = PlanType.TeamsMonthly; + + var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); + + sutProvider.GetDependency().GetPlanOrThrow(PlanType.TeamsMonthly).Returns(teamsMonthlyPlan); + + sutProvider.GetDependency().HasConfirmedOwnersExceptAsync( + providerOrganization.OrganizationId, + [], + includeProvider: false) + .Returns(true); + + var organizationRepository = sutProvider.GetDependency(); + + organizationRepository.GetOwnerEmailAddressesById(organization.Id).Returns([ + "a@example.com", + "b@example.com" + ]); + + var stripeAdapter = sutProvider.GetDependency(); + + stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, Arg.Is(options => + options.Description == string.Empty && + options.Email == organization.BillingEmail && + options.Expand[0] == "tax" && + options.Expand[1] == "tax_ids")).Returns(new Customer { - x.Arg().AutomaticTax = new SubscriptionAutomaticTaxOptions + Id = "customer_id", + Address = new Address { - Enabled = true - }; + Country = "US" + } }); + stripeAdapter.SubscriptionCreateAsync(Arg.Any()).Returns(new Subscription + { + Id = "subscription_id" + }); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge).Returns(true); + await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization); await stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Is(options => 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 c66acfa8ce..cb8a9e8c69 100644 --- a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs @@ -6,11 +6,12 @@ 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.OrganizationFeatures.Organizations; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Models; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Services; +using Bit.Core.Billing.Providers.Services; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; @@ -717,8 +718,8 @@ public class ProviderServiceTests sutProvider.GetDependency().GetByIdAsync(provider.Id).Returns(provider); var providerOrganizationRepository = sutProvider.GetDependency(); - sutProvider.GetDependency().SignupClientAsync(organizationSignup) - .Returns((organization, null as OrganizationUser, new Collection())); + sutProvider.GetDependency().SignUpClientOrganizationAsync(organizationSignup) + .Returns(new ProviderClientOrganizationSignUpResponse(organization, new Collection())); var providerOrganization = await sutProvider.Sut.CreateOrganizationAsync(provider.Id, organizationSignup, clientOwnerEmail, user); @@ -755,8 +756,8 @@ public class ProviderServiceTests var providerOrganizationRepository = sutProvider.GetDependency(); - sutProvider.GetDependency().SignupClientAsync(organizationSignup) - .Returns((organization, null as OrganizationUser, new Collection())); + sutProvider.GetDependency().SignUpClientOrganizationAsync(organizationSignup) + .Returns(new ProviderClientOrganizationSignUpResponse(organization, new Collection())); await Assert.ThrowsAsync(() => sutProvider.Sut.CreateOrganizationAsync(provider.Id, organizationSignup, clientOwnerEmail, user)); @@ -782,8 +783,8 @@ public class ProviderServiceTests var providerOrganizationRepository = sutProvider.GetDependency(); - sutProvider.GetDependency().SignupClientAsync(organizationSignup) - .Returns((organization, null as OrganizationUser, new Collection())); + sutProvider.GetDependency().SignUpClientOrganizationAsync(organizationSignup) + .Returns(new ProviderClientOrganizationSignUpResponse(organization, new Collection())); var providerOrganization = await sutProvider.Sut.CreateOrganizationAsync(provider.Id, organizationSignup, clientOwnerEmail, user); @@ -821,8 +822,8 @@ public class ProviderServiceTests sutProvider.GetDependency().GetByIdAsync(provider.Id).Returns(provider); var providerOrganizationRepository = sutProvider.GetDependency(); - sutProvider.GetDependency().SignupClientAsync(organizationSignup) - .Returns((organization, null as OrganizationUser, defaultCollection)); + sutProvider.GetDependency().SignUpClientOrganizationAsync(organizationSignup) + .Returns(new ProviderClientOrganizationSignUpResponse(organization, defaultCollection)); var providerOrganization = await sutProvider.Sut.CreateOrganizationAsync(provider.Id, organizationSignup, clientOwnerEmail, user); diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/BusinessUnitConverterTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/BusinessUnitConverterTests.cs similarity index 98% rename from bitwarden_license/test/Commercial.Core.Test/Billing/BusinessUnitConverterTests.cs rename to bitwarden_license/test/Commercial.Core.Test/Billing/Providers/BusinessUnitConverterTests.cs index 5d2d0a2c7c..c27d990213 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Billing/BusinessUnitConverterTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/BusinessUnitConverterTests.cs @@ -1,16 +1,16 @@ #nullable enable using System.Text; -using Bit.Commercial.Core.Billing; +using Bit.Commercial.Core.Billing.Providers.Services; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing; using Bit.Core.Billing.Constants; -using Bit.Core.Billing.Entities; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Repositories; +using Bit.Core.Billing.Providers.Entities; +using Bit.Core.Billing.Providers.Repositories; using Bit.Core.Billing.Services; using Bit.Core.Entities; using Bit.Core.Enums; @@ -25,7 +25,7 @@ using NSubstitute; using Stripe; using Xunit; -namespace Bit.Commercial.Core.Test.Billing; +namespace Bit.Commercial.Core.Test.Billing.Providers; public class BusinessUnitConverterTests { diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/ProviderBillingServiceTests.cs similarity index 88% rename from bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs rename to bitwarden_license/test/Commercial.Core.Test/Billing/Providers/ProviderBillingServiceTests.cs index 1862692087..9af9a71cce 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/ProviderBillingServiceTests.cs @@ -1,7 +1,7 @@ using System.Globalization; using System.Net; -using Bit.Commercial.Core.Billing; -using Bit.Commercial.Core.Billing.Models; +using Bit.Commercial.Core.Billing.Providers.Models; +using Bit.Commercial.Core.Billing.Providers.Services; using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; @@ -10,13 +10,14 @@ using Bit.Core.AdminConsole.Models.Data.Provider; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Caches; using Bit.Core.Billing.Constants; -using Bit.Core.Billing.Entities; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Models; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Repositories; +using Bit.Core.Billing.Providers.Entities; +using Bit.Core.Billing.Providers.Models; +using Bit.Core.Billing.Providers.Repositories; using Bit.Core.Billing.Services; -using Bit.Core.Billing.Services.Contracts; +using Bit.Core.Billing.Tax.Services; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -39,7 +40,7 @@ using Customer = Stripe.Customer; using PaymentMethod = Stripe.PaymentMethod; using Subscription = Stripe.Subscription; -namespace Bit.Commercial.Core.Test.Billing; +namespace Bit.Commercial.Core.Test.Billing.Providers; [SutProviderCustomize] public class ProviderBillingServiceTests @@ -261,7 +262,7 @@ public class ProviderBillingServiceTests }; sutProvider.GetDependency().GetCustomerOrThrow(provider, Arg.Is( - options => options.Expand.FirstOrDefault() == "tax_ids")) + options => options.Expand.Contains("tax") && options.Expand.Contains("tax_ids"))) .Returns(providerCustomer); sutProvider.GetDependency().BaseServiceUri @@ -311,6 +312,91 @@ public class ProviderBillingServiceTests org => org.GatewayCustomerId == "customer_id")); } + [Theory, BitAutoData] + public async Task CreateCustomer_ForClientOrg_ReverseCharge_Succeeds( + Provider provider, + Organization organization, + SutProvider sutProvider) + { + organization.GatewayCustomerId = null; + organization.Name = "Name"; + organization.BusinessName = "BusinessName"; + + var providerCustomer = new Customer + { + Address = new Address + { + Country = "CA", + PostalCode = "12345", + Line1 = "123 Main St.", + Line2 = "Unit 4", + City = "Fake Town", + State = "Fake State" + }, + TaxIds = new StripeList + { + Data = + [ + new TaxId { Type = "TYPE", Value = "VALUE" } + ] + } + }; + + sutProvider.GetDependency().GetCustomerOrThrow(provider, Arg.Is( + options => options.Expand.Contains("tax") && options.Expand.Contains("tax_ids"))) + .Returns(providerCustomer); + + sutProvider.GetDependency().BaseServiceUri + .Returns(new Bit.Core.Settings.GlobalSettings.BaseServiceUriSettings(new Bit.Core.Settings.GlobalSettings()) + { + CloudRegion = "US" + }); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge).Returns(true); + + sutProvider.GetDependency().CustomerCreateAsync(Arg.Is( + options => + options.Address.Country == providerCustomer.Address.Country && + options.Address.PostalCode == providerCustomer.Address.PostalCode && + options.Address.Line1 == providerCustomer.Address.Line1 && + options.Address.Line2 == providerCustomer.Address.Line2 && + options.Address.City == providerCustomer.Address.City && + options.Address.State == providerCustomer.Address.State && + options.Name == organization.DisplayName() && + options.Description == $"{provider.Name} Client Organization" && + options.Email == provider.BillingEmail && + options.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Organization" && + options.InvoiceSettings.CustomFields.FirstOrDefault().Value == "Name" && + options.Metadata["region"] == "US" && + options.TaxIdData.FirstOrDefault().Type == providerCustomer.TaxIds.FirstOrDefault().Type && + options.TaxIdData.FirstOrDefault().Value == providerCustomer.TaxIds.FirstOrDefault().Value && + options.TaxExempt == StripeConstants.TaxExempt.Reverse)) + .Returns(new Customer { Id = "customer_id" }); + + await sutProvider.Sut.CreateCustomerForClientOrganization(provider, organization); + + await sutProvider.GetDependency().Received(1).CustomerCreateAsync(Arg.Is( + options => + options.Address.Country == providerCustomer.Address.Country && + options.Address.PostalCode == providerCustomer.Address.PostalCode && + options.Address.Line1 == providerCustomer.Address.Line1 && + options.Address.Line2 == providerCustomer.Address.Line2 && + options.Address.City == providerCustomer.Address.City && + options.Address.State == providerCustomer.Address.State && + options.Name == organization.DisplayName() && + options.Description == $"{provider.Name} Client Organization" && + options.Email == provider.BillingEmail && + options.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Organization" && + options.InvoiceSettings.CustomFields.FirstOrDefault().Value == "Name" && + options.Metadata["region"] == "US" && + options.TaxIdData.FirstOrDefault().Type == providerCustomer.TaxIds.FirstOrDefault().Type && + options.TaxIdData.FirstOrDefault().Value == providerCustomer.TaxIds.FirstOrDefault().Value)); + + await sutProvider.GetDependency().Received(1).ReplaceAsync(Arg.Is( + org => org.GatewayCustomerId == "customer_id")); + } + #endregion #region GenerateClientInvoiceReport @@ -1181,6 +1267,62 @@ public class ProviderBillingServiceTests Assert.Equivalent(expected, actual); } + [Theory, BitAutoData] + public async Task SetupCustomer_WithCard_ReverseCharge_Success( + SutProvider sutProvider, + Provider provider, + TaxInfo taxInfo) + { + provider.Name = "MSP"; + + sutProvider.GetDependency() + .GetStripeTaxCode(Arg.Is( + p => p == taxInfo.BillingAddressCountry), + Arg.Is(p => p == taxInfo.TaxIdNumber)) + .Returns(taxInfo.TaxIdType); + + taxInfo.BillingAddressCountry = "AD"; + + var stripeAdapter = sutProvider.GetDependency(); + + var expected = new Customer + { + Id = "customer_id", + Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported } + }; + + var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.Card, "token"); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge).Returns(true); + + stripeAdapter.CustomerCreateAsync(Arg.Is(o => + o.Address.Country == taxInfo.BillingAddressCountry && + o.Address.PostalCode == taxInfo.BillingAddressPostalCode && + o.Address.Line1 == taxInfo.BillingAddressLine1 && + o.Address.Line2 == taxInfo.BillingAddressLine2 && + o.Address.City == taxInfo.BillingAddressCity && + o.Address.State == taxInfo.BillingAddressState && + o.Description == WebUtility.HtmlDecode(provider.BusinessName) && + o.Email == provider.BillingEmail && + o.PaymentMethod == tokenizedPaymentSource.Token && + o.InvoiceSettings.DefaultPaymentMethod == tokenizedPaymentSource.Token && + o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" && + o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" && + o.Metadata["region"] == "" && + o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType && + o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber && + o.TaxExempt == StripeConstants.TaxExempt.Reverse)) + .Returns(expected); + + var actual = await sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource); + + Assert.Equivalent(expected, actual); + } + [Theory, BitAutoData] public async Task SetupCustomer_Throws_BadRequestException_WhenTaxIdIsInvalid( SutProvider sutProvider, @@ -1306,7 +1448,7 @@ public class ProviderBillingServiceTests .Returns(new Customer { Id = "customer_id", - Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported } + Address = new Address { Country = "US" } }); var providerPlans = new List @@ -1358,7 +1500,7 @@ public class ProviderBillingServiceTests var customer = new Customer { Id = "customer_id", - Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported } + Address = new Address { Country = "US" } }; sutProvider.GetDependency() .GetCustomerOrThrow( @@ -1398,19 +1540,6 @@ public class ProviderBillingServiceTests var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active }; - sutProvider.GetDependency() - .When(x => x.SetCreateOptions( - Arg.Is(options => - options.Customer == "customer_id") - , Arg.Is(p => p == customer))) - .Do(x => - { - x.Arg().AutomaticTax = new SubscriptionAutomaticTaxOptions - { - Enabled = true - }; - }); - sutProvider.GetDependency().SubscriptionCreateAsync(Arg.Is( sub => sub.AutomaticTax.Enabled == true && @@ -1442,11 +1571,11 @@ public class ProviderBillingServiceTests var customer = new Customer { Id = "customer_id", + Address = new Address { Country = "US" }, InvoiceSettings = new CustomerInvoiceSettings { DefaultPaymentMethodId = "pm_123" - }, - Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported } + } }; sutProvider.GetDependency() @@ -1487,19 +1616,6 @@ public class ProviderBillingServiceTests var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active }; - sutProvider.GetDependency() - .When(x => x.SetCreateOptions( - Arg.Is(options => - options.Customer == "customer_id") - , Arg.Is(p => p == customer))) - .Do(x => - { - x.Arg().AutomaticTax = new SubscriptionAutomaticTaxOptions - { - Enabled = true - }; - }); - sutProvider.GetDependency() .IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true); @@ -1535,9 +1651,9 @@ public class ProviderBillingServiceTests var customer = new Customer { Id = "customer_id", + Address = new Address { Country = "US" }, InvoiceSettings = new CustomerInvoiceSettings(), - Metadata = new Dictionary(), - Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported } + Metadata = new Dictionary() }; sutProvider.GetDependency() @@ -1578,19 +1694,6 @@ public class ProviderBillingServiceTests var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active }; - sutProvider.GetDependency() - .When(x => x.SetCreateOptions( - Arg.Is(options => - options.Customer == "customer_id") - , Arg.Is(p => p == customer))) - .Do(x => - { - x.Arg().AutomaticTax = new SubscriptionAutomaticTaxOptions - { - Enabled = true - }; - }); - sutProvider.GetDependency() .IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true); @@ -1645,12 +1748,15 @@ public class ProviderBillingServiceTests var customer = new Customer { Id = "customer_id", + Address = new Address + { + Country = "US" + }, InvoiceSettings = new CustomerInvoiceSettings(), Metadata = new Dictionary { ["btCustomerId"] = "braintree_customer_id" - }, - Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported } + } }; sutProvider.GetDependency() @@ -1691,22 +1797,92 @@ public class ProviderBillingServiceTests var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active }; - sutProvider.GetDependency() - .When(x => x.SetCreateOptions( - Arg.Is(options => - options.Customer == "customer_id") - , Arg.Is(p => p == customer))) - .Do(x => + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true); + + sutProvider.GetDependency().SubscriptionCreateAsync(Arg.Is( + sub => + sub.AutomaticTax.Enabled == true && + sub.CollectionMethod == StripeConstants.CollectionMethod.ChargeAutomatically && + sub.Customer == "customer_id" && + sub.DaysUntilDue == null && + sub.Items.Count == 2 && + sub.Items.ElementAt(0).Price == ProviderPriceAdapter.MSP.Active.Teams && + sub.Items.ElementAt(0).Quantity == 100 && + sub.Items.ElementAt(1).Price == ProviderPriceAdapter.MSP.Active.Enterprise && + sub.Items.ElementAt(1).Quantity == 100 && + sub.Metadata["providerId"] == provider.Id.ToString() && + sub.OffSession == true && + sub.ProrationBehavior == StripeConstants.ProrationBehavior.CreateProrations && + sub.TrialPeriodDays == 14)).Returns(expected); + + var actual = await sutProvider.Sut.SetupSubscription(provider); + + Assert.Equivalent(expected, actual); + } + + [Theory, BitAutoData] + public async Task SetupSubscription_ReverseCharge_Succeeds( + SutProvider sutProvider, + Provider provider) + { + provider.Type = ProviderType.Msp; + provider.GatewaySubscriptionId = null; + + var customer = new Customer + { + Id = "customer_id", + Address = new Address { Country = "CA" }, + InvoiceSettings = new CustomerInvoiceSettings { - x.Arg().AutomaticTax = new SubscriptionAutomaticTaxOptions - { - Enabled = true - }; - }); + DefaultPaymentMethodId = "pm_123" + } + }; + + sutProvider.GetDependency() + .GetCustomerOrThrow( + provider, + Arg.Is(p => p.Expand.Contains("tax") || p.Expand.Contains("tax_ids"))).Returns(customer); + + var providerPlans = new List + { + new() + { + Id = Guid.NewGuid(), + ProviderId = provider.Id, + PlanType = PlanType.TeamsMonthly, + SeatMinimum = 100, + PurchasedSeats = 0, + AllocatedSeats = 0 + }, + new() + { + Id = Guid.NewGuid(), + ProviderId = provider.Id, + PlanType = PlanType.EnterpriseMonthly, + SeatMinimum = 100, + PurchasedSeats = 0, + AllocatedSeats = 0 + } + }; + + foreach (var plan in providerPlans) + { + sutProvider.GetDependency().GetPlanOrThrow(plan.PlanType) + .Returns(StaticStore.GetPlan(plan.PlanType)); + } + + sutProvider.GetDependency().GetByProviderId(provider.Id) + .Returns(providerPlans); + + var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active }; sutProvider.GetDependency() .IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true); + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge).Returns(true); + sutProvider.GetDependency().SubscriptionCreateAsync(Arg.Is( sub => sub.AutomaticTax.Enabled == true && diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderPriceAdapterTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/ProviderPriceAdapterTests.cs similarity index 97% rename from bitwarden_license/test/Commercial.Core.Test/Billing/ProviderPriceAdapterTests.cs rename to bitwarden_license/test/Commercial.Core.Test/Billing/Providers/ProviderPriceAdapterTests.cs index 9ecb4b0511..3087d5761c 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderPriceAdapterTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/ProviderPriceAdapterTests.cs @@ -1,11 +1,11 @@ -using Bit.Commercial.Core.Billing; +using Bit.Commercial.Core.Billing.Providers.Services; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.Billing.Enums; using Stripe; using Xunit; -namespace Bit.Commercial.Core.Test.Billing; +namespace Bit.Commercial.Core.Test.Billing.Providers; public class ProviderPriceAdapterTests { diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/TaxServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/Tax/TaxServiceTests.cs similarity index 98% rename from bitwarden_license/test/Commercial.Core.Test/Billing/TaxServiceTests.cs rename to bitwarden_license/test/Commercial.Core.Test/Billing/Tax/TaxServiceTests.cs index 3995fb9de6..f3164a14e0 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Billing/TaxServiceTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/Tax/TaxServiceTests.cs @@ -1,9 +1,9 @@ -using Bit.Core.Billing.Services; +using Bit.Core.Billing.Tax.Services.Implementations; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Xunit; -namespace Bit.Commercial.Core.Test.Billing; +namespace Bit.Commercial.Core.Test.Billing.Tax; [SutProviderCustomize] public class TaxServiceTests diff --git a/bitwarden_license/test/Commercial.Core.Test/SecretsManager/Queries/Projects/MaxProjectsQueryTests.cs b/bitwarden_license/test/Commercial.Core.Test/SecretsManager/Queries/Projects/MaxProjectsQueryTests.cs index 158463fcfa..16ae8f7f2c 100644 --- a/bitwarden_license/test/Commercial.Core.Test/SecretsManager/Queries/Projects/MaxProjectsQueryTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/SecretsManager/Queries/Projects/MaxProjectsQueryTests.cs @@ -1,14 +1,10 @@ -using System.Security.Claims; -using Bit.Commercial.Core.SecretsManager.Queries.Projects; +using Bit.Commercial.Core.SecretsManager.Queries.Projects; using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Licenses; using Bit.Core.Billing.Pricing; using Bit.Core.Exceptions; -using Bit.Core.Models.Business; using Bit.Core.Repositories; using Bit.Core.SecretsManager.Repositories; -using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Utilities; using Bit.Test.Common.AutoFixture; @@ -22,11 +18,26 @@ namespace Bit.Commercial.Core.Test.SecretsManager.Queries.Projects; [SutProviderCustomize] public class MaxProjectsQueryTests { + [Theory] + [BitAutoData] + public async Task GetByOrgIdAsync_SelfHosted_ReturnsNulls(SutProvider sutProvider, + Guid organizationId) + { + sutProvider.GetDependency().SelfHosted.Returns(true); + + var (max, overMax) = await sutProvider.Sut.GetByOrgIdAsync(organizationId, 1); + + Assert.Null(max); + Assert.Null(overMax); + } + [Theory] [BitAutoData] public async Task GetByOrgIdAsync_OrganizationIsNull_ThrowsNotFound(SutProvider sutProvider, Guid organizationId) { + sutProvider.GetDependency().SelfHosted.Returns(false); + sutProvider.GetDependency().GetByIdAsync(default).ReturnsNull(); await Assert.ThrowsAsync(async () => await sutProvider.Sut.GetByOrgIdAsync(organizationId, 1)); @@ -35,54 +46,6 @@ public class MaxProjectsQueryTests .GetProjectCountByOrganizationIdAsync(organizationId); } - [Theory] - [BitAutoData(PlanType.FamiliesAnnually2019)] - [BitAutoData(PlanType.Custom)] - [BitAutoData(PlanType.FamiliesAnnually)] - public async Task GetByOrgIdAsync_Cloud_SmPlanIsNull_ThrowsBadRequest(PlanType planType, - SutProvider sutProvider, Organization organization) - { - organization.PlanType = planType; - sutProvider.GetDependency() - .GetByIdAsync(organization.Id) - .Returns(organization); - - sutProvider.GetDependency().SelfHosted.Returns(false); - var plan = StaticStore.GetPlan(planType); - sutProvider.GetDependency().GetPlan(organization.PlanType).Returns(plan); - - await Assert.ThrowsAsync( - async () => await sutProvider.Sut.GetByOrgIdAsync(organization.Id, 1)); - - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .GetProjectCountByOrganizationIdAsync(organization.Id); - } - - [Theory] - [BitAutoData] - public async Task GetByOrgIdAsync_SelfHosted_NoMaxProjectsClaim_ThrowsBadRequest( - SutProvider sutProvider, Organization organization) - { - sutProvider.GetDependency() - .GetByIdAsync(organization.Id) - .Returns(organization); - - sutProvider.GetDependency().SelfHosted.Returns(true); - - var license = new OrganizationLicense(); - var claimsPrincipal = new ClaimsPrincipal(); - sutProvider.GetDependency().ReadOrganizationLicenseAsync(organization).Returns(license); - sutProvider.GetDependency().GetClaimsPrincipalFromLicense(license).Returns(claimsPrincipal); - - await Assert.ThrowsAsync( - async () => await sutProvider.Sut.GetByOrgIdAsync(organization.Id, 1)); - - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .GetProjectCountByOrganizationIdAsync(organization.Id); - } - [Theory] [BitAutoData(PlanType.TeamsMonthly2019)] [BitAutoData(PlanType.TeamsMonthly2020)] @@ -97,57 +60,16 @@ public class MaxProjectsQueryTests [BitAutoData(PlanType.EnterpriseAnnually2019)] [BitAutoData(PlanType.EnterpriseAnnually2020)] [BitAutoData(PlanType.EnterpriseAnnually)] - public async Task GetByOrgIdAsync_Cloud_SmNoneFreePlans_ReturnsNull(PlanType planType, + public async Task GetByOrgIdAsync_SmNoneFreePlans_ReturnsNull(PlanType planType, SutProvider sutProvider, Organization organization) { - organization.PlanType = planType; - sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); - sutProvider.GetDependency().SelfHosted.Returns(false); - var plan = StaticStore.GetPlan(planType); - sutProvider.GetDependency().GetPlan(organization.PlanType).Returns(plan); - var (limit, overLimit) = await sutProvider.Sut.GetByOrgIdAsync(organization.Id, 1); - - Assert.Null(limit); - Assert.Null(overLimit); - - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() - .GetProjectCountByOrganizationIdAsync(organization.Id); - } - - [Theory] - [BitAutoData(PlanType.TeamsMonthly2019)] - [BitAutoData(PlanType.TeamsMonthly2020)] - [BitAutoData(PlanType.TeamsMonthly)] - [BitAutoData(PlanType.TeamsAnnually2019)] - [BitAutoData(PlanType.TeamsAnnually2020)] - [BitAutoData(PlanType.TeamsAnnually)] - [BitAutoData(PlanType.TeamsStarter)] - [BitAutoData(PlanType.EnterpriseMonthly2019)] - [BitAutoData(PlanType.EnterpriseMonthly2020)] - [BitAutoData(PlanType.EnterpriseMonthly)] - [BitAutoData(PlanType.EnterpriseAnnually2019)] - [BitAutoData(PlanType.EnterpriseAnnually2020)] - [BitAutoData(PlanType.EnterpriseAnnually)] - public async Task GetByOrgIdAsync_SelfHosted_SmNoneFreePlans_ReturnsNull(PlanType planType, - SutProvider sutProvider, Organization organization) - { organization.PlanType = planType; sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); - sutProvider.GetDependency().SelfHosted.Returns(true); - var license = new OrganizationLicense(); - var plan = StaticStore.GetPlan(planType); - var claims = new List - { - new (nameof(OrganizationLicenseConstants.PlanType), organization.PlanType.ToString()), - new (nameof(OrganizationLicenseConstants.SmMaxProjects), plan.SecretsManager.MaxProjects.ToString()) - }; - var identity = new ClaimsIdentity(claims, "TestAuthenticationType"); - var claimsPrincipal = new ClaimsPrincipal(identity); - sutProvider.GetDependency().ReadOrganizationLicenseAsync(organization).Returns(license); - sutProvider.GetDependency().GetClaimsPrincipalFromLicense(license).Returns(claimsPrincipal); + sutProvider.GetDependency().GetPlan(organization.PlanType) + .Returns(StaticStore.GetPlan(organization.PlanType)); var (limit, overLimit) = await sutProvider.Sut.GetByOrgIdAsync(organization.Id, 1); @@ -183,7 +105,7 @@ public class MaxProjectsQueryTests [BitAutoData(PlanType.Free, 3, 4, true)] [BitAutoData(PlanType.Free, 4, 4, true)] [BitAutoData(PlanType.Free, 40, 4, true)] - public async Task GetByOrgIdAsync_Cloud_SmFreePlan__Success(PlanType planType, int projects, int projectsToAdd, bool expectedOverMax, + public async Task GetByOrgIdAsync_SmFreePlan__Success(PlanType planType, int projects, int projectsToAdd, bool expectedOverMax, SutProvider sutProvider, Organization organization) { organization.PlanType = planType; @@ -191,66 +113,8 @@ public class MaxProjectsQueryTests sutProvider.GetDependency().GetProjectCountByOrganizationIdAsync(organization.Id) .Returns(projects); - sutProvider.GetDependency().SelfHosted.Returns(false); - var plan = StaticStore.GetPlan(planType); - sutProvider.GetDependency().GetPlan(organization.PlanType).Returns(plan); - - var (max, overMax) = await sutProvider.Sut.GetByOrgIdAsync(organization.Id, projectsToAdd); - - Assert.NotNull(max); - Assert.NotNull(overMax); - Assert.Equal(3, max.Value); - Assert.Equal(expectedOverMax, overMax); - - await sutProvider.GetDependency().Received(1) - .GetProjectCountByOrganizationIdAsync(organization.Id); - } - - [Theory] - [BitAutoData(PlanType.Free, 0, 1, false)] - [BitAutoData(PlanType.Free, 1, 1, false)] - [BitAutoData(PlanType.Free, 2, 1, false)] - [BitAutoData(PlanType.Free, 3, 1, true)] - [BitAutoData(PlanType.Free, 4, 1, true)] - [BitAutoData(PlanType.Free, 40, 1, true)] - [BitAutoData(PlanType.Free, 0, 2, false)] - [BitAutoData(PlanType.Free, 1, 2, false)] - [BitAutoData(PlanType.Free, 2, 2, true)] - [BitAutoData(PlanType.Free, 3, 2, true)] - [BitAutoData(PlanType.Free, 4, 2, true)] - [BitAutoData(PlanType.Free, 40, 2, true)] - [BitAutoData(PlanType.Free, 0, 3, false)] - [BitAutoData(PlanType.Free, 1, 3, true)] - [BitAutoData(PlanType.Free, 2, 3, true)] - [BitAutoData(PlanType.Free, 3, 3, true)] - [BitAutoData(PlanType.Free, 4, 3, true)] - [BitAutoData(PlanType.Free, 40, 3, true)] - [BitAutoData(PlanType.Free, 0, 4, true)] - [BitAutoData(PlanType.Free, 1, 4, true)] - [BitAutoData(PlanType.Free, 2, 4, true)] - [BitAutoData(PlanType.Free, 3, 4, true)] - [BitAutoData(PlanType.Free, 4, 4, true)] - [BitAutoData(PlanType.Free, 40, 4, true)] - public async Task GetByOrgIdAsync_SelfHosted_SmFreePlan__Success(PlanType planType, int projects, int projectsToAdd, bool expectedOverMax, - SutProvider sutProvider, Organization organization) - { - organization.PlanType = planType; - sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); - sutProvider.GetDependency().GetProjectCountByOrganizationIdAsync(organization.Id) - .Returns(projects); - sutProvider.GetDependency().SelfHosted.Returns(true); - - var license = new OrganizationLicense(); - var plan = StaticStore.GetPlan(planType); - var claims = new List - { - new (nameof(OrganizationLicenseConstants.PlanType), organization.PlanType.ToString()), - new (nameof(OrganizationLicenseConstants.SmMaxProjects), plan.SecretsManager.MaxProjects.ToString()) - }; - var identity = new ClaimsIdentity(claims, "TestAuthenticationType"); - var claimsPrincipal = new ClaimsPrincipal(identity); - sutProvider.GetDependency().ReadOrganizationLicenseAsync(organization).Returns(license); - sutProvider.GetDependency().GetClaimsPrincipalFromLicense(license).Returns(claimsPrincipal); + sutProvider.GetDependency().GetPlan(organization.PlanType) + .Returns(StaticStore.GetPlan(organization.PlanType)); var (max, overMax) = await sutProvider.Sut.GetByOrgIdAsync(organization.Id, projectsToAdd); diff --git a/dev/.env.example b/dev/.env.example index f0aed83a59..7f049728d7 100644 --- a/dev/.env.example +++ b/dev/.env.example @@ -11,6 +11,7 @@ MAILCATCHER_PORT=1080 # Alternative databases POSTGRES_PASSWORD=SET_A_PASSWORD_HERE_123 MYSQL_ROOT_PASSWORD=SET_A_PASSWORD_HERE_123 +MARIADB_ROOT_PASSWORD=SET_A_PASSWORD_HERE_123 # IdP configuration # Complete using the values from the Manage SSO page in the web vault diff --git a/dev/docker-compose.yml b/dev/docker-compose.yml index a21f1ac6b8..601989a473 100644 --- a/dev/docker-compose.yml +++ b/dev/docker-compose.yml @@ -70,6 +70,20 @@ services: profiles: - mysql + mariadb: + image: mariadb:10 + ports: + - 4306:3306 + environment: + MARIADB_USER: maria + MARIADB_PASSWORD: ${MARIADB_ROOT_PASSWORD} + MARIADB_DATABASE: vault_dev + MARIADB_RANDOM_ROOT_PASSWORD: "true" + volumes: + - mariadb_dev_data:/var/lib/mysql + profiles: + - mariadb + idp: image: kenchan0130/simplesamlphp:1.19.8 container_name: idp diff --git a/dev/migrate.ps1 b/dev/migrate.ps1 index d129af4e6e..287a2d18ee 100755 --- a/dev/migrate.ps1 +++ b/dev/migrate.ps1 @@ -5,6 +5,7 @@ param( [switch]$all, [switch]$postgres, [switch]$mysql, + [switch]$mariadb, [switch]$mssql, [switch]$sqlite, [switch]$selfhost, @@ -15,11 +16,15 @@ param( $ErrorActionPreference = "Stop" $currentDir = Get-Location -if (!$all -and !$postgres -and !$mysql -and !$sqlite) { +function Get-IsEFDatabase { + return $postgres -or $mysql -or $mariadb -or $sqlite; +} + +if (!$all -and !$(Get-IsEFDatabase)) { $mssql = $true; } -if ($all -or $postgres -or $mysql -or $sqlite) { +if ($all -or $(Get-IsEFDatabase)) { dotnet ef *> $null if ($LASTEXITCODE -ne 0) { Write-Host "Entity Framework Core tools were not found in the dotnet global tools. Attempting to install" @@ -60,9 +65,12 @@ if ($all -or $mssql) { } Foreach ($item in @( - @($mysql, "MySQL", "MySqlMigrations", "mySql", 2), @($postgres, "PostgreSQL", "PostgresMigrations", "postgreSql", 0), - @($sqlite, "SQLite", "SqliteMigrations", "sqlite", 1) + @($sqlite, "SQLite", "SqliteMigrations", "sqlite", 1), + @($mysql, "MySQL", "MySqlMigrations", "mySql", 2), + # MariaDB shares the MySQL connection string in the server config so they are mutually exclusive in that context. + # However they can still be run independently for integration tests. + @($mariadb, "MariaDB", "MySqlMigrations", "mySql", 3) )) { if (!$item[0] -and !$all) { continue diff --git a/dev/servicebusemulator_config.json b/dev/servicebusemulator_config.json index 073a44618f..b107bc6190 100644 --- a/dev/servicebusemulator_config.json +++ b/dev/servicebusemulator_config.json @@ -33,6 +33,39 @@ "Name": "events-webhook-subscription" } ] + }, + { + "Name": "event-integrations", + "Subscriptions": [ + { + "Name": "integration-slack-subscription", + "Rules": [ + { + "Name": "slack-integration-filter", + "Properties": { + "FilterType": "Correlation", + "CorrelationFilter": { + "Label": "slack" + } + } + } + ] + }, + { + "Name": "integration-webhook-subscription", + "Rules": [ + { + "Name": "webhook-integration-filter", + "Properties": { + "FilterType": "Correlation", + "CorrelationFilter": { + "Label": "webhook" + } + } + } + ] + } + ] } ] } diff --git a/perf/load/helpers/auth.js b/perf/load/helpers/auth.js index 1e225d5e49..7d7fd50678 100644 --- a/perf/load/helpers/auth.js +++ b/perf/load/helpers/auth.js @@ -40,8 +40,6 @@ export function authenticate( payload["deviceName"] = "chrome"; payload["username"] = username; payload["password"] = password; - - params.headers["Auth-Email"] = encoding.b64encode(username); } else { payload["scope"] = "api.organization"; payload["grant_type"] = "client_credentials"; diff --git a/src/Admin/.dockerignore b/src/Admin/.dockerignore deleted file mode 100644 index fc12f25146..0000000000 --- a/src/Admin/.dockerignore +++ /dev/null @@ -1,4 +0,0 @@ -* -!obj/build-output/publish/* -!obj/Docker/empty/ -!entrypoint.sh diff --git a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs index cb163f400a..6d38a77d8b 100644 --- a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs @@ -11,8 +11,7 @@ using Bit.Core.AdminConsole.Repositories; 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.Billing.Providers.Services; using Bit.Core.Enums; using Bit.Core.Models.OrganizationConnectionConfigs; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; @@ -20,9 +19,6 @@ using Bit.Core.Repositories; using Bit.Core.SecretsManager.Repositories; using Bit.Core.Services; using Bit.Core.Settings; -using Bit.Core.Tools.Enums; -using Bit.Core.Tools.Models.Business; -using Bit.Core.Tools.Services; using Bit.Core.Utilities; using Bit.Core.Vault.Repositories; using Microsoft.AspNetCore.Authorization; @@ -45,12 +41,9 @@ public class OrganizationsController : Controller private readonly IPaymentService _paymentService; private readonly IApplicationCacheService _applicationCacheService; private readonly GlobalSettings _globalSettings; - private readonly IReferenceEventService _referenceEventService; - private readonly IUserService _userService; private readonly IProviderRepository _providerRepository; private readonly ILogger _logger; private readonly IAccessControlService _accessControlService; - private readonly ICurrentContext _currentContext; private readonly ISecretRepository _secretRepository; private readonly IProjectRepository _projectRepository; private readonly IServiceAccountRepository _serviceAccountRepository; @@ -73,12 +66,9 @@ public class OrganizationsController : Controller IPaymentService paymentService, IApplicationCacheService applicationCacheService, GlobalSettings globalSettings, - IReferenceEventService referenceEventService, - IUserService userService, IProviderRepository providerRepository, ILogger logger, IAccessControlService accessControlService, - ICurrentContext currentContext, ISecretRepository secretRepository, IProjectRepository projectRepository, IServiceAccountRepository serviceAccountRepository, @@ -100,12 +90,9 @@ public class OrganizationsController : Controller _paymentService = paymentService; _applicationCacheService = applicationCacheService; _globalSettings = globalSettings; - _referenceEventService = referenceEventService; - _userService = userService; _providerRepository = providerRepository; _logger = logger; _accessControlService = accessControlService; - _currentContext = currentContext; _secretRepository = secretRepository; _projectRepository = projectRepository; _serviceAccountRepository = serviceAccountRepository; @@ -272,11 +259,6 @@ public class OrganizationsController : Controller await _organizationRepository.ReplaceAsync(organization); await _applicationCacheService.UpsertOrganizationAbilityAsync(organization); - await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.OrganizationEditedByAdmin, organization, _currentContext) - { - EventRaisedByUser = _userService.GetUserName(User), - SalesAssistedTrialStarted = model.SalesAssistedTrialStarted, - }); return RedirectToAction("Edit", new { id }); } @@ -462,6 +444,7 @@ public class OrganizationsController : Controller organization.UsersGetPremium = model.UsersGetPremium; organization.UseSecretsManager = model.UseSecretsManager; organization.UseRiskInsights = model.UseRiskInsights; + organization.UseOrganizationDomains = model.UseOrganizationDomains; organization.UseAdminSponsoredFamilies = model.UseAdminSponsoredFamilies; //secrets diff --git a/src/Admin/AdminConsole/Controllers/ProvidersController.cs b/src/Admin/AdminConsole/Controllers/ProvidersController.cs index dd4332358c..b4abf81ee2 100644 --- a/src/Admin/AdminConsole/Controllers/ProvidersController.cs +++ b/src/Admin/AdminConsole/Controllers/ProvidersController.cs @@ -10,13 +10,13 @@ using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; using Bit.Core.Billing.Constants; -using Bit.Core.Billing.Entities; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Repositories; -using Bit.Core.Billing.Services; -using Bit.Core.Billing.Services.Contracts; +using Bit.Core.Billing.Providers.Entities; +using Bit.Core.Billing.Providers.Models; +using Bit.Core.Billing.Providers.Repositories; +using Bit.Core.Billing.Providers.Services; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; diff --git a/src/Admin/AdminConsole/Models/OrganizationEditModel.cs b/src/Admin/AdminConsole/Models/OrganizationEditModel.cs index 6af6c1b50a..c79124688e 100644 --- a/src/Admin/AdminConsole/Models/OrganizationEditModel.cs +++ b/src/Admin/AdminConsole/Models/OrganizationEditModel.cs @@ -102,7 +102,7 @@ public class OrganizationEditModel : OrganizationViewModel MaxAutoscaleSmSeats = org.MaxAutoscaleSmSeats; SmServiceAccounts = org.SmServiceAccounts; MaxAutoscaleSmServiceAccounts = org.MaxAutoscaleSmServiceAccounts; - + UseOrganizationDomains = org.UseOrganizationDomains; _plans = plans; } @@ -186,6 +186,8 @@ public class OrganizationEditModel : OrganizationViewModel public int? SmServiceAccounts { get; set; } [Display(Name = "Max Autoscale Machine Accounts")] public int? MaxAutoscaleSmServiceAccounts { get; set; } + [Display(Name = "Use Organization Domains")] + public bool UseOrganizationDomains { get; set; } /** * Creates a Plan[] object for use in Javascript @@ -215,6 +217,7 @@ public class OrganizationEditModel : OrganizationViewModel Has2fa = p.Has2fa, HasApi = p.HasApi, HasSso = p.HasSso, + HasOrganizationDomains = p.HasOrganizationDomains, HasKeyConnector = p.HasKeyConnector, HasScim = p.HasScim, HasResetPassword = p.HasResetPassword, @@ -315,6 +318,7 @@ public class OrganizationEditModel : OrganizationViewModel existingOrganization.MaxAutoscaleSmSeats = MaxAutoscaleSmSeats; existingOrganization.SmServiceAccounts = SmServiceAccounts; existingOrganization.MaxAutoscaleSmServiceAccounts = MaxAutoscaleSmServiceAccounts; + existingOrganization.UseOrganizationDomains = UseOrganizationDomains; return existingOrganization; } } diff --git a/src/Admin/AdminConsole/Models/OrganizationViewModel.cs b/src/Admin/AdminConsole/Models/OrganizationViewModel.cs index 69486bdcd2..412b17b3d7 100644 --- a/src/Admin/AdminConsole/Models/OrganizationViewModel.cs +++ b/src/Admin/AdminConsole/Models/OrganizationViewModel.cs @@ -44,6 +44,8 @@ public class OrganizationViewModel orgUsers .Where(u => u.Type == OrganizationUserType.Admin && u.Status == organizationUserStatus) .Select(u => u.Email)); + OwnersDetails = orgUsers.Where(u => u.Type == OrganizationUserType.Owner && u.Status == organizationUserStatus); + AdminsDetails = orgUsers.Where(u => u.Type == OrganizationUserType.Admin && u.Status == organizationUserStatus); SecretsCount = secretsCount; ProjectsCount = projectCount; ServiceAccountsCount = serviceAccountsCount; @@ -70,4 +72,6 @@ public class OrganizationViewModel public int OccupiedSmSeatsCount { get; set; } public bool UseSecretsManager => Organization.UseSecretsManager; public bool UseRiskInsights => Organization.UseRiskInsights; + public IEnumerable OwnersDetails { get; set; } + public IEnumerable AdminsDetails { get; set; } } diff --git a/src/Admin/AdminConsole/Models/ProviderEditModel.cs b/src/Admin/AdminConsole/Models/ProviderEditModel.cs index 44eebb8d7d..de9e25fa6f 100644 --- a/src/Admin/AdminConsole/Models/ProviderEditModel.cs +++ b/src/Admin/AdminConsole/Models/ProviderEditModel.cs @@ -2,8 +2,8 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Models.Data.Provider; -using Bit.Core.Billing.Entities; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Providers.Entities; using Bit.Core.Enums; using Bit.SharedWeb.Utilities; diff --git a/src/Admin/AdminConsole/Models/ProviderViewModel.cs b/src/Admin/AdminConsole/Models/ProviderViewModel.cs index bcb96df006..e1277f8e87 100644 --- a/src/Admin/AdminConsole/Models/ProviderViewModel.cs +++ b/src/Admin/AdminConsole/Models/ProviderViewModel.cs @@ -2,8 +2,8 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Models.Data.Provider; -using Bit.Core.Billing.Entities; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Providers.Entities; namespace Bit.Admin.AdminConsole.Models; @@ -19,7 +19,7 @@ public class ProviderViewModel { Provider = provider; UserCount = providerUsers.Count(); - ProviderAdmins = providerUsers.Where(u => u.Type == ProviderUserType.ProviderAdmin); + ProviderUsers = providerUsers; ProviderOrganizations = organizations.Where(o => o.ProviderId == provider.Id); if (Provider.Type == ProviderType.Msp) @@ -61,7 +61,7 @@ public class ProviderViewModel public int UserCount { get; set; } public Provider Provider { get; set; } - public IEnumerable ProviderAdmins { get; set; } + public IEnumerable ProviderUsers { get; set; } public IEnumerable ProviderOrganizations { get; set; } public List ProviderPlanViewModels { get; set; } = []; } diff --git a/src/Admin/AdminConsole/Views/Organizations/Edit.cshtml b/src/Admin/AdminConsole/Views/Organizations/Edit.cshtml index f240cb192f..690ee3d778 100644 --- a/src/Admin/AdminConsole/Views/Organizations/Edit.cshtml +++ b/src/Admin/AdminConsole/Views/Organizations/Edit.cshtml @@ -1,13 +1,9 @@ @using Bit.Admin.Enums; @using Bit.Admin.Models -@using Bit.Core @using Bit.Core.AdminConsole.Enums.Provider @using Bit.Core.Billing.Enums @using Bit.Core.Billing.Extensions -@using Bit.Core.Services -@using Microsoft.AspNetCore.Mvc.TagHelpers @inject Bit.Admin.Services.IAccessControlService AccessControlService -@inject IFeatureService FeatureService @model OrganizationEditModel @{ ViewData["Title"] = (Model.Provider != null ? "Client " : string.Empty) + "Organization: " + Model.Name; @@ -19,12 +15,10 @@ var canDelete = AccessControlService.UserHasPermission(Permission.Org_Delete); var canUnlinkFromProvider = AccessControlService.UserHasPermission(Permission.Provider_Edit); - var canConvertToBusinessUnit = - FeatureService.IsEnabled(FeatureFlagKeys.PM18770_EnableOrganizationBusinessUnitConversion) && - AccessControlService.UserHasPermission(Permission.Org_Billing_ConvertToBusinessUnit) && - Model.Organization.PlanType.GetProductTier() == ProductTierType.Enterprise && - !string.IsNullOrEmpty(Model.Organization.GatewaySubscriptionId) && - Model.Provider is null or { Type: ProviderType.BusinessUnit, Status: ProviderStatusType.Pending }; + var canConvertToBusinessUnit = AccessControlService.UserHasPermission(Permission.Org_Billing_ConvertToBusinessUnit) && + Model.Organization.PlanType.GetProductTier() == ProductTierType.Enterprise && + !string.IsNullOrEmpty(Model.Organization.GatewaySubscriptionId) && + Model.Provider is null or { Type: ProviderType.BusinessUnit, Status: ProviderStatusType.Pending }; } @section Scripts { diff --git a/src/Admin/AdminConsole/Views/Organizations/_ViewInformation.cshtml b/src/Admin/AdminConsole/Views/Organizations/_ViewInformation.cshtml index a0d421235d..9b2f7d69f8 100644 --- a/src/Admin/AdminConsole/Views/Organizations/_ViewInformation.cshtml +++ b/src/Admin/AdminConsole/Views/Organizations/_ViewInformation.cshtml @@ -19,12 +19,6 @@ @Model.UserConfirmedCount) -
Owners
-
@(string.IsNullOrWhiteSpace(Model.Owners) ? "None" : Model.Owners)
- -
Admins
-
@(string.IsNullOrWhiteSpace(Model.Admins) ? "None" : Model.Admins)
-
Using 2FA
@(Model.Organization.TwoFactorIsEnabled() ? "Yes" : "No")
@@ -76,3 +70,49 @@
Secrets Manager Seats
@(Model.UseSecretsManager ? Model.OccupiedSmSeatsCount: "N/A" )
+ +

Administrators

+
+
+
+ + + + + + + + + + @if(!Model.Admins.Any() && !Model.Owners.Any()) + { + + + + } + else + { + @foreach(var owner in Model.OwnersDetails) + { + + + + + + } + + @foreach(var admin in Model.AdminsDetails) + { + + + + + + + } + } + +
EmailRoleStatus
No results to list.
@owner.EmailOwner@owner.Status
@admin.EmailAdmin@admin.Status
+
+
+
diff --git a/src/Admin/AdminConsole/Views/Providers/Admins.cshtml b/src/Admin/AdminConsole/Views/Providers/Admins.cshtml index 86043f3a6d..29eddc8964 100644 --- a/src/Admin/AdminConsole/Views/Providers/Admins.cshtml +++ b/src/Admin/AdminConsole/Views/Providers/Admins.cshtml @@ -7,7 +7,7 @@ var canResendEmailInvite = AccessControlService.UserHasPermission(Permission.Provider_ResendEmailInvite); } -

Provider Admins

+

Administrators

@@ -15,12 +15,13 @@ Email + Role Status - @if(!Model.ProviderAdmins.Any()) + @if(!Model.ProviderUsers.Any()) { No results to list. @@ -28,29 +29,39 @@ } else { - @foreach(var admin in Model.ProviderAdmins) + @foreach(var user in Model.ProviderUsers) { - @admin.Email + @user.Email - @admin.Status + @if(@user.Type == 0) + { + Provider Admin + } + else + { + Service User + } + + + @user.Status - @if(admin.Status.Equals(ProviderUserStatusType.Confirmed) + @if(user.Status.Equals(ProviderUserStatusType.Confirmed) && @Model.Provider.Status.Equals(ProviderStatusType.Pending) && canResendEmailInvite) { - @if(@TempData["InviteResentTo"] != null && @TempData["InviteResentTo"].ToString() == @admin.UserId.Value.ToString()) + @if(@TempData["InviteResentTo"] != null && @TempData["InviteResentTo"].ToString() == @user.UserId.Value.ToString()) { } else { Resend Setup Invite diff --git a/src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml b/src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml index 7b19b19939..267264a38f 100644 --- a/src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml +++ b/src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml @@ -124,6 +124,10 @@
+
+ + +
diff --git a/src/Admin/AdminConsole/Views/Shared/_OrganizationFormScripts.cshtml b/src/Admin/AdminConsole/Views/Shared/_OrganizationFormScripts.cshtml index 98d4c0d900..ea4448d100 100644 --- a/src/Admin/AdminConsole/Views/Shared/_OrganizationFormScripts.cshtml +++ b/src/Admin/AdminConsole/Views/Shared/_OrganizationFormScripts.cshtml @@ -69,6 +69,7 @@ document.getElementById('@(nameof(Model.UseGroups))').checked = plan.hasGroups; document.getElementById('@(nameof(Model.UsePolicies))').checked = plan.hasPolicies; document.getElementById('@(nameof(Model.UseSso))').checked = plan.hasSso; + document.getElementById('@(nameof(Model.UseOrganizationDomains))').checked = plan.hasOrganizationDomains; document.getElementById('@(nameof(Model.UseScim))').checked = plan.hasScim; document.getElementById('@(nameof(Model.UseDirectory))').checked = plan.hasDirectory; document.getElementById('@(nameof(Model.UseEvents))').checked = plan.hasEvents; diff --git a/src/Admin/Billing/Controllers/BusinessUnitConversionController.cs b/src/Admin/Billing/Controllers/BusinessUnitConversionController.cs index 2421710d41..9275f41932 100644 --- a/src/Admin/Billing/Controllers/BusinessUnitConversionController.cs +++ b/src/Admin/Billing/Controllers/BusinessUnitConversionController.cs @@ -2,12 +2,11 @@ using Bit.Admin.Billing.Models; using Bit.Admin.Enums; using Bit.Admin.Utilities; -using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Repositories; -using Bit.Core.Billing.Services; +using Bit.Core.Billing.Providers.Services; using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.Utilities; @@ -18,7 +17,6 @@ namespace Bit.Admin.Billing.Controllers; [Authorize] [Route("organizations/billing/{organizationId:guid}/business-unit")] -[RequireFeature(FeatureFlagKeys.PM18770_EnableOrganizationBusinessUnitConversion)] public class BusinessUnitConversionController( IBusinessUnitConverter businessUnitConverter, IOrganizationRepository organizationRepository, diff --git a/src/Admin/Billing/Controllers/MigrateProvidersController.cs b/src/Admin/Billing/Controllers/MigrateProvidersController.cs index d4ef105e34..ef5ea2312e 100644 --- a/src/Admin/Billing/Controllers/MigrateProvidersController.cs +++ b/src/Admin/Billing/Controllers/MigrateProvidersController.cs @@ -1,8 +1,8 @@ using Bit.Admin.Billing.Models; using Bit.Admin.Enums; using Bit.Admin.Utilities; -using Bit.Core.Billing.Migration.Models; -using Bit.Core.Billing.Migration.Services; +using Bit.Core.Billing.Providers.Migration.Models; +using Bit.Core.Billing.Providers.Migration.Services; using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; diff --git a/src/Admin/Billing/Models/ProviderPlanViewModel.cs b/src/Admin/Billing/Models/ProviderPlanViewModel.cs index 7a50aba286..391c24d6df 100644 --- a/src/Admin/Billing/Models/ProviderPlanViewModel.cs +++ b/src/Admin/Billing/Models/ProviderPlanViewModel.cs @@ -1,4 +1,4 @@ -using Bit.Core.Billing.Entities; +using Bit.Core.Billing.Providers.Entities; namespace Bit.Admin.Billing.Models; diff --git a/src/Admin/Billing/Views/MigrateProviders/Details.cshtml b/src/Admin/Billing/Views/MigrateProviders/Details.cshtml index 303e6d2e45..6ee0344057 100644 --- a/src/Admin/Billing/Views/MigrateProviders/Details.cshtml +++ b/src/Admin/Billing/Views/MigrateProviders/Details.cshtml @@ -1,5 +1,5 @@ @using System.Text.Json -@model Bit.Core.Billing.Migration.Models.ProviderMigrationResult +@model Bit.Core.Billing.Providers.Migration.Models.ProviderMigrationResult @{ ViewData["Title"] = "Results"; } diff --git a/src/Admin/Billing/Views/MigrateProviders/Results.cshtml b/src/Admin/Billing/Views/MigrateProviders/Results.cshtml index 45611de80e..94db08db3d 100644 --- a/src/Admin/Billing/Views/MigrateProviders/Results.cshtml +++ b/src/Admin/Billing/Views/MigrateProviders/Results.cshtml @@ -1,4 +1,4 @@ -@model Bit.Core.Billing.Migration.Models.ProviderMigrationResult[] +@model Bit.Core.Billing.Providers.Migration.Models.ProviderMigrationResult[] @{ ViewData["Title"] = "Results"; } diff --git a/src/Admin/Controllers/UsersController.cs b/src/Admin/Controllers/UsersController.cs index cecd7a2142..b85a91719c 100644 --- a/src/Admin/Controllers/UsersController.cs +++ b/src/Admin/Controllers/UsersController.cs @@ -4,7 +4,6 @@ using Bit.Admin.Enums; using Bit.Admin.Models; using Bit.Admin.Services; using Bit.Admin.Utilities; -using Bit.Core; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Repositories; using Bit.Core.Services; @@ -89,7 +88,7 @@ public class UsersController : Controller var ciphers = await _cipherRepository.GetManyByUserIdAsync(id); var isTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user); - var verifiedDomain = await AccountDeprovisioningEnabled(user.Id); + var verifiedDomain = await _userService.IsClaimedByAnyOrganizationAsync(user.Id); return View(UserViewModel.MapViewModel(user, isTwoFactorEnabled, ciphers, verifiedDomain)); } @@ -106,7 +105,7 @@ public class UsersController : Controller var billingInfo = await _paymentService.GetBillingAsync(user); var billingHistoryInfo = await _paymentService.GetBillingHistoryAsync(user); var isTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user); - var verifiedDomain = await AccountDeprovisioningEnabled(user.Id); + var verifiedDomain = await _userService.IsClaimedByAnyOrganizationAsync(user.Id); var deviceVerificationRequired = await _userService.ActiveNewDeviceVerificationException(user.Id); return View(new UserEditModel(user, isTwoFactorEnabled, ciphers, billingInfo, billingHistoryInfo, _globalSettings, verifiedDomain, deviceVerificationRequired)); @@ -178,12 +177,4 @@ public class UsersController : Controller await _userService.ToggleNewDeviceVerificationException(user.Id); return RedirectToAction("Edit", new { id }); } - - // TODO: Feature flag to be removed in PM-14207 - private async Task AccountDeprovisioningEnabled(Guid userId) - { - return _featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) - ? await _userService.IsClaimedByAnyOrganizationAsync(userId) - : null; - } } diff --git a/src/Admin/Dockerfile b/src/Admin/Dockerfile index 79d117681c..d6b42eadfb 100644 --- a/src/Admin/Dockerfile +++ b/src/Admin/Dockerfile @@ -1,21 +1,71 @@ +############################################### +# Build stage # +############################################### +FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0 AS build + +# Docker buildx supplies the value for this arg +ARG TARGETPLATFORM + +# Determine proper runtime value for .NET +RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \ + RID=linux-x64 ; \ + elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \ + RID=linux-arm64 ; \ + elif [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then \ + RID=linux-arm ; \ + fi \ + && echo "RID=$RID" > /tmp/rid.txt + +# Set up Node +ARG NODE_VERSION=20 +RUN curl -fsSL https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash - \ + && apt-get update \ + && apt-get install -y nodejs \ + && npm install -g npm@latest && \ + rm -rf /var/lib/apt/lists/* + +# Copy required project files +WORKDIR /source +COPY . ./ + +# Restore project dependencies and tools +WORKDIR /source/src/Admin +RUN npm ci +RUN . /tmp/rid.txt && dotnet restore -r $RID + +# Build project +RUN npm run build +RUN . /tmp/rid.txt && dotnet publish \ + -c release \ + --no-restore \ + --self-contained \ + /p:PublishSingleFile=true \ + -r $RID \ + -o out + +############################################### +# App stage # +############################################### FROM mcr.microsoft.com/dotnet/aspnet:8.0 +ARG TARGETPLATFORM LABEL com.bitwarden.product="bitwarden" +ENV ASPNETCORE_ENVIRONMENT=Production +ENV ASPNETCORE_URLS=http://+:5000 +ENV SSL_CERT_DIR=/etc/bitwarden/ca-certificates +EXPOSE 5000 RUN apt-get update \ && apt-get install -y --no-install-recommends \ gosu \ curl \ - krb5-user \ && rm -rf /var/lib/apt/lists/* -ENV ASPNETCORE_URLS http://+:5000 +# Copy app from the build stage WORKDIR /app -EXPOSE 5000 -COPY obj/build-output/publish . -COPY entrypoint.sh / +COPY --from=build /source/src/Admin/out /app +COPY ./src/Admin/entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh - -HEALTHCHECK CMD curl -f http://localhost:5000 || exit 1 +HEALTHCHECK CMD curl -f http://localhost:5000/alive || exit 1 ENTRYPOINT ["/entrypoint.sh"] diff --git a/src/Admin/IdentityServer/ReadOnlyEnvIdentityUserStore.cs b/src/Admin/IdentityServer/ReadOnlyEnvIdentityUserStore.cs index 15b8d894b7..89f04230b3 100644 --- a/src/Admin/IdentityServer/ReadOnlyEnvIdentityUserStore.cs +++ b/src/Admin/IdentityServer/ReadOnlyEnvIdentityUserStore.cs @@ -39,7 +39,7 @@ public class ReadOnlyEnvIdentityUserStore : ReadOnlyIdentityUserStore } } - var userStamp = usersDict.ContainsKey(normalizedEmail) ? usersDict[normalizedEmail] : null; + var userStamp = usersDict.GetValueOrDefault(normalizedEmail); if (userStamp == null) { return Task.FromResult(null); diff --git a/src/Admin/Models/ChargeBraintreeModel.cs b/src/Admin/Models/ChargeBraintreeModel.cs index 2ba06cb980..8c2f39e58d 100644 --- a/src/Admin/Models/ChargeBraintreeModel.cs +++ b/src/Admin/Models/ChargeBraintreeModel.cs @@ -17,7 +17,7 @@ public class ChargeBraintreeModel : IValidatableObject { if (Id != null) { - if (Id.Length != 36 || (Id[0] != 'o' && Id[0] != 'u') || + if (Id.Length != 36 || (Id[0] != 'o' && Id[0] != 'u' && Id[0] != 'p') || !Guid.TryParse(Id.Substring(1, 32), out var guid)) { yield return new ValidationResult("Customer Id is not a valid format."); diff --git a/src/Admin/Program.cs b/src/Admin/Program.cs index fb5dc7e08b..05bf35d41d 100644 --- a/src/Admin/Program.cs +++ b/src/Admin/Program.cs @@ -20,8 +20,8 @@ public class Program logging.AddSerilog(hostingContext, (e, globalSettings) => { var context = e.Properties["SourceContext"].ToString(); - if (e.Properties.ContainsKey("RequestPath") && - !string.IsNullOrWhiteSpace(e.Properties["RequestPath"]?.ToString()) && + if (e.Properties.TryGetValue("RequestPath", out var requestPath) && + !string.IsNullOrWhiteSpace(requestPath?.ToString()) && (context.Contains(".Server.Kestrel") || context.Contains(".Core.IISHttpServer"))) { return false; diff --git a/src/Admin/Services/AccessControlService.cs b/src/Admin/Services/AccessControlService.cs index f45f30e216..a2ba9fa6ff 100644 --- a/src/Admin/Services/AccessControlService.cs +++ b/src/Admin/Services/AccessControlService.cs @@ -29,12 +29,12 @@ public class AccessControlService : IAccessControlService } var userRole = GetUserRoleFromClaim(); - if (string.IsNullOrEmpty(userRole) || !RolePermissionMapping.RolePermissions.ContainsKey(userRole)) + if (string.IsNullOrEmpty(userRole) || !RolePermissionMapping.RolePermissions.TryGetValue(userRole, out var rolePermissions)) { return false; } - return RolePermissionMapping.RolePermissions[userRole].Contains(permission); + return rolePermissions.Contains(permission); } public string GetUserRole(string userEmail) diff --git a/src/Admin/Startup.cs b/src/Admin/Startup.cs index 11f9e7ce68..5b34e13f6c 100644 --- a/src/Admin/Startup.cs +++ b/src/Admin/Startup.cs @@ -10,7 +10,7 @@ using Microsoft.AspNetCore.Mvc.Razor; using Microsoft.Extensions.DependencyInjection.Extensions; using Bit.Admin.Services; using Bit.Core.Billing.Extensions; -using Bit.Core.Billing.Migration; +using Bit.Core.Billing.Providers.Migration; #if !OSS using Bit.Commercial.Core.Utilities; diff --git a/src/Admin/Tools/Jobs/DeleteSendsJob.cs b/src/Admin/Tools/Jobs/DeleteSendsJob.cs index dafce03994..7449d2ea01 100644 --- a/src/Admin/Tools/Jobs/DeleteSendsJob.cs +++ b/src/Admin/Tools/Jobs/DeleteSendsJob.cs @@ -2,7 +2,7 @@ using Bit.Core; using Bit.Core.Jobs; using Bit.Core.Tools.Repositories; -using Bit.Core.Tools.Services; +using Bit.Core.Tools.SendFeatures.Commands.Interfaces; using Quartz; namespace Bit.Admin.Tools.Jobs; @@ -32,10 +32,10 @@ public class DeleteSendsJob : BaseJob } using (var scope = _serviceProvider.CreateScope()) { - var sendService = scope.ServiceProvider.GetRequiredService(); + var nonAnonymousSendCommand = scope.ServiceProvider.GetRequiredService(); foreach (var send in sends) { - await sendService.DeleteSendAsync(send); + await nonAnonymousSendCommand.DeleteSendAsync(send); } } } diff --git a/src/Admin/entrypoint.sh b/src/Admin/entrypoint.sh index 2c564b1ce6..4d7d238d25 100644 --- a/src/Admin/entrypoint.sh +++ b/src/Admin/entrypoint.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # Setup @@ -19,31 +19,36 @@ then LGID=65534 fi -# Create user and group +if [ "$(id -u)" = "0" ] +then + # Create user and group -groupadd -o -g $LGID $GROUPNAME >/dev/null 2>&1 || -groupmod -o -g $LGID $GROUPNAME >/dev/null 2>&1 -useradd -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 || -usermod -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 -mkhomedir_helper $USERNAME + groupadd -o -g $LGID $GROUPNAME >/dev/null 2>&1 || + groupmod -o -g $LGID $GROUPNAME >/dev/null 2>&1 + useradd -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 || + usermod -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 + mkhomedir_helper $USERNAME -# The rest... + # The rest... -chown -R $USERNAME:$GROUPNAME /app -mkdir -p /etc/bitwarden/core -mkdir -p /etc/bitwarden/logs -mkdir -p /etc/bitwarden/ca-certificates -chown -R $USERNAME:$GROUPNAME /etc/bitwarden + chown -R $USERNAME:$GROUPNAME /app + mkdir -p /etc/bitwarden/core + mkdir -p /etc/bitwarden/logs + mkdir -p /etc/bitwarden/ca-certificates + chown -R $USERNAME:$GROUPNAME /etc/bitwarden -if [[ $globalSettings__selfHosted == "true" ]]; then - cp /etc/bitwarden/ca-certificates/*.crt /usr/local/share/ca-certificates/ >/dev/null 2>&1 \ - && update-ca-certificates + if [[ -f "/etc/bitwarden/kerberos/bitwarden.keytab" && -f "/etc/bitwarden/kerberos/krb5.conf" ]]; then + chown -R $USERNAME:$GROUPNAME /etc/bitwarden/kerberos + fi + + gosu_cmd="gosu $USERNAME:$GROUPNAME" +else + gosu_cmd="" fi if [[ -f "/etc/bitwarden/kerberos/bitwarden.keytab" && -f "/etc/bitwarden/kerberos/krb5.conf" ]]; then - chown -R $USERNAME:$GROUPNAME /etc/bitwarden/kerberos - cp -f /etc/bitwarden/kerberos/krb5.conf /etc/krb5.conf - gosu $USERNAME:$GROUPNAME kinit $globalSettings__kerberosUser -k -t /etc/bitwarden/kerberos/bitwarden.keytab + cp -f /etc/bitwarden/kerberos/krb5.conf /etc/krb5.conf + $gosu_cmd kinit $globalSettings__kerberosUser -k -t /etc/bitwarden/kerberos/bitwarden.keytab fi -exec gosu $USERNAME:$GROUPNAME dotnet /app/Admin.dll +exec $gosu_cmd /app/Admin diff --git a/src/Admin/package-lock.json b/src/Admin/package-lock.json index 24c2466746..e73ccfcef5 100644 --- a/src/Admin/package-lock.json +++ b/src/Admin/package-lock.json @@ -9,18 +9,18 @@ "version": "0.0.0", "license": "GPL-3.0", "dependencies": { - "bootstrap": "5.3.3", + "bootstrap": "5.3.6", "font-awesome": "4.7.0", "jquery": "3.7.1", "toastr": "2.1.4" }, "devDependencies": { "css-loader": "7.1.2", - "expose-loader": "5.0.0", + "expose-loader": "5.0.1", "mini-css-extract-plugin": "2.9.2", - "sass": "1.85.0", - "sass-loader": "16.0.4", - "webpack": "5.97.1", + "sass": "1.88.0", + "sass-loader": "16.0.5", + "webpack": "5.99.8", "webpack-cli": "5.1.4" } }, @@ -456,13 +456,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.13.14", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.14.tgz", - "integrity": "sha512-Zs/Ollc1SJ8nKUAgc7ivOEdIBM8JAKgrqqUYi2J997JuKO7/tpQC+WCetQ1sypiKCQWHdvdg9wBNpUPEWZae7w==", + "version": "22.15.21", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.21.tgz", + "integrity": "sha512-EV/37Td6c+MgKAbkcLG6vqZ2zEYHD7bvSrzqqs2RIhbA6w3x+Dqz8MZM3sP6kGTeLrdoOgKZe+Xja7tUB2DNkQ==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.20.0" + "undici-types": "~6.21.0" } }, "node_modules/@webassemblyjs/ast": { @@ -749,9 +749,9 @@ } }, "node_modules/bootstrap": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.3.tgz", - "integrity": "sha512-8HLCdWgyoMguSO9o+aH+iuZ+aht+mzW0u3HIMzVu7Srrpv7EBBxTnrFlSCskwdY1+EOFQSm7uMJhNQHkdPcmjg==", + "version": "5.3.6", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.6.tgz", + "integrity": "sha512-jX0GAcRzvdwISuvArXn3m7KZscWWFAf1MKBcnzaN02qWMb3jpMoUX4/qgeiGzqyIb4ojulRzs89UCUmGcFSzTA==", "funding": [ { "type": "github", @@ -782,9 +782,9 @@ } }, "node_modules/browserslist": { - "version": "4.24.4", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz", - "integrity": "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==", + "version": "4.24.5", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.5.tgz", + "integrity": "sha512-FDToo4Wo82hIdgc1CQ+NQD0hEhmpPjrZ3hiUgwgOG6IuTdlpr8jdjyG24P6cNP1yJpTLzS5OcGgSw0xmDU1/Tw==", "dev": true, "funding": [ { @@ -802,10 +802,10 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001688", - "electron-to-chromium": "^1.5.73", + "caniuse-lite": "^1.0.30001716", + "electron-to-chromium": "^1.5.149", "node-releases": "^2.0.19", - "update-browserslist-db": "^1.1.1" + "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" @@ -822,9 +822,9 @@ "license": "MIT" }, "node_modules/caniuse-lite": { - "version": "1.0.30001707", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001707.tgz", - "integrity": "sha512-3qtRjw/HQSMlDWf+X79N206fepf4SOOU6SQLMaq/0KkZLmSjPxAkBOQQ+FxbHKfHmYLZFfdWsO3KA90ceHPSnw==", + "version": "1.0.30001718", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001718.tgz", + "integrity": "sha512-AflseV1ahcSunK53NfEs9gFWgOEmzr0f+kaMFA4xiLZlr9Hzt7HxcSpIFcnNCUkz6R6dWKa54rUz3HUmI3nVcw==", "dev": true, "funding": [ { @@ -976,9 +976,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.128", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.128.tgz", - "integrity": "sha512-bo1A4HH/NS522Ws0QNFIzyPcyUUNV/yyy70Ho1xqfGYzPUme2F/xr4tlEOuM6/A538U1vDA7a4XfCd1CKRegKQ==", + "version": "1.5.155", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.155.tgz", + "integrity": "sha512-ps5KcGGmwL8VaeJlvlDlu4fORQpv3+GIcF5I3f9tUKUlJ/wsysh6HU8P5L1XWRYeXfA0oJd4PyM8ds8zTFf6Ng==", "dev": true, "license": "ISC" }, @@ -1010,9 +1010,9 @@ } }, "node_modules/es-module-lexer": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.6.0.tgz", - "integrity": "sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==", + "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==", "dev": true, "license": "MIT" }, @@ -1084,9 +1084,9 @@ } }, "node_modules/expose-loader": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/expose-loader/-/expose-loader-5.0.0.tgz", - "integrity": "sha512-BtUqYRmvx1bEY5HN6eK2I9URUZgNmN0x5UANuocaNjXSgfoDlkXt+wyEMe7i5DzDNh2BKJHPc5F4rBwEdSQX6w==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/expose-loader/-/expose-loader-5.0.1.tgz", + "integrity": "sha512-5YPZuszN/eWND/B+xuq5nIpb/l5TV1HYmdO6SubYtHv+HenVw9/6bn33Mm5reY8DNid7AVtbARvyUD34edfCtg==", "dev": true, "license": "MIT", "engines": { @@ -1107,13 +1107,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, "node_modules/fast-uri": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", @@ -1249,9 +1242,9 @@ } }, "node_modules/immutable": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.1.tgz", - "integrity": "sha512-3jatXi9ObIsPGr3N5hGw/vWWcTkq6hUYhpQz4k0wLC+owqWi/LiugIw9x0EdNZ2yGedKN/HzePiBvaJRXa0Ujg==", + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.2.tgz", + "integrity": "sha512-qHKXW1q6liAk1Oys6umoaZbDRqjcjgSrbnrifHsfsttza7zcvRAsL7mMV6xWcyhwQy7Xj5v4hhbr6b+iDYwlmQ==", "dev": true, "license": "MIT" }, @@ -1755,16 +1748,6 @@ "dev": true, "license": "MIT" }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -1878,9 +1861,9 @@ "license": "MIT" }, "node_modules/sass": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.85.0.tgz", - "integrity": "sha512-3ToiC1xZ1Y8aU7+CkgCI/tqyuPXEmYGJXO7H4uqp0xkLXUqp88rQQ4j1HmP37xSJLbCJPaIiv+cT1y+grssrww==", + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.88.0.tgz", + "integrity": "sha512-sF6TWQqjFvr4JILXzG4ucGOLELkESHL+I5QJhh7CNaE+Yge0SI+ehCatsXhJ7ymU1hAFcIS3/PBpjdIbXoyVbg==", "dev": true, "license": "MIT", "dependencies": { @@ -1899,9 +1882,9 @@ } }, "node_modules/sass-loader": { - "version": "16.0.4", - "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-16.0.4.tgz", - "integrity": "sha512-LavLbgbBGUt3wCiYzhuLLu65+fWXaXLmq7YxivLhEqmiupCFZ5sKUAipK3do6V80YSU0jvSxNhEdT13IXNr3rg==", + "version": "16.0.5", + "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-16.0.5.tgz", + "integrity": "sha512-oL+CMBXrj6BZ/zOq4os+UECPL+bWqt6OAC6DWS8Ln8GZRcMDjlJ4JC3FBDuHJdYaFWIdKNIBYmtZtK2MaMkNIw==", "dev": true, "license": "MIT", "dependencies": { @@ -1940,9 +1923,9 @@ } }, "node_modules/schema-utils": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.0.tgz", - "integrity": "sha512-Gf9qqc58SpCA/xdziiHz35F4GNIWYWZrEshUc/G/r5BnLph6xpKuLeoJoQuj5WfBIx/eQLf+hmVPYHaxJu7V2g==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz", + "integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1960,9 +1943,9 @@ } }, "node_modules/semver": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", - "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", "dev": true, "license": "ISC", "bin": { @@ -2079,9 +2062,9 @@ } }, "node_modules/tapable": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", - "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.2.tgz", + "integrity": "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==", "dev": true, "license": "MIT", "engines": { @@ -2089,14 +2072,14 @@ } }, "node_modules/terser": { - "version": "5.39.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.39.0.tgz", - "integrity": "sha512-LBAhFyLho16harJoWMg/nZsQYgTrg5jXOn2nCYjRUcZZEdE3qa2zb8QEDRUGVZBW4rlazf2fxkg8tztybTaqWw==", + "version": "5.39.2", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.39.2.tgz", + "integrity": "sha512-yEPUmWve+VA78bI71BW70Dh0TuV4HHd+I5SHOAfS1+QBOmvmCiiffgjR8ryyEd3KIfvPGFqoADt8LdQ6XpXIvg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.8.2", + "acorn": "^8.14.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, @@ -2165,9 +2148,9 @@ } }, "node_modules/undici-types": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", - "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" }, @@ -2202,16 +2185,6 @@ "browserslist": ">= 4.21.0" } }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -2220,9 +2193,9 @@ "license": "MIT" }, "node_modules/watchpack": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz", - "integrity": "sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw==", + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz", + "integrity": "sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==", "dev": true, "license": "MIT", "dependencies": { @@ -2234,14 +2207,15 @@ } }, "node_modules/webpack": { - "version": "5.97.1", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.97.1.tgz", - "integrity": "sha512-EksG6gFY3L1eFMROS/7Wzgrii5mBAFe4rIr3r2BTfo7bcc+DWwFZ4OJ/miOuHJO/A85HwyI4eQ0F6IKXesO7Fg==", + "version": "5.99.8", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.99.8.tgz", + "integrity": "sha512-lQ3CPiSTpfOnrEGeXDwoq5hIGzSjmwD72GdfVzF7CQAI7t47rJG9eDWvcEkEn3CUQymAElVvDg3YNTlCYj+qUQ==", "dev": true, "license": "MIT", "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", @@ -2258,9 +2232,9 @@ "loader-runner": "^4.2.0", "mime-types": "^2.1.27", "neo-async": "^2.6.2", - "schema-utils": "^3.2.0", + "schema-utils": "^4.3.2", "tapable": "^2.1.1", - "terser-webpack-plugin": "^5.3.10", + "terser-webpack-plugin": "^5.3.11", "watchpack": "^2.4.1", "webpack-sources": "^3.2.3" }, @@ -2361,59 +2335,6 @@ "node": ">=10.13.0" } }, - "node_modules/webpack/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/webpack/node_modules/ajv-keywords": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "ajv": "^6.9.1" - } - }, - "node_modules/webpack/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/webpack/node_modules/schema-utils": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", - "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/json-schema": "^7.0.8", - "ajv": "^6.12.5", - "ajv-keywords": "^3.5.2" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/src/Admin/package.json b/src/Admin/package.json index 7f3c8046a2..e88cd42eca 100644 --- a/src/Admin/package.json +++ b/src/Admin/package.json @@ -8,18 +8,18 @@ "build": "webpack" }, "dependencies": { - "bootstrap": "5.3.3", + "bootstrap": "5.3.6", "font-awesome": "4.7.0", "jquery": "3.7.1", "toastr": "2.1.4" }, "devDependencies": { "css-loader": "7.1.2", - "expose-loader": "5.0.0", + "expose-loader": "5.0.1", "mini-css-extract-plugin": "2.9.2", - "sass": "1.85.0", - "sass-loader": "16.0.4", - "webpack": "5.97.1", + "sass": "1.88.0", + "sass-loader": "16.0.5", + "webpack": "5.99.8", "webpack-cli": "5.1.4" } } diff --git a/src/Api/.dockerignore b/src/Api/.dockerignore deleted file mode 100644 index fc12f25146..0000000000 --- a/src/Api/.dockerignore +++ /dev/null @@ -1,4 +0,0 @@ -* -!obj/build-output/publish/* -!obj/Docker/empty/ -!entrypoint.sh diff --git a/src/Api/AdminConsole/Controllers/OrganizationDomainController.cs b/src/Api/AdminConsole/Controllers/OrganizationDomainController.cs index b9afde2724..a8882dfaf3 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationDomainController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationDomainController.cs @@ -2,13 +2,11 @@ using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Api.Models.Response; -using Bit.Core; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Exceptions; using Bit.Core.Repositories; -using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -137,7 +135,6 @@ public class OrganizationDomainController : Controller [AllowAnonymous] [HttpPost("domain/sso/verified")] - [RequireFeature(FeatureFlagKeys.VerifiedSsoDomainEndpoint)] public async Task GetVerifiedOrgDomainSsoDetailsAsync( [FromBody] OrganizationDomainSsoDetailsRequestModel model) { diff --git a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs index 536914b56f..6b23edf347 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs @@ -616,7 +616,6 @@ public class OrganizationUsersController : Controller new OrganizationUserBulkResponseModel(r.OrganizationUserId, r.ErrorMessage))); } - [RequireFeature(FeatureFlagKeys.AccountDeprovisioning)] [HttpDelete("{id}/delete-account")] [HttpPost("{id}/delete-account")] public async Task DeleteAccount(Guid orgId, Guid id) @@ -635,7 +634,6 @@ public class OrganizationUsersController : Controller await _deleteClaimedOrganizationUserAccountCommand.DeleteUserAsync(orgId, id, currentUser.Id); } - [RequireFeature(FeatureFlagKeys.AccountDeprovisioning)] [HttpDelete("delete-account")] [HttpPost("delete-account")] public async Task> BulkDeleteAccount(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) @@ -760,11 +758,6 @@ public class OrganizationUsersController : Controller private async Task> GetClaimedByOrganizationStatusAsync(Guid orgId, IEnumerable userIds) { - if (!_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)) - { - return userIds.ToDictionary(kvp => kvp, kvp => false); - } - var usersOrganizationClaimedStatus = await _getOrganizationUsersClaimedStatusQuery.GetUsersOrganizationClaimedStatusAsync(orgId, userIds); return usersOrganizationClaimedStatus; } diff --git a/src/Api/AdminConsole/Controllers/OrganizationsController.cs b/src/Api/AdminConsole/Controllers/OrganizationsController.cs index c856c8ab91..0d498beab1 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationsController.cs @@ -25,7 +25,7 @@ using Bit.Core.Auth.Services; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Services; +using Bit.Core.Billing.Providers.Services; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -279,8 +279,7 @@ public class OrganizationsController : Controller throw new BadRequestException("Your organization's Single Sign-On settings prevent you from leaving."); } - if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) - && (await _userService.GetOrganizationsClaimingUserAsync(user.Id)).Any(x => x.Id == id)) + if ((await _userService.GetOrganizationsClaimingUserAsync(user.Id)).Any(x => x.Id == id)) { throw new BadRequestException("Claimed user account cannot leave claiming organization. Contact your organization administrator for additional details."); } diff --git a/src/Api/AdminConsole/Controllers/PoliciesController.cs b/src/Api/AdminConsole/Controllers/PoliciesController.cs index 7de6f6e730..86a1609ee6 100644 --- a/src/Api/AdminConsole/Controllers/PoliciesController.cs +++ b/src/Api/AdminConsole/Controllers/PoliciesController.cs @@ -2,7 +2,6 @@ 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; @@ -79,7 +78,7 @@ public class PoliciesController : Controller return new PolicyDetailResponseModel(new Policy { Type = (PolicyType)type }); } - if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) && policy.Type is PolicyType.SingleOrg) + if (policy.Type is PolicyType.SingleOrg) { return await policy.GetSingleOrgPolicyDetailResponseAsync(_organizationHasVerifiedDomainsQuery); } diff --git a/src/Api/AdminConsole/Controllers/ProviderClientsController.cs b/src/Api/AdminConsole/Controllers/ProviderClientsController.cs index 74d2feff3c..f226ba316e 100644 --- a/src/Api/AdminConsole/Controllers/ProviderClientsController.cs +++ b/src/Api/AdminConsole/Controllers/ProviderClientsController.cs @@ -2,7 +2,7 @@ using Bit.Api.Billing.Models.Requests; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; -using Bit.Core.Billing.Services; +using Bit.Core.Billing.Providers.Services; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Models.Business; diff --git a/src/Api/AdminConsole/Controllers/SlackIntegrationController.cs b/src/Api/AdminConsole/Controllers/SlackIntegrationController.cs index a8bef10dc6..c0ab5c059b 100644 --- a/src/Api/AdminConsole/Controllers/SlackIntegrationController.cs +++ b/src/Api/AdminConsole/Controllers/SlackIntegrationController.cs @@ -2,10 +2,10 @@ using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Core; using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Models.Data.Integrations; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Exceptions; -using Bit.Core.Models.Data.Integrations; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Utilities; diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationCreateRequestModel.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationCreateRequestModel.cs index 539260a312..e18122fd2b 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationCreateRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationCreateRequestModel.cs @@ -75,6 +75,8 @@ public class OrganizationCreateRequestModel : IValidatableObject public string InitiationPath { get; set; } + public bool SkipTrial { get; set; } + public virtual OrganizationSignup ToOrganizationSignup(User user) { var orgSignup = new OrganizationSignup @@ -107,6 +109,7 @@ public class OrganizationCreateRequestModel : IValidatableObject BillingAddressCountry = BillingAddressCountry, }, InitiationPath = InitiationPath, + SkipTrial = SkipTrial }; Keys?.ToOrganizationSignup(orgSignup); diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModel.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModel.cs index 6566760e17..ccab2b36ae 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModel.cs @@ -1,8 +1,8 @@ using System.ComponentModel.DataAnnotations; using System.Text.Json; using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Models.Data.Integrations; using Bit.Core.Enums; -using Bit.Core.Models.Data.Integrations; #nullable enable diff --git a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs index a14e3efb51..95754598b9 100644 --- a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs @@ -64,6 +64,7 @@ public class OrganizationResponseModel : ResponseModel LimitItemDeletion = organization.LimitItemDeletion; AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems; UseRiskInsights = organization.UseRiskInsights; + UseOrganizationDomains = organization.UseOrganizationDomains; UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies; } @@ -111,6 +112,7 @@ public class OrganizationResponseModel : ResponseModel public bool LimitItemDeletion { get; set; } public bool AllowAdminAccessToAllCollectionItems { get; set; } public bool UseRiskInsights { get; set; } + public bool UseOrganizationDomains { get; set; } public bool UseAdminSponsoredFamilies { get; set; } } diff --git a/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs b/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs index 5c900b73e0..cb0ab62fd1 100644 --- a/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs @@ -73,6 +73,7 @@ public class ProfileOrganizationResponseModel : ResponseModel AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems; UserIsClaimedByOrganization = organizationIdsClaimingUser.Contains(organization.OrganizationId); UseRiskInsights = organization.UseRiskInsights; + UseOrganizationDomains = organization.UseOrganizationDomains; UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies; if (organization.SsoConfig != null) @@ -136,7 +137,6 @@ public class ProfileOrganizationResponseModel : ResponseModel public bool AllowAdminAccessToAllCollectionItems { get; set; } /// /// Obsolete. - /// /// See /// [Obsolete("Please use UserIsClaimedByOrganization instead. This property will be removed in a future version.")] @@ -146,17 +146,15 @@ public class ProfileOrganizationResponseModel : ResponseModel set => UserIsClaimedByOrganization = value; } /// - /// Indicates if the organization claims the user. + /// Indicates if the user is claimed by the organization. /// /// - /// An organization claims a user if the user's email domain is verified by the organization and the user is a member of it. + /// A user is claimed by an organization if the user's email domain is verified by the organization and the user is a member. /// The organization must be enabled and able to have verified domains. /// - /// - /// False if the Account Deprovisioning feature flag is disabled. - /// public bool UserIsClaimedByOrganization { get; set; } public bool UseRiskInsights { get; set; } + public bool UseOrganizationDomains { get; set; } public bool UseAdminSponsoredFamilies { get; set; } public bool IsAdminInitiated { get; set; } } diff --git a/src/Api/AdminConsole/Models/Response/ProfileProviderOrganizationResponseModel.cs b/src/Api/AdminConsole/Models/Response/ProfileProviderOrganizationResponseModel.cs index 5d5e1f9b85..24b6fed704 100644 --- a/src/Api/AdminConsole/Models/Response/ProfileProviderOrganizationResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/ProfileProviderOrganizationResponseModel.cs @@ -50,6 +50,7 @@ public class ProfileProviderOrganizationResponseModel : ProfileOrganizationRespo LimitItemDeletion = organization.LimitItemDeletion; AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems; UseRiskInsights = organization.UseRiskInsights; + UseOrganizationDomains = organization.UseOrganizationDomains; UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies; } } diff --git a/src/Api/Auth/Controllers/AccountsController.cs b/src/Api/Auth/Controllers/AccountsController.cs index fdd5fbb290..2499b269f5 100644 --- a/src/Api/Auth/Controllers/AccountsController.cs +++ b/src/Api/Auth/Controllers/AccountsController.cs @@ -518,9 +518,8 @@ public class AccountsController : Controller } else { - // If Account Deprovisioning is enabled, we need to check if the user is claimed by any organization. - if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) - && await _userService.IsClaimedByAnyOrganizationAsync(user.Id)) + // Check if the user is claimed by any organization. + if (await _userService.IsClaimedByAnyOrganizationAsync(user.Id)) { throw new BadRequestException("Cannot delete accounts owned by an organization. Contact your organization administrator for additional details."); } diff --git a/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs b/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs index 357db5ad1e..8d7df4160d 100644 --- a/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs +++ b/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs @@ -25,7 +25,7 @@ public class UpdateTwoFactorAuthenticatorRequestModel : SecretVerificationReques { providers = new Dictionary(); } - else if (providers.ContainsKey(TwoFactorProviderType.Authenticator)) + else { providers.Remove(TwoFactorProviderType.Authenticator); } @@ -62,7 +62,7 @@ public class UpdateTwoFactorDuoRequestModel : SecretVerificationRequestModel, IV { providers = []; } - else if (providers.ContainsKey(TwoFactorProviderType.Duo)) + else { providers.Remove(TwoFactorProviderType.Duo); } @@ -88,7 +88,7 @@ public class UpdateTwoFactorDuoRequestModel : SecretVerificationRequestModel, IV { providers = []; } - else if (providers.ContainsKey(TwoFactorProviderType.OrganizationDuo)) + else { providers.Remove(TwoFactorProviderType.OrganizationDuo); } @@ -145,7 +145,7 @@ public class UpdateTwoFactorYubicoOtpRequestModel : SecretVerificationRequestMod { providers = new Dictionary(); } - else if (providers.ContainsKey(TwoFactorProviderType.YubiKey)) + else { providers.Remove(TwoFactorProviderType.YubiKey); } @@ -228,7 +228,7 @@ public class TwoFactorEmailRequestModel : SecretVerificationRequestModel { providers = new Dictionary(); } - else if (providers.ContainsKey(TwoFactorProviderType.Email)) + else { providers.Remove(TwoFactorProviderType.Email); } diff --git a/src/Api/Auth/Models/Response/EmergencyAccessResponseModel.cs b/src/Api/Auth/Models/Response/EmergencyAccessResponseModel.cs index 2fb9a67199..90b265715d 100644 --- a/src/Api/Auth/Models/Response/EmergencyAccessResponseModel.cs +++ b/src/Api/Auth/Models/Response/EmergencyAccessResponseModel.cs @@ -90,6 +90,13 @@ public class EmergencyAccessGrantorDetailsResponseModel : EmergencyAccessRespons public class EmergencyAccessTakeoverResponseModel : ResponseModel { + /// + /// Creates a new instance of the class. + /// + /// Consumed for the Encrypted Key value + /// consumed for the KDF configuration + /// name of the object + /// emergencyAccess cannot be null public EmergencyAccessTakeoverResponseModel(EmergencyAccess emergencyAccess, User grantor, string obj = "emergencyAccessTakeover") : base(obj) { if (emergencyAccess == null) diff --git a/src/Api/Auth/Models/Response/TwoFactor/TwoFactorAuthenticatorResponseModel.cs b/src/Api/Auth/Models/Response/TwoFactor/TwoFactorAuthenticatorResponseModel.cs index f791c6fb1e..71569174a7 100644 --- a/src/Api/Auth/Models/Response/TwoFactor/TwoFactorAuthenticatorResponseModel.cs +++ b/src/Api/Auth/Models/Response/TwoFactor/TwoFactorAuthenticatorResponseModel.cs @@ -13,9 +13,9 @@ public class TwoFactorAuthenticatorResponseModel : ResponseModel ArgumentNullException.ThrowIfNull(user); var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Authenticator); - if (provider?.MetaData?.ContainsKey("Key") ?? false) + if (provider?.MetaData?.TryGetValue("Key", out var keyValue) ?? false) { - Key = (string)provider.MetaData["Key"]; + Key = (string)keyValue; Enabled = provider.Enabled; } else diff --git a/src/Api/Auth/Models/Response/TwoFactor/TwoFactorEmailResponseModel.cs b/src/Api/Auth/Models/Response/TwoFactor/TwoFactorEmailResponseModel.cs index ee1797f83e..d1d87d85b5 100644 --- a/src/Api/Auth/Models/Response/TwoFactor/TwoFactorEmailResponseModel.cs +++ b/src/Api/Auth/Models/Response/TwoFactor/TwoFactorEmailResponseModel.cs @@ -15,9 +15,9 @@ public class TwoFactorEmailResponseModel : ResponseModel } var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Email); - if (provider?.MetaData?.ContainsKey("Email") ?? false) + if (provider?.MetaData?.TryGetValue("Email", out var email) ?? false) { - Email = (string)provider.MetaData["Email"]; + Email = (string)email; Enabled = provider.Enabled; } else diff --git a/src/Api/Auth/Models/Response/TwoFactor/TwoFactorYubiKeyResponseModel.cs b/src/Api/Auth/Models/Response/TwoFactor/TwoFactorYubiKeyResponseModel.cs index 014863497d..0a97367017 100644 --- a/src/Api/Auth/Models/Response/TwoFactor/TwoFactorYubiKeyResponseModel.cs +++ b/src/Api/Auth/Models/Response/TwoFactor/TwoFactorYubiKeyResponseModel.cs @@ -19,29 +19,29 @@ public class TwoFactorYubiKeyResponseModel : ResponseModel { Enabled = provider.Enabled; - if (provider.MetaData.ContainsKey("Key1")) + if (provider.MetaData.TryGetValue("Key1", out var key1)) { - Key1 = (string)provider.MetaData["Key1"]; + Key1 = (string)key1; } - if (provider.MetaData.ContainsKey("Key2")) + if (provider.MetaData.TryGetValue("Key2", out var key2)) { - Key2 = (string)provider.MetaData["Key2"]; + Key2 = (string)key2; } - if (provider.MetaData.ContainsKey("Key3")) + if (provider.MetaData.TryGetValue("Key3", out var key3)) { - Key3 = (string)provider.MetaData["Key3"]; + Key3 = (string)key3; } - if (provider.MetaData.ContainsKey("Key4")) + if (provider.MetaData.TryGetValue("Key4", out var key4)) { - Key4 = (string)provider.MetaData["Key4"]; + Key4 = (string)key4; } - if (provider.MetaData.ContainsKey("Key5")) + if (provider.MetaData.TryGetValue("Key5", out var key5)) { - Key5 = (string)provider.MetaData["Key5"]; + Key5 = (string)key5; } - if (provider.MetaData.ContainsKey("Nfc")) + if (provider.MetaData.TryGetValue("Nfc", out var nfc)) { - Nfc = (bool)provider.MetaData["Nfc"]; + Nfc = (bool)nfc; } } else diff --git a/src/Api/Billing/Controllers/AccountsBillingController.cs b/src/Api/Billing/Controllers/AccountsBillingController.cs index fcb89226e7..7abcf8c357 100644 --- a/src/Api/Billing/Controllers/AccountsBillingController.cs +++ b/src/Api/Billing/Controllers/AccountsBillingController.cs @@ -1,7 +1,7 @@ #nullable enable using Bit.Api.Billing.Models.Responses; -using Bit.Core.Billing.Models.Api.Requests.Accounts; using Bit.Core.Billing.Services; +using Bit.Core.Billing.Tax.Requests; using Bit.Core.Services; using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; diff --git a/src/Api/Billing/Controllers/AccountsController.cs b/src/Api/Billing/Controllers/AccountsController.cs index 49ff679bb8..10d386641d 100644 --- a/src/Api/Billing/Controllers/AccountsController.cs +++ b/src/Api/Billing/Controllers/AccountsController.cs @@ -6,14 +6,10 @@ using Bit.Api.Utilities; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Billing.Models; using Bit.Core.Billing.Services; -using Bit.Core.Context; using Bit.Core.Exceptions; using Bit.Core.Models.Business; using Bit.Core.Services; using Bit.Core.Settings; -using Bit.Core.Tools.Enums; -using Bit.Core.Tools.Models.Business; -using Bit.Core.Tools.Services; using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -161,8 +157,6 @@ public class AccountsController( [HttpPost("cancel")] public async Task PostCancelAsync( [FromBody] SubscriptionCancellationRequestModel request, - [FromServices] ICurrentContext currentContext, - [FromServices] IReferenceEventService referenceEventService, [FromServices] ISubscriberService subscriberService) { var user = await userService.GetUserByPrincipalAsync(User); @@ -175,12 +169,6 @@ public class AccountsController( await subscriberService.CancelSubscription(user, new OffboardingSurveyResponse { UserId = user.Id, Reason = request.Reason, Feedback = request.Feedback }, user.IsExpired()); - - await referenceEventService.RaiseEventAsync(new ReferenceEvent( - ReferenceEventType.CancelSubscription, - user, - currentContext) - { EndOfPeriod = user.IsExpired() }); } [HttpPost("reinstate-premium")] diff --git a/src/Api/Billing/Controllers/InvoicesController.cs b/src/Api/Billing/Controllers/InvoicesController.cs index 686d9b9643..5a1d732f42 100644 --- a/src/Api/Billing/Controllers/InvoicesController.cs +++ b/src/Api/Billing/Controllers/InvoicesController.cs @@ -1,5 +1,5 @@ using Bit.Core.AdminConsole.Entities; -using Bit.Core.Billing.Models.Api.Requests.Organizations; +using Bit.Core.Billing.Tax.Requests; using Bit.Core.Context; using Bit.Core.Repositories; using Bit.Core.Services; diff --git a/src/Api/Billing/Controllers/OrganizationBillingController.cs b/src/Api/Billing/Controllers/OrganizationBillingController.cs index b82c627ee0..071aae5060 100644 --- a/src/Api/Billing/Controllers/OrganizationBillingController.cs +++ b/src/Api/Billing/Controllers/OrganizationBillingController.cs @@ -4,11 +4,12 @@ using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.Billing.Models.Requests; using Bit.Api.Billing.Models.Responses; using Bit.Api.Billing.Queries.Organizations; -using Bit.Core; using Bit.Core.Billing.Models; using Bit.Core.Billing.Models.Sales; using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Providers.Services; using Bit.Core.Billing.Services; +using Bit.Core.Billing.Tax.Models; using Bit.Core.Context; using Bit.Core.Repositories; using Bit.Core.Services; @@ -23,7 +24,6 @@ namespace Bit.Api.Billing.Controllers; public class OrganizationBillingController( IBusinessUnitConverter businessUnitConverter, ICurrentContext currentContext, - IFeatureService featureService, IOrganizationBillingService organizationBillingService, IOrganizationRepository organizationRepository, IOrganizationWarningsQuery organizationWarningsQuery, @@ -291,14 +291,20 @@ public class OrganizationBillingController( sale.Organization.PlanType = plan.Type; sale.Organization.Plan = plan.Name; sale.SubscriptionSetup.SkipTrial = true; - await organizationBillingService.Finalize(sale); + + if (organizationSignup.PaymentMethodType == null || string.IsNullOrEmpty(organizationSignup.PaymentToken)) + { + return Error.BadRequest("A payment method is required to restart the subscription."); + } var org = await organizationRepository.GetByIdAsync(organizationId); Debug.Assert(org is not null, "This organization has already been found via this same ID, this should be fine."); - if (organizationSignup.PaymentMethodType != null) + var paymentSource = new TokenizedPaymentSource(organizationSignup.PaymentMethodType.Value, organizationSignup.PaymentToken); + var taxInformation = TaxInformation.From(organizationSignup.TaxInfo); + await organizationBillingService.Finalize(sale); + var updatedOrg = await organizationRepository.GetByIdAsync(organizationId); + if (updatedOrg != null) { - var paymentSource = new TokenizedPaymentSource(organizationSignup.PaymentMethodType.Value, organizationSignup.PaymentToken); - var taxInformation = TaxInformation.From(organizationSignup.TaxInfo); - await organizationBillingService.UpdatePaymentMethod(org, paymentSource, taxInformation); + await organizationBillingService.UpdatePaymentMethod(updatedOrg, paymentSource, taxInformation); } return TypedResults.Ok(); @@ -310,14 +316,6 @@ public class OrganizationBillingController( [FromRoute] Guid organizationId, [FromBody] SetupBusinessUnitRequestBody requestBody) { - var enableOrganizationBusinessUnitConversion = - featureService.IsEnabled(FeatureFlagKeys.PM18770_EnableOrganizationBusinessUnitConversion); - - if (!enableOrganizationBusinessUnitConversion) - { - return Error.NotFound(); - } - var organization = await organizationRepository.GetByIdAsync(organizationId); if (organization == null) diff --git a/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs b/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs index c4dc5fae75..c45b34422c 100644 --- a/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs +++ b/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs @@ -222,6 +222,20 @@ public class OrganizationSponsorshipsController : Controller await _revokeSponsorshipCommand.RevokeSponsorshipAsync(existingOrgSponsorship); } + [Authorize("Application")] + [HttpDelete("{sponsoringOrgId}/{sponsoredFriendlyName}/revoke")] + [SelfHosted(NotSelfHostedOnly = true)] + public async Task AdminInitiatedRevokeSponsorshipAsync(Guid sponsoringOrgId, string sponsoredFriendlyName) + { + var sponsorships = await _organizationSponsorshipRepository.GetManyBySponsoringOrganizationAsync(sponsoringOrgId); + var existingOrgSponsorship = sponsorships.FirstOrDefault(s => s.FriendlyName != null && s.FriendlyName.Equals(sponsoredFriendlyName, StringComparison.OrdinalIgnoreCase)); + if (existingOrgSponsorship == null) + { + throw new BadRequestException("The specified sponsored organization could not be found under the given sponsoring organization."); + } + await _revokeSponsorshipCommand.RevokeSponsorshipAsync(existingOrgSponsorship); + } + [Authorize("Application")] [HttpDelete("sponsored/{sponsoredOrgId}")] [HttpPost("sponsored/{sponsoredOrgId}/remove")] diff --git a/src/Api/Billing/Controllers/OrganizationsController.cs b/src/Api/Billing/Controllers/OrganizationsController.cs index 510f6c2835..c8a3c20c91 100644 --- a/src/Api/Billing/Controllers/OrganizationsController.cs +++ b/src/Api/Billing/Controllers/OrganizationsController.cs @@ -20,9 +20,6 @@ using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; -using Bit.Core.Tools.Enums; -using Bit.Core.Tools.Models.Business; -using Bit.Core.Tools.Services; using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -44,7 +41,6 @@ public class OrganizationsController( IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand, IUpgradeOrganizationPlanCommand upgradeOrganizationPlanCommand, IAddSecretsManagerSubscriptionCommand addSecretsManagerSubscriptionCommand, - IReferenceEventService referenceEventService, ISubscriberService subscriberService, IOrganizationInstallationRepository organizationInstallationRepository, IPricingClient pricingClient) @@ -109,28 +105,6 @@ public class OrganizationsController( return license; } - [HttpPost("{id:guid}/payment")] - [SelfHosted(NotSelfHostedOnly = true)] - public async Task PostPayment(Guid id, [FromBody] PaymentRequestModel model) - { - if (!await currentContext.EditPaymentMethods(id)) - { - throw new NotFoundException(); - } - - await organizationService.ReplacePaymentMethodAsync(id, model.PaymentToken, - model.PaymentMethodType.Value, new TaxInfo - { - BillingAddressLine1 = model.Line1, - BillingAddressLine2 = model.Line2, - BillingAddressState = model.State, - BillingAddressCity = model.City, - BillingAddressPostalCode = model.PostalCode, - BillingAddressCountry = model.Country, - TaxIdNumber = model.TaxId, - }); - } - [HttpPost("{id:guid}/upgrade")] [SelfHosted(NotSelfHostedOnly = true)] public async Task PostUpgrade(Guid id, [FromBody] OrganizationUpgradeRequestModel model) @@ -268,14 +242,6 @@ public class OrganizationsController( Feedback = request.Feedback }, organization.IsExpired()); - - await referenceEventService.RaiseEventAsync(new ReferenceEvent( - ReferenceEventType.CancelSubscription, - organization, - currentContext) - { - EndOfPeriod = organization.IsExpired() - }); } [HttpPost("{id:guid}/reinstate")] diff --git a/src/Api/Billing/Controllers/ProviderBillingController.cs b/src/Api/Billing/Controllers/ProviderBillingController.cs index bb1fd7bb25..1309b2df6d 100644 --- a/src/Api/Billing/Controllers/ProviderBillingController.cs +++ b/src/Api/Billing/Controllers/ProviderBillingController.cs @@ -1,11 +1,14 @@ using Bit.Api.Billing.Models.Requests; using Bit.Api.Billing.Models.Responses; +using Bit.Commercial.Core.Billing.Providers.Services; using Bit.Core; using Bit.Core.AdminConsole.Repositories; -using Bit.Core.Billing.Models; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Repositories; +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; @@ -78,13 +81,6 @@ public class ProviderBillingController( [FromRoute] Guid providerId, [FromBody] UpdatePaymentMethodRequestBody requestBody) { - var allowProviderPaymentMethod = featureService.IsEnabled(FeatureFlagKeys.PM18794_ProviderPaymentMethod); - - if (!allowProviderPaymentMethod) - { - return TypedResults.NotFound(); - } - var (provider, result) = await TryGetBillableProviderForAdminOperation(providerId); if (provider == null) @@ -108,13 +104,6 @@ public class ProviderBillingController( [FromRoute] Guid providerId, [FromBody] VerifyBankAccountRequestBody requestBody) { - var allowProviderPaymentMethod = featureService.IsEnabled(FeatureFlagKeys.PM18794_ProviderPaymentMethod); - - if (!allowProviderPaymentMethod) - { - return TypedResults.NotFound(); - } - var (provider, result) = await TryGetBillableProviderForAdminOperation(providerId); if (provider == null) @@ -147,13 +136,33 @@ public class ProviderBillingController( var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id); + var getProviderPriceFromStripe = featureService.IsEnabled(FeatureFlagKeys.PM21383_GetProviderPriceFromStripe); + var configuredProviderPlans = await Task.WhenAll(providerPlans.Select(async providerPlan => { var plan = await pricingClient.GetPlanOrThrow(providerPlan.PlanType); + + decimal unitAmount; + + if (getProviderPriceFromStripe) + { + var priceId = ProviderPriceAdapter.GetPriceId(provider, subscription, plan.Type); + var price = await stripeAdapter.PriceGetAsync(priceId); + + unitAmount = price.UnitAmountDecimal.HasValue + ? price.UnitAmountDecimal.Value / 100M + : plan.PasswordManager.ProviderPortalSeatPrice; + } + else + { + unitAmount = plan.PasswordManager.ProviderPortalSeatPrice; + } + return new ConfiguredProviderPlan( providerPlan.Id, providerPlan.ProviderId, plan, + unitAmount, providerPlan.SeatMinimum ?? 0, providerPlan.PurchasedSeats ?? 0, providerPlan.AllocatedSeats ?? 0); diff --git a/src/Api/Billing/Controllers/StripeController.cs b/src/Api/Billing/Controllers/StripeController.cs index f5e8253bfa..15fccd16f4 100644 --- a/src/Api/Billing/Controllers/StripeController.cs +++ b/src/Api/Billing/Controllers/StripeController.cs @@ -1,4 +1,4 @@ -using Bit.Core.Billing.Services; +using Bit.Core.Billing.Tax.Services; using Bit.Core.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http.HttpResults; diff --git a/src/Api/Billing/Controllers/TaxController.cs b/src/Api/Billing/Controllers/TaxController.cs new file mode 100644 index 0000000000..7b8b9d960f --- /dev/null +++ b/src/Api/Billing/Controllers/TaxController.cs @@ -0,0 +1,36 @@ +using Bit.Api.Billing.Models.Requests; +using Bit.Core.Billing.Tax.Commands; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Bit.Api.Billing.Controllers; + +[Authorize("Application")] +[Route("tax")] +public class TaxController( + IPreviewTaxAmountCommand previewTaxAmountCommand) : BaseBillingController +{ + [HttpPost("preview-amount/organization-trial")] + public async Task PreviewTaxAmountForOrganizationTrialAsync( + [FromBody] PreviewTaxAmountForOrganizationTrialRequestBody requestBody) + { + var parameters = new OrganizationTrialParameters + { + PlanType = requestBody.PlanType, + ProductType = requestBody.ProductType, + TaxInformation = new OrganizationTrialParameters.TaxInformationDTO + { + Country = requestBody.TaxInformation.Country, + PostalCode = requestBody.TaxInformation.PostalCode, + TaxId = requestBody.TaxInformation.TaxId + } + }; + + var result = await previewTaxAmountCommand.Run(parameters); + + return result.Match( + taxAmount => TypedResults.Ok(new { TaxAmount = taxAmount }), + badRequest => Error.BadRequest(badRequest.TranslationKey), + unhandled => Error.ServerError(unhandled.TranslationKey)); + } +} diff --git a/src/Api/Billing/Models/Requests/PreviewTaxAmountForOrganizationTrialRequestBody.cs b/src/Api/Billing/Models/Requests/PreviewTaxAmountForOrganizationTrialRequestBody.cs new file mode 100644 index 0000000000..a3fda0fd6c --- /dev/null +++ b/src/Api/Billing/Models/Requests/PreviewTaxAmountForOrganizationTrialRequestBody.cs @@ -0,0 +1,27 @@ +#nullable enable +using System.ComponentModel.DataAnnotations; +using Bit.Core.Billing.Enums; + +namespace Bit.Api.Billing.Models.Requests; + +public class PreviewTaxAmountForOrganizationTrialRequestBody +{ + [Required] + public PlanType PlanType { get; set; } + + [Required] + public ProductType ProductType { get; set; } + + [Required] public TaxInformationDTO TaxInformation { get; set; } = null!; + + public class TaxInformationDTO + { + [Required] + public string Country { get; set; } = null!; + + [Required] + public string PostalCode { get; set; } = null!; + + public string? TaxId { get; set; } + } +} diff --git a/src/Api/Billing/Models/Requests/TaxInformationRequestBody.cs b/src/Api/Billing/Models/Requests/TaxInformationRequestBody.cs index 32ba2effb2..edc45ce483 100644 --- a/src/Api/Billing/Models/Requests/TaxInformationRequestBody.cs +++ b/src/Api/Billing/Models/Requests/TaxInformationRequestBody.cs @@ -1,5 +1,5 @@ using System.ComponentModel.DataAnnotations; -using Bit.Core.Billing.Models; +using Bit.Core.Billing.Tax.Models; namespace Bit.Api.Billing.Models.Requests; diff --git a/src/Api/Billing/Models/Responses/OrganizationMetadataResponse.cs b/src/Api/Billing/Models/Responses/OrganizationMetadataResponse.cs index 1dfc79be21..341dbceadf 100644 --- a/src/Api/Billing/Models/Responses/OrganizationMetadataResponse.cs +++ b/src/Api/Billing/Models/Responses/OrganizationMetadataResponse.cs @@ -12,7 +12,8 @@ public record OrganizationMetadataResponse( bool IsSubscriptionCanceled, DateTime? InvoiceDueDate, DateTime? InvoiceCreatedDate, - DateTime? SubPeriodEndDate) + DateTime? SubPeriodEndDate, + int OrganizationOccupiedSeats) { public static OrganizationMetadataResponse From(OrganizationMetadata metadata) => new( @@ -25,5 +26,6 @@ public record OrganizationMetadataResponse( metadata.IsSubscriptionCanceled, metadata.InvoiceDueDate, metadata.InvoiceCreatedDate, - metadata.SubPeriodEndDate); + metadata.SubPeriodEndDate, + metadata.OrganizationOccupiedSeats); } diff --git a/src/Api/Billing/Models/Responses/PaymentMethodResponse.cs b/src/Api/Billing/Models/Responses/PaymentMethodResponse.cs index b89c1e9db9..fd248a0a00 100644 --- a/src/Api/Billing/Models/Responses/PaymentMethodResponse.cs +++ b/src/Api/Billing/Models/Responses/PaymentMethodResponse.cs @@ -1,4 +1,5 @@ using Bit.Core.Billing.Models; +using Bit.Core.Billing.Tax.Models; namespace Bit.Api.Billing.Models.Responses; diff --git a/src/Api/Billing/Models/Responses/ProviderSubscriptionResponse.cs b/src/Api/Billing/Models/Responses/ProviderSubscriptionResponse.cs index ea1479c9df..e5b868af9a 100644 --- a/src/Api/Billing/Models/Responses/ProviderSubscriptionResponse.cs +++ b/src/Api/Billing/Models/Responses/ProviderSubscriptionResponse.cs @@ -2,6 +2,8 @@ using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Models; +using Bit.Core.Billing.Providers.Models; +using Bit.Core.Billing.Tax.Models; using Stripe; namespace Bit.Api.Billing.Models.Responses; @@ -34,7 +36,7 @@ public record ProviderSubscriptionResponse( .Select(providerPlan => { var plan = providerPlan.Plan; - var cost = (providerPlan.SeatMinimum + providerPlan.PurchasedSeats) * plan.PasswordManager.ProviderPortalSeatPrice; + var cost = (providerPlan.SeatMinimum + providerPlan.PurchasedSeats) * providerPlan.Price; var cadence = plan.IsAnnual ? _annualCadence : _monthlyCadence; return new ProviderPlanResponse( plan.Name, diff --git a/src/Api/Billing/Models/Responses/TaxInformationResponse.cs b/src/Api/Billing/Models/Responses/TaxInformationResponse.cs index 02349d74f7..59e4934751 100644 --- a/src/Api/Billing/Models/Responses/TaxInformationResponse.cs +++ b/src/Api/Billing/Models/Responses/TaxInformationResponse.cs @@ -1,4 +1,4 @@ -using Bit.Core.Billing.Models; +using Bit.Core.Billing.Tax.Models; namespace Bit.Api.Billing.Models.Responses; diff --git a/src/Api/Controllers/SelfHosted/SelfHostedOrganizationSponsorshipsController.cs b/src/Api/Controllers/SelfHosted/SelfHostedOrganizationSponsorshipsController.cs index e328b7c3e4..371b321a4c 100644 --- a/src/Api/Controllers/SelfHosted/SelfHostedOrganizationSponsorshipsController.cs +++ b/src/Api/Controllers/SelfHosted/SelfHostedOrganizationSponsorshipsController.cs @@ -1,6 +1,10 @@ -using Bit.Api.Models.Request.Organizations; +using Bit.Api.AdminConsole.Authorization.Requirements; +using Bit.Api.Models.Request.Organizations; +using Bit.Api.Models.Response; using Bit.Core.Context; using Bit.Core.Exceptions; +using Bit.Core.Models.Api.Response.OrganizationSponsorships; +using Bit.Core.Models.Data.Organizations.OrganizationSponsorships; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.Repositories; using Bit.Core.Services; @@ -22,6 +26,7 @@ public class SelfHostedOrganizationSponsorshipsController : Controller private readonly IRevokeSponsorshipCommand _revokeSponsorshipCommand; private readonly ICurrentContext _currentContext; private readonly IFeatureService _featureService; + private readonly IAuthorizationService _authorizationService; public SelfHostedOrganizationSponsorshipsController( ICreateSponsorshipCommand offerSponsorshipCommand, @@ -30,7 +35,8 @@ public class SelfHostedOrganizationSponsorshipsController : Controller IOrganizationSponsorshipRepository organizationSponsorshipRepository, IOrganizationUserRepository organizationUserRepository, ICurrentContext currentContext, - IFeatureService featureService + IFeatureService featureService, + IAuthorizationService authorizationService ) { _offerSponsorshipCommand = offerSponsorshipCommand; @@ -40,6 +46,7 @@ public class SelfHostedOrganizationSponsorshipsController : Controller _organizationUserRepository = organizationUserRepository; _currentContext = currentContext; _featureService = featureService; + _authorizationService = authorizationService; } [HttpPost("{sponsoringOrgId}/families-for-enterprise")] @@ -84,4 +91,41 @@ public class SelfHostedOrganizationSponsorshipsController : Controller await _revokeSponsorshipCommand.RevokeSponsorshipAsync(existingOrgSponsorship); } + + [HttpDelete("{sponsoringOrgId}/{sponsoredFriendlyName}/revoke")] + public async Task AdminInitiatedRevokeSponsorshipAsync(Guid sponsoringOrgId, string sponsoredFriendlyName) + { + var sponsorships = await _organizationSponsorshipRepository.GetManyBySponsoringOrganizationAsync(sponsoringOrgId); + var existingOrgSponsorship = sponsorships.FirstOrDefault(s => s.FriendlyName != null && s.FriendlyName.Equals(sponsoredFriendlyName, StringComparison.OrdinalIgnoreCase)); + if (existingOrgSponsorship == null) + { + throw new BadRequestException("The specified sponsored organization could not be found under the given sponsoring organization."); + } + await _revokeSponsorshipCommand.RevokeSponsorshipAsync(existingOrgSponsorship); + } + + [Authorize("Application")] + [HttpGet("{orgId}/sponsored")] + public async Task> GetSponsoredOrganizations(Guid orgId) + { + var sponsoringOrg = await _organizationRepository.GetByIdAsync(orgId); + if (sponsoringOrg == null) + { + throw new NotFoundException(); + } + + var authorizationResult = await _authorizationService.AuthorizeAsync(User, orgId, new ManageUsersRequirement()); + if (!authorizationResult.Succeeded) + { + throw new UnauthorizedAccessException(); + } + + var sponsorships = await _organizationSponsorshipRepository.GetManyBySponsoringOrganizationAsync(orgId); + return new ListResponseModel( + sponsorships + .Where(s => s.IsAdminInitiated) + .Select(s => new OrganizationSponsorshipInvitesResponseModel(new OrganizationSponsorshipData(s))) + ); + + } } diff --git a/src/Api/Dirt/Models/Response/MemberCipherDetailsResponseModel.cs b/src/Api/Dirt/Models/Response/MemberCipherDetailsResponseModel.cs index 5c87264c51..d927da8123 100644 --- a/src/Api/Dirt/Models/Response/MemberCipherDetailsResponseModel.cs +++ b/src/Api/Dirt/Models/Response/MemberCipherDetailsResponseModel.cs @@ -4,18 +4,20 @@ namespace Bit.Api.Tools.Models.Response; public class MemberCipherDetailsResponseModel { + public Guid? UserGuid { get; set; } public string UserName { get; set; } public string Email { get; set; } public bool UsesKeyConnector { get; set; } /// - /// A distinct list of the cipher ids associated with + /// A distinct list of the cipher ids associated with /// the organization member /// public IEnumerable CipherIds { get; set; } public MemberCipherDetailsResponseModel(MemberAccessCipherDetails memberAccessCipherDetails) { + this.UserGuid = memberAccessCipherDetails.UserGuid; this.UserName = memberAccessCipherDetails.UserName; this.Email = memberAccessCipherDetails.Email; this.UsesKeyConnector = memberAccessCipherDetails.UsesKeyConnector; diff --git a/src/Api/Dockerfile b/src/Api/Dockerfile index 6970dfa7bb..29adde878c 100644 --- a/src/Api/Dockerfile +++ b/src/Api/Dockerfile @@ -1,6 +1,50 @@ +############################################### +# Build stage # +############################################### +FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0 AS build + +# Docker buildx supplies the value for this arg +ARG TARGETPLATFORM + +# Determine proper runtime value for .NET +# We put the value in a file to be read by later layers. +RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \ + RID=linux-x64 ; \ + elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \ + RID=linux-arm64 ; \ + elif [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then \ + RID=linux-arm ; \ + fi \ + && echo "RID=$RID" > /tmp/rid.txt + +# Copy required project files +WORKDIR /source +COPY . ./ + +# Restore project dependencies and tools +WORKDIR /source/src/Api +RUN . /tmp/rid.txt && dotnet restore -r $RID + +# Build project +RUN . /tmp/rid.txt && dotnet publish \ + -c release \ + --no-restore \ + --self-contained \ + /p:PublishSingleFile=true \ + -r $RID \ + -o out + +############################################### +# App stage # +############################################### FROM mcr.microsoft.com/dotnet/aspnet:8.0 +ARG TARGETPLATFORM LABEL com.bitwarden.product="bitwarden" +ENV ASPNETCORE_ENVIRONMENT=Production +ENV ASPNETCORE_URLS=http://+:5000 +ENV SSL_CERT_DIR=/etc/bitwarden/ca-certificates +EXPOSE 5000 RUN apt-get update \ && apt-get install -y --no-install-recommends \ @@ -9,13 +53,11 @@ RUN apt-get update \ krb5-user \ && rm -rf /var/lib/apt/lists/* -ENV ASPNETCORE_URLS http://+:5000 +# Copy app from the build stage WORKDIR /app -EXPOSE 5000 -COPY obj/build-output/publish . -COPY entrypoint.sh / +COPY --from=build /source/src/Api/out /app +COPY ./src/Api/entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh - HEALTHCHECK CMD curl -f http://localhost:5000/alive || exit 1 ENTRYPOINT ["/entrypoint.sh"] diff --git a/src/Api/KeyManagement/Validators/SendRotationValidator.cs b/src/Api/KeyManagement/Validators/SendRotationValidator.cs index c39f563b51..10a5d996b7 100644 --- a/src/Api/KeyManagement/Validators/SendRotationValidator.cs +++ b/src/Api/KeyManagement/Validators/SendRotationValidator.cs @@ -12,17 +12,17 @@ namespace Bit.Api.KeyManagement.Validators; /// public class SendRotationValidator : IRotationValidator, IReadOnlyList> { - private readonly ISendService _sendService; + private readonly ISendAuthorizationService _sendAuthorizationService; private readonly ISendRepository _sendRepository; /// /// Instantiates a new /// - /// Enables conversion of to + /// Enables conversion of to /// Retrieves all user s - public SendRotationValidator(ISendService sendService, ISendRepository sendRepository) + public SendRotationValidator(ISendAuthorizationService sendAuthorizationService, ISendRepository sendRepository) { - _sendService = sendService; + _sendAuthorizationService = sendAuthorizationService; _sendRepository = sendRepository; } @@ -44,7 +44,7 @@ public class SendRotationValidator : IRotationValidator new BaseSecretResponseModel(s)); return new ListResponseModel(responses); @@ -303,21 +290,18 @@ public class SecretsController : Controller if (syncResult.HasChanges) { - await LogSecretsRetrievalAsync(organizationId, syncResult.Secrets); + await LogSecretsRetrievalAsync(syncResult.Secrets); } return new SecretsSyncResponseModel(syncResult.HasChanges, syncResult.Secrets); } - private async Task LogSecretsRetrievalAsync(Guid organizationId, IEnumerable secrets) + private async Task LogSecretsRetrievalAsync(IEnumerable secrets) { if (_currentContext.IdentityClientType == IdentityClientType.ServiceAccount) { var userId = _userService.GetProperUserId(User)!.Value; - var org = await _organizationRepository.GetByIdAsync(organizationId); await _eventService.LogServiceAccountSecretsEventAsync(userId, secrets, EventType.Secret_Retrieved); - await _referenceEventService.RaiseEventAsync( - new ReferenceEvent(ReferenceEventType.SmServiceAccountAccessedSecret, org, _currentContext)); } } } diff --git a/src/Api/Startup.cs b/src/Api/Startup.cs index 1cc371ae1b..e24f96a7a9 100644 --- a/src/Api/Startup.cs +++ b/src/Api/Startup.cs @@ -28,13 +28,12 @@ using Bit.Core.Tools.Entities; using Bit.Core.Vault.Entities; using Bit.Api.Auth.Models.Request.WebAuthn; using Bit.Api.Billing; -using Bit.Core.AdminConsole.Services.NoopImplementations; using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Identity.TokenProviders; -using Bit.Core.Services; using Bit.Core.Tools.ImportFeatures; using Bit.Core.Tools.ReportFeatures; using Bit.Core.Auth.Models.Api.Request; +using Bit.Core.Tools.SendFeatures; #if !OSS using Bit.Commercial.Core.SecretsManager; @@ -186,6 +185,7 @@ public class Startup services.AddPhishingDomainServices(globalSettings); services.AddBillingQueries(); + services.AddSendServices(); // Authorization Handlers services.AddAuthorizationHandlers(); @@ -222,18 +222,8 @@ public class Startup services.AddHostedService(); } - // Slack - if (CoreHelpers.SettingHasValue(globalSettings.Slack.ClientId) && - CoreHelpers.SettingHasValue(globalSettings.Slack.ClientSecret) && - CoreHelpers.SettingHasValue(globalSettings.Slack.Scopes)) - { - services.AddHttpClient(SlackService.HttpClientName); - services.AddSingleton(); - } - else - { - services.AddSingleton(); - } + // Add SlackService for OAuth API requests - if configured + services.AddSlackService(globalSettings); } public void Configure( diff --git a/src/Api/Tools/Controllers/SendsController.cs b/src/Api/Tools/Controllers/SendsController.cs index 3b5534bed0..a51ec942cf 100644 --- a/src/Api/Tools/Controllers/SendsController.cs +++ b/src/Api/Tools/Controllers/SendsController.cs @@ -5,13 +5,14 @@ using Bit.Api.Tools.Models.Request; using Bit.Api.Tools.Models.Response; using Bit.Api.Utilities; using Bit.Core; -using Bit.Core.Context; using Bit.Core.Exceptions; 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.Services; using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; @@ -25,30 +26,34 @@ public class SendsController : Controller { private readonly ISendRepository _sendRepository; private readonly IUserService _userService; - private readonly ISendService _sendService; + private readonly ISendAuthorizationService _sendAuthorizationService; private readonly ISendFileStorageService _sendFileStorageService; + private readonly IAnonymousSendCommand _anonymousSendCommand; + private readonly INonAnonymousSendCommand _nonAnonymousSendCommand; private readonly ILogger _logger; private readonly GlobalSettings _globalSettings; - private readonly ICurrentContext _currentContext; public SendsController( ISendRepository sendRepository, IUserService userService, - ISendService sendService, + ISendAuthorizationService sendAuthorizationService, + IAnonymousSendCommand anonymousSendCommand, + INonAnonymousSendCommand nonAnonymousSendCommand, ISendFileStorageService sendFileStorageService, ILogger logger, - GlobalSettings globalSettings, - ICurrentContext currentContext) + GlobalSettings globalSettings) { _sendRepository = sendRepository; _userService = userService; - _sendService = sendService; + _sendAuthorizationService = sendAuthorizationService; + _anonymousSendCommand = anonymousSendCommand; + _nonAnonymousSendCommand = nonAnonymousSendCommand; _sendFileStorageService = sendFileStorageService; _logger = logger; _globalSettings = globalSettings; - _currentContext = currentContext; } + #region Anonymous endpoints [AllowAnonymous] [HttpPost("access/{id}")] public async Task Access(string id, [FromBody] SendAccessRequestModel model) @@ -61,18 +66,19 @@ public class SendsController : Controller //} var guid = new Guid(CoreHelpers.Base64UrlDecode(id)); - var (send, passwordRequired, passwordInvalid) = - await _sendService.AccessAsync(guid, model.Password); - if (passwordRequired) + var send = await _sendRepository.GetByIdAsync(guid); + SendAccessResult sendAuthResult = + await _sendAuthorizationService.AccessAsync(send, model.Password); + if (sendAuthResult.Equals(SendAccessResult.PasswordRequired)) { return new UnauthorizedResult(); } - if (passwordInvalid) + if (sendAuthResult.Equals(SendAccessResult.PasswordInvalid)) { await Task.Delay(2000); throw new BadRequestException("Invalid password."); } - if (send == null) + if (sendAuthResult.Equals(SendAccessResult.Denied)) { throw new NotFoundException(); } @@ -106,19 +112,19 @@ public class SendsController : Controller throw new BadRequestException("Could not locate send"); } - var (url, passwordRequired, passwordInvalid) = await _sendService.GetSendFileDownloadUrlAsync(send, fileId, + var (url, result) = await _anonymousSendCommand.GetSendFileDownloadUrlAsync(send, fileId, model.Password); - if (passwordRequired) + if (result.Equals(SendAccessResult.PasswordRequired)) { return new UnauthorizedResult(); } - if (passwordInvalid) + if (result.Equals(SendAccessResult.PasswordInvalid)) { await Task.Delay(2000); throw new BadRequestException("Invalid password."); } - if (send == null) + if (result.Equals(SendAccessResult.Denied)) { throw new NotFoundException(); } @@ -130,6 +136,45 @@ public class SendsController : Controller }); } + [AllowAnonymous] + [HttpPost("file/validate/azure")] + public async Task AzureValidateFile() + { + return await ApiHelpers.HandleAzureEvents(Request, new Dictionary> + { + { + "Microsoft.Storage.BlobCreated", async (eventGridEvent) => + { + try + { + 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) + { + if (_sendFileStorageService is AzureSendFileStorageService azureSendFileStorageService) + { + await azureSendFileStorageService.DeleteBlobAsync(blobName); + } + return; + } + + await _nonAnonymousSendCommand.ConfirmFileSize(send); + } + catch (Exception e) + { + _logger.LogError(e, $"Uncaught exception occurred while handling event grid event: {JsonSerializer.Serialize(eventGridEvent)}"); + return; + } + } + } + }); + } + + #endregion + + #region Non-anonymous endpoints + [HttpGet("{id}")] public async Task Get(string id) { @@ -157,8 +202,8 @@ public class SendsController : Controller { model.ValidateCreation(); var userId = _userService.GetProperUserId(User).Value; - var send = model.ToSend(userId, _sendService); - await _sendService.SaveSendAsync(send); + var send = model.ToSend(userId, _sendAuthorizationService); + await _nonAnonymousSendCommand.SaveSendAsync(send); return new SendResponseModel(send, _globalSettings); } @@ -175,15 +220,15 @@ public class SendsController : Controller throw new BadRequestException("Invalid content. File size hint is required."); } - if (model.FileLength.Value > SendService.MAX_FILE_SIZE) + if (model.FileLength.Value > Constants.FileSize501mb) { - throw new BadRequestException($"Max file size is {SendService.MAX_FILE_SIZE_READABLE}."); + throw new BadRequestException($"Max file size is {SendFileSettingHelper.MAX_FILE_SIZE_READABLE}."); } model.ValidateCreation(); var userId = _userService.GetProperUserId(User).Value; - var (send, data) = model.ToSend(userId, model.File.FileName, _sendService); - var uploadUrl = await _sendService.SaveFileSendAsync(send, data, model.FileLength.Value); + 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, @@ -230,41 +275,7 @@ public class SendsController : Controller var send = await _sendRepository.GetByIdAsync(new Guid(id)); await Request.GetFileAsync(async (stream) => { - await _sendService.UploadFileToExistingSendAsync(stream, send); - }); - } - - [AllowAnonymous] - [HttpPost("file/validate/azure")] - public async Task AzureValidateFile() - { - return await ApiHelpers.HandleAzureEvents(Request, new Dictionary> - { - { - "Microsoft.Storage.BlobCreated", async (eventGridEvent) => - { - try - { - 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) - { - if (_sendFileStorageService is AzureSendFileStorageService azureSendFileStorageService) - { - await azureSendFileStorageService.DeleteBlobAsync(blobName); - } - return; - } - await _sendService.ValidateSendFile(send); - } - catch (Exception e) - { - _logger.LogError(e, $"Uncaught exception occurred while handling event grid event: {JsonSerializer.Serialize(eventGridEvent)}"); - return; - } - } - } + await _nonAnonymousSendCommand.UploadFileToExistingSendAsync(stream, send); }); } @@ -279,7 +290,7 @@ public class SendsController : Controller throw new NotFoundException(); } - await _sendService.SaveSendAsync(model.ToSend(send, _sendService)); + await _nonAnonymousSendCommand.SaveSendAsync(model.ToSend(send, _sendAuthorizationService)); return new SendResponseModel(send, _globalSettings); } @@ -294,7 +305,7 @@ public class SendsController : Controller } send.Password = null; - await _sendService.SaveSendAsync(send); + await _nonAnonymousSendCommand.SaveSendAsync(send); return new SendResponseModel(send, _globalSettings); } @@ -308,6 +319,8 @@ public class SendsController : Controller throw new NotFoundException(); } - await _sendService.DeleteSendAsync(send); + await _nonAnonymousSendCommand.DeleteSendAsync(send); } + + #endregion } diff --git a/src/Api/Tools/Models/Request/SendRequestModel.cs b/src/Api/Tools/Models/Request/SendRequestModel.cs index 660ff41e3a..5b3fd7ba31 100644 --- a/src/Api/Tools/Models/Request/SendRequestModel.cs +++ b/src/Api/Tools/Models/Request/SendRequestModel.cs @@ -36,31 +36,31 @@ public class SendRequestModel public bool? Disabled { get; set; } public bool? HideEmail { get; set; } - public Send ToSend(Guid userId, ISendService sendService) + public Send ToSend(Guid userId, ISendAuthorizationService sendAuthorizationService) { var send = new Send { Type = Type, UserId = (Guid?)userId }; - ToSend(send, sendService); + ToSend(send, sendAuthorizationService); return send; } - public (Send, SendFileData) ToSend(Guid userId, string fileName, ISendService sendService) + public (Send, SendFileData) ToSend(Guid userId, string fileName, ISendAuthorizationService sendAuthorizationService) { var send = ToSendBase(new Send { Type = Type, UserId = (Guid?)userId - }, sendService); + }, sendAuthorizationService); var data = new SendFileData(Name, Notes, fileName); return (send, data); } - public Send ToSend(Send existingSend, ISendService sendService) + public Send ToSend(Send existingSend, ISendAuthorizationService sendAuthorizationService) { - existingSend = ToSendBase(existingSend, sendService); + existingSend = ToSendBase(existingSend, sendAuthorizationService); switch (existingSend.Type) { case SendType.File: @@ -125,7 +125,7 @@ public class SendRequestModel } } - private Send ToSendBase(Send existingSend, ISendService sendService) + private Send ToSendBase(Send existingSend, ISendAuthorizationService authorizationService) { existingSend.Key = Key; existingSend.ExpirationDate = ExpirationDate; @@ -133,7 +133,7 @@ public class SendRequestModel existingSend.MaxAccessCount = MaxAccessCount; if (!string.IsNullOrWhiteSpace(Password)) { - existingSend.Password = sendService.HashPassword(Password); + existingSend.Password = authorizationService.HashPassword(Password); } existingSend.Disabled = Disabled.GetValueOrDefault(); existingSend.HideEmail = HideEmail.GetValueOrDefault(); diff --git a/src/Api/Utilities/ApiHelpers.cs b/src/Api/Utilities/ApiHelpers.cs index f4f1830e16..2c6dc8b73b 100644 --- a/src/Api/Utilities/ApiHelpers.cs +++ b/src/Api/Utilities/ApiHelpers.cs @@ -62,9 +62,9 @@ public static class ApiHelpers } } - if (eventTypeHandlers.ContainsKey(eventGridEvent.EventType)) + if (eventTypeHandlers.TryGetValue(eventGridEvent.EventType, out var eventTypeHandler)) { - await eventTypeHandlers[eventGridEvent.EventType](eventGridEvent); + await eventTypeHandler(eventGridEvent); } } diff --git a/src/Api/Vault/Controllers/CiphersController.cs b/src/Api/Vault/Controllers/CiphersController.cs index 03b83e3de2..92b611f588 100644 --- a/src/Api/Vault/Controllers/CiphersController.cs +++ b/src/Api/Vault/Controllers/CiphersController.cs @@ -151,6 +151,16 @@ public class CiphersController : Controller public async Task Post([FromBody] CipherRequestModel model) { var user = await _userService.GetUserByPrincipalAsync(User); + + // Validate the model was encrypted for the posting user + if (model.EncryptedFor != null) + { + if (model.EncryptedFor != user.Id) + { + throw new BadRequestException("Cipher was not encrypted for the current user. Please try again."); + } + } + var cipher = model.ToCipherDetails(user.Id); if (cipher.OrganizationId.HasValue && !await _currentContext.OrganizationUser(cipher.OrganizationId.Value)) { @@ -170,6 +180,16 @@ public class CiphersController : Controller public async Task PostCreate([FromBody] CipherCreateRequestModel model) { var user = await _userService.GetUserByPrincipalAsync(User); + + // Validate the model was encrypted for the posting user + if (model.Cipher.EncryptedFor != null) + { + if (model.Cipher.EncryptedFor != user.Id) + { + throw new BadRequestException("Cipher was not encrypted for the current user. Please try again."); + } + } + var cipher = model.Cipher.ToCipherDetails(user.Id); if (cipher.OrganizationId.HasValue && !await _currentContext.OrganizationUser(cipher.OrganizationId.Value)) { @@ -192,6 +212,16 @@ public class CiphersController : Controller } var userId = _userService.GetProperUserId(User).Value; + + // Validate the model was encrypted for the posting user + if (model.Cipher.EncryptedFor != null) + { + if (model.Cipher.EncryptedFor != userId) + { + throw new BadRequestException("Cipher was not encrypted for the current user. Please try again."); + } + } + await _cipherService.SaveAsync(cipher, userId, model.Cipher.LastKnownRevisionDate, model.CollectionIds, true, false); var response = new CipherMiniResponseModel(cipher, _globalSettings, false); @@ -209,6 +239,15 @@ public class CiphersController : Controller throw new NotFoundException(); } + // Validate the model was encrypted for the posting user + if (model.EncryptedFor != null) + { + if (model.EncryptedFor != user.Id) + { + throw new BadRequestException("Cipher was not encrypted for the current user. Please try again."); + } + } + ValidateClientVersionForFido2CredentialSupport(cipher); var collectionIds = (await _collectionCipherRepository.GetManyByUserIdCipherIdAsync(user.Id, id)).Select(c => c.CollectionId).ToList(); @@ -237,6 +276,15 @@ public class CiphersController : Controller var userId = _userService.GetProperUserId(User).Value; var cipher = await _cipherRepository.GetOrganizationDetailsByIdAsync(id); + // Validate the model was encrypted for the posting user + if (model.EncryptedFor != null) + { + if (model.EncryptedFor != userId) + { + throw new BadRequestException("Cipher was not encrypted for the current user. Please try again."); + } + } + ValidateClientVersionForFido2CredentialSupport(cipher); if (cipher == null || !cipher.OrganizationId.HasValue || @@ -315,26 +363,10 @@ public class CiphersController : Controller { var org = _currentContext.GetOrganization(organizationId); - // If we're not an "admin", we don't need to check the ciphers - if (org is not ({ Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or { Permissions.EditAnyCollection: true })) + // If we're not an "admin" or if we're not a provider user we don't need to check the ciphers + if (org is not ({ Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or { Permissions.EditAnyCollection: true }) || await _currentContext.ProviderUserForOrgAsync(organizationId)) { - // Are we a provider user? If so, we need to be sure we're not restricted - // Once the feature flag is removed, this check can be combined with the above - if (await _currentContext.ProviderUserForOrgAsync(organizationId)) - { - // Provider is restricted from editing ciphers, so we're not an "admin" - if (_featureService.IsEnabled(FeatureFlagKeys.RestrictProviderAccess)) - { - return false; - } - - // Provider is unrestricted, so we're an "admin", don't return early - } - else - { - // Not a provider or admin - return false; - } + return false; } // We know we're an "admin", now check the ciphers explicitly (in case admins are restricted) @@ -350,26 +382,10 @@ public class CiphersController : Controller var org = _currentContext.GetOrganization(organizationId); - // If we're not an "admin", we don't need to check the ciphers - if (org is not ({ Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or { Permissions.EditAnyCollection: true })) + // If we're not an "admin" or if we're a provider user we don't need to check the ciphers + if (org is not ({ Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or { Permissions.EditAnyCollection: true }) || await _currentContext.ProviderUserForOrgAsync(organizationId)) { - // Are we a provider user? If so, we need to be sure we're not restricted - // Once the feature flag is removed, this check can be combined with the above - if (await _currentContext.ProviderUserForOrgAsync(organizationId)) - { - // Provider is restricted from editing ciphers, so we're not an "admin" - if (_featureService.IsEnabled(FeatureFlagKeys.RestrictProviderAccess)) - { - return false; - } - - // Provider is unrestricted, so we're an "admin", don't return early - } - else - { - // Not a provider or admin - return false; - } + return false; } // If the user can edit all ciphers for the organization, just check they all belong to the org @@ -462,10 +478,10 @@ public class CiphersController : Controller return true; } - // Provider users can edit all ciphers if RestrictProviderAccess is disabled + // Provider users cannot edit ciphers if (await _currentContext.ProviderUserForOrgAsync(organizationId)) { - return !_featureService.IsEnabled(FeatureFlagKeys.RestrictProviderAccess); + return false; } return false; @@ -485,10 +501,10 @@ public class CiphersController : Controller return true; } - // Provider users can only access organization ciphers if RestrictProviderAccess is disabled + // Provider users cannot access organization ciphers if (await _currentContext.ProviderUserForOrgAsync(organizationId)) { - return !_featureService.IsEnabled(FeatureFlagKeys.RestrictProviderAccess); + return false; } return false; @@ -508,10 +524,10 @@ public class CiphersController : Controller return true; } - // Provider users can only access all ciphers if RestrictProviderAccess is disabled + // Provider users cannot access ciphers if (await _currentContext.ProviderUserForOrgAsync(organizationId)) { - return !_featureService.IsEnabled(FeatureFlagKeys.RestrictProviderAccess); + return false; } return false; @@ -690,6 +706,15 @@ public class CiphersController : Controller throw new NotFoundException(); } + // Validate the model was encrypted for the posting user + if (model.Cipher.EncryptedFor != null) + { + if (model.Cipher.EncryptedFor != user.Id) + { + throw new BadRequestException("Cipher was not encrypted for the current user. Please try again."); + } + } + ValidateClientVersionForFido2CredentialSupport(cipher); var original = cipher.Clone(); @@ -1039,7 +1064,7 @@ public class CiphersController : Controller [HttpPut("share")] [HttpPost("share")] - public async Task PutShareMany([FromBody] CipherBulkShareRequestModel model) + public async Task PutShareMany([FromBody] CipherBulkShareRequestModel model) { var organizationId = new Guid(model.Ciphers.First().OrganizationId); if (!await _currentContext.OrganizationUser(organizationId)) @@ -1048,26 +1073,40 @@ public class CiphersController : Controller } var userId = _userService.GetProperUserId(User).Value; + var ciphers = await _cipherRepository.GetManyByUserIdAsync(userId, withOrganizations: false); var ciphersDict = ciphers.ToDictionary(c => c.Id); + // Validate the model was encrypted for the posting user + foreach (var cipher in model.Ciphers) + { + if (cipher.EncryptedFor.HasValue && cipher.EncryptedFor.Value != userId) + { + throw new BadRequestException("Cipher was not encrypted for the current user. Please try again."); + } + } + var shareCiphers = new List<(Cipher, DateTime?)>(); foreach (var cipher in model.Ciphers) { - if (!ciphersDict.ContainsKey(cipher.Id.Value)) + if (!ciphersDict.TryGetValue(cipher.Id.Value, out var existingCipher)) { - throw new BadRequestException("Trying to move ciphers that you do not own."); + throw new BadRequestException("Trying to share ciphers that you do not own."); } - var existingCipher = ciphersDict[cipher.Id.Value]; - ValidateClientVersionForFido2CredentialSupport(existingCipher); - shareCiphers.Add((cipher.ToCipher(existingCipher), cipher.LastKnownRevisionDate)); + shareCiphers.Add(((Cipher)existingCipher, cipher.LastKnownRevisionDate)); } - await _cipherService.ShareManyAsync(shareCiphers, organizationId, - model.CollectionIds.Select(c => new Guid(c)), userId); + var updated = await _cipherService.ShareManyAsync( + shareCiphers, + organizationId, + model.CollectionIds.Select(Guid.Parse), + userId + ); + + return updated.Select(c => new CipherMiniResponseModel(c, _globalSettings, false)).ToArray(); } [HttpPost("purge")] @@ -1086,9 +1125,8 @@ public class CiphersController : Controller throw new BadRequestException(ModelState); } - // If Account Deprovisioning is enabled, we need to check if the user is claimed by any organization. - if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) - && await _userService.IsClaimedByAnyOrganizationAsync(user.Id)) + // Check if the user is claimed by any organization. + if (await _userService.IsClaimedByAnyOrganizationAsync(user.Id)) { throw new BadRequestException("Cannot purge accounts owned by an organization. Contact your organization administrator for additional details."); } @@ -1150,14 +1188,14 @@ public class CiphersController : Controller var cipher = await GetByIdAsync(id, userId); var attachments = cipher?.GetAttachments(); - if (attachments == null || !attachments.ContainsKey(attachmentId) || attachments[attachmentId].Validated) + if (attachments == null || !attachments.TryGetValue(attachmentId, out var attachment) || attachment.Validated) { throw new NotFoundException(); } return new AttachmentUploadDataResponseModel { - Url = await _attachmentStorageService.GetAttachmentUploadUrlAsync(cipher, attachments[attachmentId]), + Url = await _attachmentStorageService.GetAttachmentUploadUrlAsync(cipher, attachment), FileUploadType = _attachmentStorageService.FileUploadType, }; } @@ -1176,11 +1214,10 @@ public class CiphersController : Controller var userId = _userService.GetProperUserId(User).Value; var cipher = await GetByIdAsync(id, userId); var attachments = cipher?.GetAttachments(); - if (attachments == null || !attachments.ContainsKey(attachmentId)) + if (attachments == null || !attachments.TryGetValue(attachmentId, out var attachmentData)) { throw new NotFoundException(); } - var attachmentData = attachments[attachmentId]; await Request.GetFileAsync(async (stream) => { @@ -1330,7 +1367,7 @@ public class CiphersController : Controller var cipher = await _cipherRepository.GetByIdAsync(new Guid(cipherId)); var attachments = cipher?.GetAttachments() ?? new Dictionary(); - if (cipher == null || !attachments.ContainsKey(attachmentId) || attachments[attachmentId].Validated) + if (cipher == null || !attachments.TryGetValue(attachmentId, out var attachment) || attachment.Validated) { if (_attachmentStorageService is AzureSendFileStorageService azureFileStorageService) { @@ -1340,7 +1377,7 @@ public class CiphersController : Controller return; } - await _cipherService.ValidateCipherAttachmentFile(cipher, attachments[attachmentId]); + await _cipherService.ValidateCipherAttachmentFile(cipher, attachment); } catch (Exception e) { diff --git a/src/Api/Vault/Models/Request/CipherRequestModel.cs b/src/Api/Vault/Models/Request/CipherRequestModel.cs index 89eda415b1..229d27e484 100644 --- a/src/Api/Vault/Models/Request/CipherRequestModel.cs +++ b/src/Api/Vault/Models/Request/CipherRequestModel.cs @@ -11,6 +11,10 @@ namespace Bit.Api.Vault.Models.Request; public class CipherRequestModel { + /// + /// The Id of the user that encrypted the cipher. It should always represent a UserId. + /// + public Guid? EncryptedFor { get; set; } public CipherType Type { get; set; } [StringLength(36)] @@ -109,18 +113,25 @@ public class CipherRequestModel if (hasAttachments2) { - foreach (var attachment in attachments.Where(a => Attachments2.ContainsKey(a.Key))) + foreach (var attachment in attachments) { - var attachment2 = Attachments2[attachment.Key]; + if (!Attachments2.TryGetValue(attachment.Key, out var attachment2)) + { + continue; + } attachment.Value.FileName = attachment2.FileName; attachment.Value.Key = attachment2.Key; } } else if (hasAttachments) { - foreach (var attachment in attachments.Where(a => Attachments.ContainsKey(a.Key))) + foreach (var attachment in attachments) { - attachment.Value.FileName = Attachments[attachment.Key]; + if (!Attachments.TryGetValue(attachment.Key, out var attachmentForKey)) + { + continue; + } + attachment.Value.FileName = attachmentForKey; attachment.Value.Key = null; } } diff --git a/src/Api/Vault/Models/Response/CipherResponseModel.cs b/src/Api/Vault/Models/Response/CipherResponseModel.cs index 358da3e62a..240783837e 100644 --- a/src/Api/Vault/Models/Response/CipherResponseModel.cs +++ b/src/Api/Vault/Models/Response/CipherResponseModel.cs @@ -129,13 +129,13 @@ public class CipherDetailsResponseModel : CipherResponseModel IDictionary> collectionCiphers, string obj = "cipherDetails") : base(cipher, user, organizationAbilities, globalSettings, obj) { - if (collectionCiphers?.ContainsKey(cipher.Id) ?? false) + if (collectionCiphers?.TryGetValue(cipher.Id, out var collectionCipher) ?? false) { - CollectionIds = collectionCiphers[cipher.Id].Select(c => c.CollectionId); + CollectionIds = collectionCipher.Select(c => c.CollectionId); } else { - CollectionIds = new Guid[] { }; + CollectionIds = []; } } @@ -147,7 +147,7 @@ public class CipherDetailsResponseModel : CipherResponseModel IEnumerable collectionCiphers, string obj = "cipherDetails") : base(cipher, user, organizationAbilities, globalSettings, obj) { - CollectionIds = collectionCiphers?.Select(c => c.CollectionId) ?? new List(); + CollectionIds = collectionCiphers?.Select(c => c.CollectionId) ?? []; } public CipherDetailsResponseModel( @@ -158,7 +158,7 @@ public class CipherDetailsResponseModel : CipherResponseModel string obj = "cipherDetails") : base(cipher, user, organizationAbilities, globalSettings, obj) { - CollectionIds = cipher.CollectionIds ?? new List(); + CollectionIds = cipher.CollectionIds ?? []; } public IEnumerable CollectionIds { get; set; } @@ -170,13 +170,13 @@ public class CipherMiniDetailsResponseModel : CipherMiniResponseModel IDictionary> collectionCiphers, bool orgUseTotp, string obj = "cipherMiniDetails") : base(cipher, globalSettings, orgUseTotp, obj) { - if (collectionCiphers?.ContainsKey(cipher.Id) ?? false) + if (collectionCiphers?.TryGetValue(cipher.Id, out var collectionCipher) ?? false) { - CollectionIds = collectionCiphers[cipher.Id].Select(c => c.CollectionId); + CollectionIds = collectionCipher.Select(c => c.CollectionId); } else { - CollectionIds = new Guid[] { }; + CollectionIds = []; } } @@ -184,7 +184,7 @@ public class CipherMiniDetailsResponseModel : CipherMiniResponseModel GlobalSettings globalSettings, bool orgUseTotp, string obj = "cipherMiniDetails") : base(cipher, globalSettings, orgUseTotp, obj) { - CollectionIds = cipher.CollectionIds ?? new List(); + CollectionIds = cipher.CollectionIds ?? []; } public CipherMiniDetailsResponseModel(CipherOrganizationDetailsWithCollections cipher, diff --git a/src/Api/entrypoint.sh b/src/Api/entrypoint.sh index 37d117215c..d89a4648ec 100644 --- a/src/Api/entrypoint.sh +++ b/src/Api/entrypoint.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # Setup @@ -19,31 +19,36 @@ then LGID=65534 fi -# Create user and group +if [ "$(id -u)" = "0" ] +then + # Create user and group -groupadd -o -g $LGID $GROUPNAME >/dev/null 2>&1 || -groupmod -o -g $LGID $GROUPNAME >/dev/null 2>&1 -useradd -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 || -usermod -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 -mkhomedir_helper $USERNAME + groupadd -o -g $LGID $GROUPNAME >/dev/null 2>&1 || + groupmod -o -g $LGID $GROUPNAME >/dev/null 2>&1 + useradd -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 || + usermod -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 + mkhomedir_helper $USERNAME -# The rest... + # The rest... -chown -R $USERNAME:$GROUPNAME /app -mkdir -p /etc/bitwarden/core -mkdir -p /etc/bitwarden/logs -mkdir -p /etc/bitwarden/ca-certificates -chown -R $USERNAME:$GROUPNAME /etc/bitwarden + chown -R $USERNAME:$GROUPNAME /app + mkdir -p /etc/bitwarden/core + mkdir -p /etc/bitwarden/logs + mkdir -p /etc/bitwarden/ca-certificates + chown -R $USERNAME:$GROUPNAME /etc/bitwarden -if [[ $globalSettings__selfHosted == "true" ]]; then - cp /etc/bitwarden/ca-certificates/*.crt /usr/local/share/ca-certificates/ >/dev/null 2>&1 \ - && update-ca-certificates + if [[ -f "/etc/bitwarden/kerberos/bitwarden.keytab" && -f "/etc/bitwarden/kerberos/krb5.conf" ]]; then + chown -R $USERNAME:$GROUPNAME /etc/bitwarden/kerberos + fi + + gosu_cmd="gosu $USERNAME:$GROUPNAME" +else + gosu_cmd="" fi if [[ -f "/etc/bitwarden/kerberos/bitwarden.keytab" && -f "/etc/bitwarden/kerberos/krb5.conf" ]]; then - chown -R $USERNAME:$GROUPNAME /etc/bitwarden/kerberos - cp -f /etc/bitwarden/kerberos/krb5.conf /etc/krb5.conf - gosu $USERNAME:$GROUPNAME kinit $globalSettings__kerberosUser -k -t /etc/bitwarden/kerberos/bitwarden.keytab + cp -f /etc/bitwarden/kerberos/krb5.conf /etc/krb5.conf + $gosu_cmd kinit $globalSettings__kerberosUser -k -t /etc/bitwarden/kerberos/bitwarden.keytab fi -exec gosu $USERNAME:$GROUPNAME dotnet /app/Api.dll +exec $gosu_cmd /app/Api diff --git a/src/Billing/.dockerignore b/src/Billing/.dockerignore deleted file mode 100644 index fc12f25146..0000000000 --- a/src/Billing/.dockerignore +++ /dev/null @@ -1,4 +0,0 @@ -* -!obj/build-output/publish/* -!obj/Docker/empty/ -!entrypoint.sh diff --git a/src/Billing/Controllers/AppleController.cs b/src/Billing/Controllers/AppleController.cs index 1bcbbf2ad6..5c231de8ed 100644 --- a/src/Billing/Controllers/AppleController.cs +++ b/src/Billing/Controllers/AppleController.cs @@ -28,8 +28,8 @@ public class AppleController : Controller return new BadRequestResult(); } - var key = HttpContext.Request.Query.ContainsKey("key") ? - HttpContext.Request.Query["key"].ToString() : null; + var key = HttpContext.Request.Query.TryGetValue("key", out var keyValue) ? + keyValue.ToString() : null; if (!CoreHelpers.FixedTimeEquals(key, _billingSettings.AppleWebhookKey)) { return new BadRequestResult(); diff --git a/src/Billing/Controllers/FreshdeskController.cs b/src/Billing/Controllers/FreshdeskController.cs index 4bf6b7bad4..1fb0fb7ac7 100644 --- a/src/Billing/Controllers/FreshdeskController.cs +++ b/src/Billing/Controllers/FreshdeskController.cs @@ -63,6 +63,12 @@ public class FreshdeskController : Controller 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}"; @@ -121,18 +127,7 @@ public class FreshdeskController : Controller Content = JsonContent.Create(updateBody), }; await CallFreshdeskApiAsync(updateRequest); - - 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); + await CreateNote(ticketId, note); } return new OkResult(); @@ -208,6 +203,21 @@ public class FreshdeskController : Controller 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 diff --git a/src/Billing/Controllers/PayPalController.cs b/src/Billing/Controllers/PayPalController.cs index 2afde80601..36987c6e44 100644 --- a/src/Billing/Controllers/PayPalController.cs +++ b/src/Billing/Controllers/PayPalController.cs @@ -51,8 +51,8 @@ public class PayPalController : Controller [HttpPost("ipn")] public async Task PostIpn() { - var key = HttpContext.Request.Query.ContainsKey("key") - ? HttpContext.Request.Query["key"].ToString() + var key = HttpContext.Request.Query.TryGetValue("key", out var keyValue) + ? keyValue.ToString() : null; if (string.IsNullOrEmpty(key)) diff --git a/src/Billing/Dockerfile b/src/Billing/Dockerfile index 9abbe16477..5eb4e9c0e0 100644 --- a/src/Billing/Dockerfile +++ b/src/Billing/Dockerfile @@ -1,6 +1,50 @@ +############################################### +# Build stage # +############################################### +FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0 AS build + +# Docker buildx supplies the value for this arg +ARG TARGETPLATFORM + +# Determine proper runtime value for .NET +# We put the value in a file to be read by later layers. +RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \ + RID=linux-x64 ; \ + elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \ + RID=linux-arm64 ; \ + elif [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then \ + RID=linux-arm ; \ + fi \ + && echo "RID=$RID" > /tmp/rid.txt + +# Copy required project files +WORKDIR /source +COPY . ./ + +# Restore project dependencies and tools +WORKDIR /source/src/Billing +RUN . /tmp/rid.txt && dotnet restore -r $RID + +# Build project +RUN . /tmp/rid.txt && dotnet publish \ + -c release \ + --no-restore \ + --self-contained \ + /p:PublishSingleFile=true \ + -r $RID \ + -o out + +############################################### +# App stage # +############################################### FROM mcr.microsoft.com/dotnet/aspnet:8.0 +ARG TARGETPLATFORM LABEL com.bitwarden.product="bitwarden" +ENV ASPNETCORE_ENVIRONMENT=Production +ENV ASPNETCORE_URLS=http://+:5000 +ENV SSL_CERT_DIR=/etc/bitwarden/ca-certificates +EXPOSE 5000 RUN apt-get update \ && apt-get install -y --no-install-recommends \ @@ -8,14 +52,11 @@ RUN apt-get update \ curl \ && rm -rf /var/lib/apt/lists/* -ENV ASPNETCORE_URLS http://+:5000 +# Copy app from the build stage WORKDIR /app -EXPOSE 5000 -COPY entrypoint.sh / +COPY --from=build /source/src/Billing/out /app +COPY ./src/Billing/entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh - -COPY obj/build-output/publish . - HEALTHCHECK CMD curl -f http://localhost:5000/alive || exit 1 ENTRYPOINT ["/entrypoint.sh"] diff --git a/src/Billing/Program.cs b/src/Billing/Program.cs index 33e2665427..3e005ce7fd 100644 --- a/src/Billing/Program.cs +++ b/src/Billing/Program.cs @@ -20,8 +20,8 @@ public class Program return e.Level >= globalSettings.MinLogLevel.BillingSettings.Jobs; } - if (e.Properties.ContainsKey("RequestPath") && - !string.IsNullOrWhiteSpace(e.Properties["RequestPath"]?.ToString()) && + if (e.Properties.TryGetValue("RequestPath", out var requestPath) && + !string.IsNullOrWhiteSpace(requestPath?.ToString()) && (context.Contains(".Server.Kestrel") || context.Contains(".Core.IISHttpServer"))) { return false; diff --git a/src/Billing/Services/Implementations/CustomerUpdatedHandler.cs b/src/Billing/Services/Implementations/CustomerUpdatedHandler.cs index 6deb0bc330..fe7745f760 100644 --- a/src/Billing/Services/Implementations/CustomerUpdatedHandler.cs +++ b/src/Billing/Services/Implementations/CustomerUpdatedHandler.cs @@ -1,8 +1,4 @@ -using Bit.Core.Context; -using Bit.Core.Repositories; -using Bit.Core.Tools.Enums; -using Bit.Core.Tools.Models.Business; -using Bit.Core.Tools.Services; +using Bit.Core.Repositories; using Event = Stripe.Event; namespace Bit.Billing.Services.Implementations; @@ -10,23 +6,17 @@ namespace Bit.Billing.Services.Implementations; public class CustomerUpdatedHandler : ICustomerUpdatedHandler { private readonly IOrganizationRepository _organizationRepository; - private readonly IReferenceEventService _referenceEventService; - private readonly ICurrentContext _currentContext; private readonly IStripeEventService _stripeEventService; private readonly IStripeEventUtilityService _stripeEventUtilityService; private readonly ILogger _logger; public CustomerUpdatedHandler( IOrganizationRepository organizationRepository, - IReferenceEventService referenceEventService, - ICurrentContext currentContext, IStripeEventService stripeEventService, IStripeEventUtilityService stripeEventUtilityService, ILogger logger) { _organizationRepository = organizationRepository ?? throw new ArgumentNullException(nameof(organizationRepository)); - _referenceEventService = referenceEventService; - _currentContext = currentContext; _stripeEventService = stripeEventService; _stripeEventUtilityService = stripeEventUtilityService; _logger = logger; @@ -95,20 +85,5 @@ public class CustomerUpdatedHandler : ICustomerUpdatedHandler organization.BillingEmail = customer.Email; await _organizationRepository.ReplaceAsync(organization); - - if (_referenceEventService == null) - { - _logger.LogError("ReferenceEventService was not initialized in CustomerUpdatedHandler"); - throw new InvalidOperationException($"{nameof(_referenceEventService)} is not initialized"); - } - - if (_currentContext == null) - { - _logger.LogError("CurrentContext was not initialized in CustomerUpdatedHandler"); - throw new InvalidOperationException($"{nameof(_currentContext)} is not initialized"); - } - - await _referenceEventService.RaiseEventAsync( - new ReferenceEvent(ReferenceEventType.OrganizationEditedInStripe, organization, _currentContext)); } } diff --git a/src/Billing/Services/Implementations/PaymentSucceededHandler.cs b/src/Billing/Services/Implementations/PaymentSucceededHandler.cs index 40d8c8349d..4c256e3d85 100644 --- a/src/Billing/Services/Implementations/PaymentSucceededHandler.cs +++ b/src/Billing/Services/Implementations/PaymentSucceededHandler.cs @@ -3,13 +3,9 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Pricing; -using Bit.Core.Context; using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; -using Bit.Core.Tools.Enums; -using Bit.Core.Tools.Models.Business; -using Bit.Core.Tools.Services; using Event = Stripe.Event; namespace Bit.Billing.Services.Implementations; @@ -22,9 +18,6 @@ public class PaymentSucceededHandler : IPaymentSucceededHandler private readonly IStripeFacade _stripeFacade; private readonly IProviderRepository _providerRepository; private readonly IOrganizationRepository _organizationRepository; - private readonly IReferenceEventService _referenceEventService; - private readonly ICurrentContext _currentContext; - private readonly IUserRepository _userRepository; private readonly IStripeEventUtilityService _stripeEventUtilityService; private readonly IPushNotificationService _pushNotificationService; private readonly IOrganizationEnableCommand _organizationEnableCommand; @@ -36,9 +29,6 @@ public class PaymentSucceededHandler : IPaymentSucceededHandler IStripeFacade stripeFacade, IProviderRepository providerRepository, IOrganizationRepository organizationRepository, - IReferenceEventService referenceEventService, - ICurrentContext currentContext, - IUserRepository userRepository, IStripeEventUtilityService stripeEventUtilityService, IUserService userService, IPushNotificationService pushNotificationService, @@ -50,9 +40,6 @@ public class PaymentSucceededHandler : IPaymentSucceededHandler _stripeFacade = stripeFacade; _providerRepository = providerRepository; _organizationRepository = organizationRepository; - _referenceEventService = referenceEventService; - _currentContext = currentContext; - _userRepository = userRepository; _stripeEventUtilityService = stripeEventUtilityService; _userService = userService; _pushNotificationService = pushNotificationService; @@ -116,27 +103,7 @@ public class PaymentSucceededHandler : IPaymentSucceededHandler _logger.LogError("invoice.payment_succeeded webhook ({EventID}) for Provider ({ProviderID}) indicates missing subscription line items", parsedEvent.Id, provider.Id); - - return; } - - await _referenceEventService.RaiseEventAsync(new ReferenceEvent - { - Type = ReferenceEventType.Rebilled, - Source = ReferenceEventSource.Provider, - Id = provider.Id, - PlanType = PlanType.TeamsMonthly, - Seats = (int)teamsMonthlyLineItem.Quantity - }); - - await _referenceEventService.RaiseEventAsync(new ReferenceEvent - { - Type = ReferenceEventType.Rebilled, - Source = ReferenceEventSource.Provider, - Id = provider.Id, - PlanType = PlanType.EnterpriseMonthly, - Seats = (int)enterpriseMonthlyLineItem.Quantity - }); } else if (organizationId.HasValue) { @@ -156,15 +123,6 @@ public class PaymentSucceededHandler : IPaymentSucceededHandler await _organizationEnableCommand.EnableAsync(organizationId.Value, subscription.CurrentPeriodEnd); await _pushNotificationService.PushSyncOrganizationStatusAsync(organization); - - await _referenceEventService.RaiseEventAsync( - new ReferenceEvent(ReferenceEventType.Rebilled, organization, _currentContext) - { - PlanName = organization?.Plan, - PlanType = organization?.PlanType, - Seats = organization?.Seats, - Storage = organization?.MaxStorageGb, - }); } else if (userId.HasValue) { @@ -174,14 +132,6 @@ public class PaymentSucceededHandler : IPaymentSucceededHandler } await _userService.EnablePremiumAsync(userId.Value, subscription.CurrentPeriodEnd); - - var user = await _userRepository.GetByIdAsync(userId.Value); - await _referenceEventService.RaiseEventAsync( - new ReferenceEvent(ReferenceEventType.Rebilled, user, _currentContext) - { - PlanName = IStripeEventUtilityService.PremiumPlanId, - Storage = user?.MaxStorageGb, - }); } } } diff --git a/src/Billing/Services/Implementations/ProviderEventService.cs b/src/Billing/Services/Implementations/ProviderEventService.cs index 4e35a6c894..1f6ef741df 100644 --- a/src/Billing/Services/Implementations/ProviderEventService.cs +++ b/src/Billing/Services/Implementations/ProviderEventService.cs @@ -1,8 +1,8 @@ using Bit.Billing.Constants; using Bit.Core.AdminConsole.Repositories; -using Bit.Core.Billing.Entities; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Repositories; +using Bit.Core.Billing.Providers.Entities; +using Bit.Core.Billing.Providers.Repositories; using Bit.Core.Enums; using Bit.Core.Repositories; using Stripe; diff --git a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs index f75cbf8a8b..e31d1dceb7 100644 --- a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs +++ b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs @@ -1,11 +1,11 @@ using Bit.Core; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Repositories; 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.Services.Contracts; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.Repositories; using Bit.Core.Services; @@ -25,8 +25,7 @@ public class UpcomingInvoiceHandler( IStripeEventService stripeEventService, IStripeEventUtilityService stripeEventUtilityService, IUserRepository userRepository, - IValidateSponsorshipCommand validateSponsorshipCommand, - IAutomaticTaxFactory automaticTaxFactory) + IValidateSponsorshipCommand validateSponsorshipCommand) : IUpcomingInvoiceHandler { public async Task HandleAsync(Event parsedEvent) @@ -46,6 +45,8 @@ public class UpcomingInvoiceHandler( var (organizationId, userId, providerId) = stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata); + var setNonUSBusinessUseToReverseCharge = featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge); + if (organizationId.HasValue) { var organization = await organizationRepository.GetByIdAsync(organizationId.Value); @@ -55,7 +56,7 @@ public class UpcomingInvoiceHandler( return; } - await TryEnableAutomaticTaxAsync(subscription); + await AlignOrganizationTaxConcernsAsync(organization, subscription, parsedEvent.Id, setNonUSBusinessUseToReverseCharge); var plan = await pricingClient.GetPlanOrThrow(organization.PlanType); @@ -100,7 +101,25 @@ public class UpcomingInvoiceHandler( return; } - await TryEnableAutomaticTaxAsync(subscription); + if (!subscription.AutomaticTax.Enabled && subscription.Customer.HasRecognizedTaxLocation()) + { + try + { + await stripeFacade.UpdateSubscription(subscription.Id, + new SubscriptionUpdateOptions + { + AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true } + }); + } + catch (Exception exception) + { + logger.LogError( + exception, + "Failed to set user's ({UserID}) subscription to automatic tax while processing event with ID {EventID}", + user.Id, + parsedEvent.Id); + } + } if (user.Premium) { @@ -116,7 +135,7 @@ public class UpcomingInvoiceHandler( return; } - await TryEnableAutomaticTaxAsync(subscription); + await AlignProviderTaxConcernsAsync(provider, subscription, parsedEvent.Id, setNonUSBusinessUseToReverseCharge); await SendUpcomingInvoiceEmailsAsync(new List { provider.BillingEmail }, invoice); } @@ -139,50 +158,123 @@ public class UpcomingInvoiceHandler( } } - private async Task TryEnableAutomaticTaxAsync(Subscription subscription) + private async Task AlignOrganizationTaxConcernsAsync( + Organization organization, + Subscription subscription, + string eventId, + bool setNonUSBusinessUseToReverseCharge) { - if (featureService.IsEnabled(FeatureFlagKeys.PM19147_AutomaticTaxImprovements)) - { - var automaticTaxParameters = new AutomaticTaxFactoryParameters(subscription.Items.Select(x => x.Price.Id)); - var automaticTaxStrategy = await automaticTaxFactory.CreateAsync(automaticTaxParameters); - var updateOptions = automaticTaxStrategy.GetUpdateOptions(subscription); + var nonUSBusinessUse = + organization.PlanType.GetProductTier() != ProductTierType.Families && + subscription.Customer.Address.Country != "US"; - if (updateOptions == null) + bool setAutomaticTaxToEnabled; + + if (setNonUSBusinessUseToReverseCharge) + { + if (nonUSBusinessUse && subscription.Customer.TaxExempt != StripeConstants.TaxExempt.Reverse) { - return; + try + { + await stripeFacade.UpdateCustomer(subscription.CustomerId, + new CustomerUpdateOptions { TaxExempt = StripeConstants.TaxExempt.Reverse }); + } + catch (Exception exception) + { + logger.LogError( + exception, + "Failed to set organization's ({OrganizationID}) to reverse tax exemption while processing event with ID {EventID}", + organization.Id, + eventId); + } } - await stripeFacade.UpdateSubscription(subscription.Id, updateOptions); - return; + setAutomaticTaxToEnabled = true; } - - if (subscription.AutomaticTax.Enabled || - !subscription.Customer.HasBillingLocation() || - await IsNonTaxableNonUSBusinessUseSubscription(subscription)) + else { - return; + setAutomaticTaxToEnabled = + subscription.Customer.HasRecognizedTaxLocation() && + (subscription.Customer.Address.Country == "US" || + (nonUSBusinessUse && subscription.Customer.TaxIds.Any())); } - await stripeFacade.UpdateSubscription(subscription.Id, - new SubscriptionUpdateOptions + if (!subscription.AutomaticTax.Enabled && setAutomaticTaxToEnabled) + { + try { - DefaultTaxRates = [], - AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true } - }); + await stripeFacade.UpdateSubscription(subscription.Id, + new SubscriptionUpdateOptions + { + AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true } + }); + } + catch (Exception exception) + { + logger.LogError( + exception, + "Failed to set organization's ({OrganizationID}) subscription to automatic tax while processing event with ID {EventID}", + organization.Id, + eventId); + } + } + } - return; + private async Task AlignProviderTaxConcernsAsync( + Provider provider, + Subscription subscription, + string eventId, + bool setNonUSBusinessUseToReverseCharge) + { + bool setAutomaticTaxToEnabled; - async Task IsNonTaxableNonUSBusinessUseSubscription(Subscription localSubscription) + if (setNonUSBusinessUseToReverseCharge) { - var familyPriceIds = (await Task.WhenAll( - pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019), - pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually))) - .Select(plan => plan.PasswordManager.StripePlanId); + if (subscription.Customer.Address.Country != "US" && subscription.Customer.TaxExempt != StripeConstants.TaxExempt.Reverse) + { + try + { + await stripeFacade.UpdateCustomer(subscription.CustomerId, + new CustomerUpdateOptions { TaxExempt = StripeConstants.TaxExempt.Reverse }); + } + catch (Exception exception) + { + logger.LogError( + exception, + "Failed to set provider's ({ProviderID}) to reverse tax exemption while processing event with ID {EventID}", + provider.Id, + eventId); + } + } - return localSubscription.Customer.Address.Country != "US" && - localSubscription.Metadata.ContainsKey(StripeConstants.MetadataKeys.OrganizationId) && - !localSubscription.Items.Select(item => item.Price.Id).Intersect(familyPriceIds).Any() && - !localSubscription.Customer.TaxIds.Any(); + setAutomaticTaxToEnabled = true; + } + else + { + setAutomaticTaxToEnabled = + subscription.Customer.HasRecognizedTaxLocation() && + (subscription.Customer.Address.Country == "US" || + subscription.Customer.TaxIds.Any()); + } + + if (!subscription.AutomaticTax.Enabled && setAutomaticTaxToEnabled) + { + try + { + await stripeFacade.UpdateSubscription(subscription.Id, + new SubscriptionUpdateOptions + { + AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true } + }); + } + catch (Exception exception) + { + logger.LogError( + exception, + "Failed to set provider's ({ProviderID}) subscription to automatic tax while processing event with ID {EventID}", + provider.Id, + eventId); + } } } } diff --git a/src/Billing/entrypoint.sh b/src/Billing/entrypoint.sh index 6d98cfa6f6..66540416f5 100644 --- a/src/Billing/entrypoint.sh +++ b/src/Billing/entrypoint.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # Setup @@ -19,25 +19,27 @@ then LGID=65534 fi -# Create user and group +if [ "$(id -u)" = "0" ] +then + # Create user and group -groupadd -o -g $LGID $GROUPNAME >/dev/null 2>&1 || -groupmod -o -g $LGID $GROUPNAME >/dev/null 2>&1 -useradd -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 || -usermod -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 -mkhomedir_helper $USERNAME + groupadd -o -g $LGID $GROUPNAME >/dev/null 2>&1 || + groupmod -o -g $LGID $GROUPNAME >/dev/null 2>&1 + useradd -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 || + usermod -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 + mkhomedir_helper $USERNAME -# The rest... + # The rest... -chown -R $USERNAME:$GROUPNAME /app -mkdir -p /etc/bitwarden/core -mkdir -p /etc/bitwarden/logs -mkdir -p /etc/bitwarden/ca-certificates -chown -R $USERNAME:$GROUPNAME /etc/bitwarden + chown -R $USERNAME:$GROUPNAME /app + mkdir -p /etc/bitwarden/core + mkdir -p /etc/bitwarden/logs + mkdir -p /etc/bitwarden/ca-certificates + chown -R $USERNAME:$GROUPNAME /etc/bitwarden -if [[ $globalSettings__selfHosted == "true" ]]; then - cp /etc/bitwarden/ca-certificates/*.crt /usr/local/share/ca-certificates/ >/dev/null 2>&1 \ - && update-ca-certificates + gosu_cmd="gosu $USERNAME:$GROUPNAME" +else + gosu_cmd="" fi -exec gosu $USERNAME:$GROUPNAME dotnet /app/Billing.dll +exec $gosu_cmd /app/Billing diff --git a/src/Core/AdminConsole/Entities/Organization.cs b/src/Core/AdminConsole/Entities/Organization.cs index 17d9847574..274c7f8ddb 100644 --- a/src/Core/AdminConsole/Entities/Organization.cs +++ b/src/Core/AdminConsole/Entities/Organization.cs @@ -8,14 +8,13 @@ using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Business; using Bit.Core.Services; -using Bit.Core.Tools.Entities; using Bit.Core.Utilities; #nullable enable namespace Bit.Core.AdminConsole.Entities; -public class Organization : ITableObject, IStorableSubscriber, IRevisable, IReferenceable +public class Organization : ITableObject, IStorableSubscriber, IRevisable { private Dictionary? _twoFactorProviders; @@ -114,6 +113,11 @@ public class Organization : ITableObject, IStorableSubscriber, IRevisable, /// public bool UseRiskInsights { get; set; } + /// + /// If true, the organization can claim domains, which unlocks additional enterprise features + /// + public bool UseOrganizationDomains { get; set; } + /// /// If set to true, admins can initiate organization-issued sponsorships. /// @@ -253,12 +257,12 @@ public class Organization : ITableObject, IStorableSubscriber, IRevisable, public bool TwoFactorProviderIsEnabled(TwoFactorProviderType provider) { var providers = GetTwoFactorProviders(); - if (providers == null || !providers.ContainsKey(provider)) + if (providers == null || !providers.TryGetValue(provider, out var twoFactorProvider)) { return false; } - return providers[provider].Enabled && Use2fa; + return twoFactorProvider.Enabled && Use2fa; } public bool TwoFactorIsEnabled() @@ -275,12 +279,7 @@ public class Organization : ITableObject, IStorableSubscriber, IRevisable, public TwoFactorProvider? GetTwoFactorProvider(TwoFactorProviderType provider) { var providers = GetTwoFactorProviders(); - if (providers == null || !providers.ContainsKey(provider)) - { - return null; - } - - return providers[provider]; + return providers?.GetValueOrDefault(provider); } public void UpdateFromLicense(OrganizationLicense license, IFeatureService featureService) @@ -319,5 +318,7 @@ public class Organization : ITableObject, IStorableSubscriber, IRevisable, SmSeats = license.SmSeats; SmServiceAccounts = license.SmServiceAccounts; UseRiskInsights = license.UseRiskInsights; + UseOrganizationDomains = license.UseOrganizationDomains; + UseAdminSponsoredFamilies = license.UseAdminSponsoredFamilies; } } diff --git a/src/Core/AdminConsole/Enums/IntegrationType.cs b/src/Core/AdminConsole/Enums/IntegrationType.cs index 0f5123554e..5edd54df23 100644 --- a/src/Core/AdminConsole/Enums/IntegrationType.cs +++ b/src/Core/AdminConsole/Enums/IntegrationType.cs @@ -7,3 +7,19 @@ public enum IntegrationType : int Slack = 3, Webhook = 4, } + +public static class IntegrationTypeExtensions +{ + public static string ToRoutingKey(this IntegrationType type) + { + switch (type) + { + case IntegrationType.Slack: + return "slack"; + case IntegrationType.Webhook: + return "webhook"; + default: + throw new ArgumentOutOfRangeException(nameof(type), $"Unsupported integration type: {type}"); + } + } +} diff --git a/src/Core/AdminConsole/Models/Data/Integrations/IIntegrationMessage.cs b/src/Core/AdminConsole/Models/Data/Integrations/IIntegrationMessage.cs new file mode 100644 index 0000000000..bd1f280cad --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/Integrations/IIntegrationMessage.cs @@ -0,0 +1,12 @@ +using Bit.Core.Enums; + +namespace Bit.Core.AdminConsole.Models.Data.Integrations; + +public interface IIntegrationMessage +{ + IntegrationType IntegrationType { get; } + int RetryCount { get; set; } + DateTime? DelayUntilDate { get; set; } + void ApplyRetry(DateTime? handlerDelayUntilDate); + string ToJson(); +} diff --git a/src/Core/AdminConsole/Models/Data/Integrations/IntegrationHandlerResult.cs b/src/Core/AdminConsole/Models/Data/Integrations/IntegrationHandlerResult.cs new file mode 100644 index 0000000000..d2f0bde693 --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/Integrations/IntegrationHandlerResult.cs @@ -0,0 +1,16 @@ +namespace Bit.Core.AdminConsole.Models.Data.Integrations; + +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/Integrations/IntegrationMessage.cs b/src/Core/AdminConsole/Models/Data/Integrations/IntegrationMessage.cs new file mode 100644 index 0000000000..1f288914d0 --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/Integrations/IntegrationMessage.cs @@ -0,0 +1,34 @@ +using System.Text.Json; +using Bit.Core.Enums; + +namespace Bit.Core.AdminConsole.Models.Data.Integrations; + +public class IntegrationMessage : IIntegrationMessage +{ + public IntegrationType IntegrationType { get; set; } + public T Configuration { get; set; } + public string RenderedTemplate { get; set; } + public int RetryCount { get; set; } = 0; + public DateTime? DelayUntilDate { get; set; } + + public void ApplyRetry(DateTime? handlerDelayUntilDate) + { + RetryCount++; + + var baseTime = handlerDelayUntilDate ?? DateTime.UtcNow; + var backoffSeconds = Math.Pow(2, RetryCount); + var jitterSeconds = Random.Shared.Next(0, 3); + + DelayUntilDate = baseTime.AddSeconds(backoffSeconds + jitterSeconds); + } + + public string ToJson() + { + return JsonSerializer.Serialize(this); + } + + public static IntegrationMessage FromJson(string json) + { + return JsonSerializer.Deserialize>(json); + } +} diff --git a/src/Core/AdminConsole/Models/Data/Integrations/IntegrationTemplateContext.cs b/src/Core/AdminConsole/Models/Data/Integrations/IntegrationTemplateContext.cs index 18aa3b7681..338c2b963d 100644 --- a/src/Core/AdminConsole/Models/Data/Integrations/IntegrationTemplateContext.cs +++ b/src/Core/AdminConsole/Models/Data/Integrations/IntegrationTemplateContext.cs @@ -1,10 +1,11 @@ -using Bit.Core.AdminConsole.Entities; +#nullable enable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.Entities; using Bit.Core.Enums; +using Bit.Core.Models.Data; -#nullable enable - -namespace Bit.Core.Models.Data.Integrations; +namespace Bit.Core.AdminConsole.Models.Data.Integrations; public class IntegrationTemplateContext(EventMessage eventMessage) { diff --git a/src/Core/AdminConsole/Models/Data/Integrations/SlackIntegration.cs b/src/Core/AdminConsole/Models/Data/Integrations/SlackIntegration.cs index e6fc1440ea..4fcce542ce 100644 --- a/src/Core/AdminConsole/Models/Data/Integrations/SlackIntegration.cs +++ b/src/Core/AdminConsole/Models/Data/Integrations/SlackIntegration.cs @@ -1,3 +1,3 @@ -namespace Bit.Core.Models.Data.Integrations; +namespace Bit.Core.AdminConsole.Models.Data.Integrations; public record SlackIntegration(string token); diff --git a/src/Core/AdminConsole/Models/Data/Integrations/SlackIntegrationConfiguration.cs b/src/Core/AdminConsole/Models/Data/Integrations/SlackIntegrationConfiguration.cs index ad25d35e7e..2930004cbf 100644 --- a/src/Core/AdminConsole/Models/Data/Integrations/SlackIntegrationConfiguration.cs +++ b/src/Core/AdminConsole/Models/Data/Integrations/SlackIntegrationConfiguration.cs @@ -1,3 +1,3 @@ -namespace Bit.Core.Models.Data.Integrations; +namespace Bit.Core.AdminConsole.Models.Data.Integrations; public record SlackIntegrationConfiguration(string channelId); diff --git a/src/Core/AdminConsole/Models/Data/Integrations/SlackIntegrationConfigurationDetails.cs b/src/Core/AdminConsole/Models/Data/Integrations/SlackIntegrationConfigurationDetails.cs index 49ca9df4e0..b81e50d403 100644 --- a/src/Core/AdminConsole/Models/Data/Integrations/SlackIntegrationConfigurationDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Integrations/SlackIntegrationConfigurationDetails.cs @@ -1,3 +1,3 @@ -namespace Bit.Core.Models.Data.Integrations; +namespace Bit.Core.AdminConsole.Models.Data.Integrations; public record SlackIntegrationConfigurationDetails(string channelId, string token); diff --git a/src/Core/AdminConsole/Models/Data/Integrations/WebhookIntegrationConfiguration.cs b/src/Core/AdminConsole/Models/Data/Integrations/WebhookIntegrationConfiguration.cs index 9a7591f24b..e8217d3ad3 100644 --- a/src/Core/AdminConsole/Models/Data/Integrations/WebhookIntegrationConfiguration.cs +++ b/src/Core/AdminConsole/Models/Data/Integrations/WebhookIntegrationConfiguration.cs @@ -1,3 +1,3 @@ -namespace Bit.Core.Models.Data.Integrations; +namespace Bit.Core.AdminConsole.Models.Data.Integrations; public record WebhookIntegrationConfiguration(string url); diff --git a/src/Core/AdminConsole/Models/Data/Integrations/WebhookIntegrationConfigurationDetails.cs b/src/Core/AdminConsole/Models/Data/Integrations/WebhookIntegrationConfigurationDetails.cs new file mode 100644 index 0000000000..e3e92c900f --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/Integrations/WebhookIntegrationConfigurationDetails.cs @@ -0,0 +1,3 @@ +namespace Bit.Core.AdminConsole.Models.Data.Integrations; + +public record WebhookIntegrationConfigurationDetails(string url); diff --git a/src/Core/AdminConsole/Models/Data/Integrations/WebhookIntegrationConfigurationDetils.cs b/src/Core/AdminConsole/Models/Data/Integrations/WebhookIntegrationConfigurationDetils.cs deleted file mode 100644 index f165828de0..0000000000 --- a/src/Core/AdminConsole/Models/Data/Integrations/WebhookIntegrationConfigurationDetils.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace Bit.Core.Models.Data.Integrations; - -public record WebhookIntegrationConfigurationDetils(string url); diff --git a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationAbility.cs b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationAbility.cs index d27bf40994..ae91f204e3 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationAbility.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationAbility.cs @@ -26,6 +26,7 @@ public class OrganizationAbility LimitItemDeletion = organization.LimitItemDeletion; AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems; UseRiskInsights = organization.UseRiskInsights; + UseOrganizationDomains = organization.UseOrganizationDomains; UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies; } @@ -46,5 +47,6 @@ public class OrganizationAbility public bool LimitItemDeletion { get; set; } public bool AllowAdminAccessToAllCollectionItems { get; set; } public bool UseRiskInsights { get; set; } + public bool UseOrganizationDomains { get; set; } public bool UseAdminSponsoredFamilies { 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 a804dc0f6a..8de422ee31 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserOrganizationDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserOrganizationDetails.cs @@ -59,6 +59,7 @@ public class OrganizationUserOrganizationDetails public bool LimitItemDeletion { get; set; } public bool AllowAdminAccessToAllCollectionItems { get; set; } public bool UseRiskInsights { get; set; } + public bool UseOrganizationDomains { get; set; } public bool UseAdminSponsoredFamilies { get; set; } public bool? IsAdminInitiated { get; set; } } diff --git a/src/Core/AdminConsole/Models/Data/Organizations/SelfHostedOrganizationDetails.cs b/src/Core/AdminConsole/Models/Data/Organizations/SelfHostedOrganizationDetails.cs index ab2dfd7e0e..a6ad47f829 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/SelfHostedOrganizationDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/SelfHostedOrganizationDetails.cs @@ -150,6 +150,7 @@ public class SelfHostedOrganizationDetails : Organization AllowAdminAccessToAllCollectionItems = AllowAdminAccessToAllCollectionItems, Status = Status, UseRiskInsights = UseRiskInsights, + UseAdminSponsoredFamilies = UseAdminSponsoredFamilies, }; } } diff --git a/src/Core/AdminConsole/Models/Data/Provider/ProviderUserOrganizationDetails.cs b/src/Core/AdminConsole/Models/Data/Provider/ProviderUserOrganizationDetails.cs index 8717a8f008..4621de8268 100644 --- a/src/Core/AdminConsole/Models/Data/Provider/ProviderUserOrganizationDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Provider/ProviderUserOrganizationDetails.cs @@ -45,6 +45,7 @@ public class ProviderUserOrganizationDetails public bool LimitItemDeletion { get; set; } public bool AllowAdminAccessToAllCollectionItems { get; set; } public bool UseRiskInsights { get; set; } + public bool UseOrganizationDomains { get; set; } public bool UseAdminSponsoredFamilies { get; set; } public ProviderType ProviderType { get; set; } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Groups/CreateGroupCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Groups/CreateGroupCommand.cs index 11bf6d7f66..f514beed38 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Groups/CreateGroupCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Groups/CreateGroupCommand.cs @@ -1,15 +1,11 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces; using Bit.Core.AdminConsole.Repositories; -using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Data; using Bit.Core.Repositories; using Bit.Core.Services; -using Bit.Core.Tools.Enums; -using Bit.Core.Tools.Models.Business; -using Bit.Core.Tools.Services; namespace Bit.Core.AdminConsole.OrganizationFeatures.Groups; @@ -18,21 +14,16 @@ public class CreateGroupCommand : ICreateGroupCommand private readonly IEventService _eventService; private readonly IGroupRepository _groupRepository; private readonly IOrganizationUserRepository _organizationUserRepository; - private readonly IReferenceEventService _referenceEventService; - private readonly ICurrentContext _currentContext; public CreateGroupCommand( IEventService eventService, IGroupRepository groupRepository, - IOrganizationUserRepository organizationUserRepository, - IReferenceEventService referenceEventService, - ICurrentContext currentContext) + IOrganizationUserRepository organizationUserRepository + ) { _eventService = eventService; _groupRepository = groupRepository; _organizationUserRepository = organizationUserRepository; - _referenceEventService = referenceEventService; - _currentContext = currentContext; } public async Task CreateGroupAsync(Group group, Organization organization, @@ -77,8 +68,6 @@ public class CreateGroupCommand : ICreateGroupCommand { await _groupRepository.CreateAsync(group, collections); } - - await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.GroupCreated, organization, _currentContext)); } private async Task GroupRepositoryUpdateUsersAsync(Group group, IEnumerable userIds, diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs index ec635282f7..43a3120ffd 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs @@ -20,7 +20,6 @@ public class VerifyOrganizationDomainCommand( IDnsResolverService dnsResolverService, IEventService eventService, IGlobalSettings globalSettings, - IFeatureService featureService, ICurrentContext currentContext, ISavePolicyCommand savePolicyCommand, IMailService mailService, @@ -125,11 +124,8 @@ public class VerifyOrganizationDomainCommand( private async Task DomainVerificationSideEffectsAsync(OrganizationDomain domain, IActingUser actingUser) { - if (featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)) - { - await EnableSingleOrganizationPolicyAsync(domain.OrganizationId, actingUser); - await SendVerifiedDomainUserEmailAsync(domain); - } + await EnableSingleOrganizationPolicyAsync(domain.OrganizationId, actingUser); + await SendVerifiedDomainUserEmailAsync(domain); } private async Task EnableSingleOrganizationPolicyAsync(Guid organizationId, IActingUser actingUser) => diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs index f3426efddc..3770d867cf 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs @@ -1,4 +1,6 @@ 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.Auth.UserFeatures.TwoFactorAuth.Interfaces; @@ -27,6 +29,8 @@ public class AcceptOrgUserCommand : IAcceptOrgUserCommand private readonly IUserRepository _userRepository; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly IDataProtectorTokenFactory _orgUserInviteTokenDataFactory; + private readonly IFeatureService _featureService; + private readonly IPolicyRequirementQuery _policyRequirementQuery; public AcceptOrgUserCommand( IDataProtectionProvider dataProtectionProvider, @@ -37,9 +41,10 @@ public class AcceptOrgUserCommand : IAcceptOrgUserCommand IMailService mailService, IUserRepository userRepository, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, - IDataProtectorTokenFactory orgUserInviteTokenDataFactory) + IDataProtectorTokenFactory orgUserInviteTokenDataFactory, + IFeatureService featureService, + IPolicyRequirementQuery policyRequirementQuery) { - // TODO: remove data protector when old token validation removed _dataProtector = dataProtectionProvider.CreateProtector(OrgUserInviteTokenable.DataProtectorPurpose); _globalSettings = globalSettings; @@ -50,6 +55,8 @@ public class AcceptOrgUserCommand : IAcceptOrgUserCommand _userRepository = userRepository; _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; _orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory; + _featureService = featureService; + _policyRequirementQuery = policyRequirementQuery; } public async Task AcceptOrgUserByEmailTokenAsync(Guid organizationUserId, User user, string emailToken, @@ -196,15 +203,7 @@ public class AcceptOrgUserCommand : IAcceptOrgUserCommand } // Enforce Two Factor Authentication Policy of organization user is trying to join - if (!await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user)) - { - var invitedTwoFactorPolicies = await _policyService.GetPoliciesApplicableToUserAsync(user.Id, - PolicyType.TwoFactorAuthentication, OrganizationUserStatusType.Invited); - if (invitedTwoFactorPolicies.Any(p => p.OrganizationId == orgUser.OrganizationId)) - { - throw new BadRequestException("You cannot join this organization until you enable two-step login on your user account."); - } - } + await ValidateTwoFactorAuthenticationPolicyAsync(user, orgUser.OrganizationId); orgUser.Status = OrganizationUserStatusType.Accepted; orgUser.UserId = user.Id; @@ -224,4 +223,33 @@ public class AcceptOrgUserCommand : IAcceptOrgUserCommand return orgUser; } + private async Task ValidateTwoFactorAuthenticationPolicyAsync(User user, Guid organizationId) + { + if (_featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements)) + { + if (await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user)) + { + // If the user has two-step login enabled, we skip checking the 2FA policy + return; + } + + var twoFactorPolicyRequirement = await _policyRequirementQuery.GetAsync(user.Id); + if (twoFactorPolicyRequirement.IsTwoFactorRequiredForOrganization(organizationId)) + { + throw new BadRequestException("You cannot join this organization until you enable two-step login on your user account."); + } + + return; + } + + if (!await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user)) + { + var invitedTwoFactorPolicies = await _policyService.GetPoliciesApplicableToUserAsync(user.Id, + PolicyType.TwoFactorAuthentication, OrganizationUserStatusType.Invited); + if (invitedTwoFactorPolicies.Any(p => p.OrganizationId == organizationId)) + { + throw new BadRequestException("You cannot join this organization until you enable two-step login on your user account."); + } + } + } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs index 9bfe8f791e..806cf5a533 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs @@ -1,5 +1,7 @@ using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Billing.Enums; @@ -24,6 +26,8 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand private readonly IPushRegistrationService _pushRegistrationService; private readonly IPolicyService _policyService; private readonly IDeviceRepository _deviceRepository; + private readonly IPolicyRequirementQuery _policyRequirementQuery; + private readonly IFeatureService _featureService; public ConfirmOrganizationUserCommand( IOrganizationRepository organizationRepository, @@ -35,7 +39,9 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand IPushNotificationService pushNotificationService, IPushRegistrationService pushRegistrationService, IPolicyService policyService, - IDeviceRepository deviceRepository) + IDeviceRepository deviceRepository, + IPolicyRequirementQuery policyRequirementQuery, + IFeatureService featureService) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -47,6 +53,8 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand _pushRegistrationService = pushRegistrationService; _policyService = policyService; _deviceRepository = deviceRepository; + _policyRequirementQuery = policyRequirementQuery; + _featureService = featureService; } public async Task ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key, @@ -118,8 +126,8 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand } } - var twoFactorEnabled = usersTwoFactorEnabled.FirstOrDefault(tuple => tuple.userId == user.Id).twoFactorIsEnabled; - await CheckPoliciesAsync(organizationId, user, orgUsers, twoFactorEnabled); + var userTwoFactorEnabled = usersTwoFactorEnabled.FirstOrDefault(tuple => tuple.userId == user.Id).twoFactorIsEnabled; + await CheckPoliciesAsync(organizationId, user, orgUsers, userTwoFactorEnabled); orgUser.Status = OrganizationUserStatusType.Confirmed; orgUser.Key = keys[orgUser.Id]; orgUser.Email = null; @@ -142,15 +150,10 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand } private async Task CheckPoliciesAsync(Guid organizationId, User user, - ICollection userOrgs, bool twoFactorEnabled) + ICollection userOrgs, bool userTwoFactorEnabled) { // Enforce Two Factor Authentication Policy for this organization - var orgRequiresTwoFactor = (await _policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.TwoFactorAuthentication)) - .Any(p => p.OrganizationId == organizationId); - if (orgRequiresTwoFactor && !twoFactorEnabled) - { - throw new BadRequestException("User does not have two-step login enabled."); - } + await ValidateTwoFactorAuthenticationPolicyAsync(user, organizationId, userTwoFactorEnabled); var hasOtherOrgs = userOrgs.Any(ou => ou.OrganizationId != organizationId); var singleOrgPolicies = await _policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg); @@ -168,6 +171,33 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand } } + private async Task ValidateTwoFactorAuthenticationPolicyAsync(User user, Guid organizationId, bool userTwoFactorEnabled) + { + if (_featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements)) + { + if (userTwoFactorEnabled) + { + // If the user has two-step login enabled, we skip checking the 2FA policy + return; + } + + var twoFactorPolicyRequirement = await _policyRequirementQuery.GetAsync(user.Id); + if (twoFactorPolicyRequirement.IsTwoFactorRequiredForOrganization(organizationId)) + { + throw new BadRequestException("User does not have two-step login enabled."); + } + + return; + } + + var orgRequiresTwoFactor = (await _policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.TwoFactorAuthentication)) + .Any(p => p.OrganizationId == organizationId); + if (orgRequiresTwoFactor && !userTwoFactorEnabled) + { + throw new BadRequestException("User does not have two-step login enabled."); + } + } + private async Task DeleteAndPushUserRegistrationAsync(Guid organizationId, Guid userId) { var devices = await GetUserDeviceIdsAsync(userId); diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedOrganizationUserAccountCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedOrganizationUserAccountCommand.cs index 49ddf0a548..60a1c8bfbf 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedOrganizationUserAccountCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedOrganizationUserAccountCommand.cs @@ -7,9 +7,6 @@ using Bit.Core.Exceptions; using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; -using Bit.Core.Tools.Enums; -using Bit.Core.Tools.Models.Business; -using Bit.Core.Tools.Services; #nullable enable @@ -24,7 +21,6 @@ public class DeleteClaimedOrganizationUserAccountCommand : IDeleteClaimedOrganiz private readonly IUserRepository _userRepository; private readonly ICurrentContext _currentContext; private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery; - private readonly IReferenceEventService _referenceEventService; private readonly IPushNotificationService _pushService; private readonly IOrganizationRepository _organizationRepository; private readonly IProviderUserRepository _providerUserRepository; @@ -36,7 +32,6 @@ public class DeleteClaimedOrganizationUserAccountCommand : IDeleteClaimedOrganiz IUserRepository userRepository, ICurrentContext currentContext, IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery, - IReferenceEventService referenceEventService, IPushNotificationService pushService, IOrganizationRepository organizationRepository, IProviderUserRepository providerUserRepository) @@ -48,7 +43,6 @@ public class DeleteClaimedOrganizationUserAccountCommand : IDeleteClaimedOrganiz _userRepository = userRepository; _currentContext = currentContext; _hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery; - _referenceEventService = referenceEventService; _pushService = pushService; _organizationRepository = organizationRepository; _providerUserRepository = providerUserRepository; @@ -195,8 +189,6 @@ public class DeleteClaimedOrganizationUserAccountCommand : IDeleteClaimedOrganiz await _userRepository.DeleteManyAsync(users); foreach (var user in users) { - await _referenceEventService.RaiseEventAsync( - new ReferenceEvent(ReferenceEventType.DeleteAccount, user, _currentContext)); await _pushService.PushLogOutAsync(user.Id); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/GetOrganizationUsersClaimedStatusQuery.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/GetOrganizationUsersClaimedStatusQuery.cs index 1dda9483cd..d8c510119a 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/GetOrganizationUsersClaimedStatusQuery.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/GetOrganizationUsersClaimedStatusQuery.cs @@ -24,9 +24,7 @@ public class GetOrganizationUsersClaimedStatusQuery : IGetOrganizationUsersClaim // Users can only be claimed by an Organization that is enabled and can have organization domains var organizationAbility = await _applicationCacheService.GetOrganizationAbilityAsync(organizationId); - // TODO: Replace "UseSso" with a new organization ability like "UseOrganizationDomains" (PM-11622). - // Verified domains were tied to SSO, so we currently check the "UseSso" organization ability. - if (organizationAbility is { Enabled: true, UseSso: true }) + if (organizationAbility is { Enabled: true, UseOrganizationDomains: true }) { // Get all organization users with claimed domains by the organization var organizationUsersWithClaimedDomain = await _organizationUserRepository.GetManyByOrganizationWithClaimedDomainsAsync(organizationId); diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUsersCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUsersCommand.cs index ba53cbf9cc..20771169cc 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUsersCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUsersCommand.cs @@ -16,9 +16,6 @@ using Bit.Core.Models.Business; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.Repositories; using Bit.Core.Services; -using Bit.Core.Tools.Enums; -using Bit.Core.Tools.Models.Business; -using Bit.Core.Tools.Services; using Microsoft.Extensions.Logging; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; @@ -28,8 +25,6 @@ public class InviteOrganizationUsersCommand(IEventService eventService, IInviteUsersValidator inviteUsersValidator, IPaymentService paymentService, IOrganizationRepository organizationRepository, - IReferenceEventService referenceEventService, - ICurrentContext currentContext, IApplicationCacheService applicationCacheService, IMailService mailService, ILogger logger, @@ -155,8 +150,6 @@ public class InviteOrganizationUsersCommand(IEventService eventService, await SendAdditionalEmailsAsync(validatedRequest, organization); await SendInvitesAsync(organizationUserToInviteEntities, organization); - - await PublishReferenceEventAsync(validatedRequest, organization); } catch (Exception ex) { @@ -224,14 +217,6 @@ public class InviteOrganizationUsersCommand(IEventService eventService, } } - private async Task PublishReferenceEventAsync(Valid validatedResult, - Organization organization) => - await referenceEventService.RaiseEventAsync( - new ReferenceEvent(ReferenceEventType.InvitedUsers, organization, currentContext) - { - Users = validatedResult.Value.Invites.Length - }); - private async Task SendInvitesAsync(IEnumerable users, Organization organization) => await sendOrganizationInvitesCommand.SendInvitesAsync( new SendInvitesRequest( @@ -318,15 +303,6 @@ public class InviteOrganizationUsersCommand(IEventService eventService, await organizationRepository.ReplaceAsync(organization); // could optimize this with only a property update await applicationCacheService.UpsertOrganizationAbilityAsync(organization); - - await referenceEventService.RaiseEventAsync( - new ReferenceEvent(ReferenceEventType.AdjustSeats, organization, currentContext) - { - PlanName = validatedResult.Value.InviteOrganization.Plan.Name, - PlanType = validatedResult.Value.InviteOrganization.Plan.Type, - Seats = validatedResult.Value.PasswordManagerSubscriptionUpdate.UpdatedSeatTotal, - PreviousSeats = validatedResult.Value.PasswordManagerSubscriptionUpdate.Seats - }); } } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RemoveOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RemoveOrganizationUserCommand.cs index 4de2cd0ea5..00d3ebb533 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RemoveOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RemoveOrganizationUserCommand.cs @@ -159,7 +159,7 @@ public class RemoveOrganizationUserCommand : IRemoveOrganizationUserCommand throw new BadRequestException(RemoveAdminByCustomUserErrorMessage); } - if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) && deletingUserId.HasValue && eventSystemUser == null) + if (deletingUserId.HasValue && eventSystemUser == null) { var claimedStatus = await _getOrganizationUsersClaimedStatusQuery.GetUsersOrganizationClaimedStatusAsync(orgUser.OrganizationId, new[] { orgUser.Id }); if (claimedStatus.TryGetValue(orgUser.Id, out var isClaimed) && isClaimed) @@ -214,7 +214,7 @@ public class RemoveOrganizationUserCommand : IRemoveOrganizationUserCommand deletingUserIsOwner = await _currentContext.OrganizationOwner(organizationId); } - var claimedStatus = _featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) && deletingUserId.HasValue && eventSystemUser == null + var claimedStatus = deletingUserId.HasValue && eventSystemUser == null ? await _getOrganizationUsersClaimedStatusQuery.GetUsersOrganizationClaimedStatusAsync(organizationId, filteredUsers.Select(u => u.Id)) : filteredUsers.ToDictionary(u => u.Id, u => false); var result = new List<(OrganizationUser OrganizationUser, string ErrorMessage)>(); diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs index 74165a5a71..fe19cd1389 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs @@ -1,5 +1,7 @@ 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.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Billing.Enums; @@ -22,7 +24,9 @@ public class RestoreOrganizationUserCommand( ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, IPolicyService policyService, IUserRepository userRepository, - IOrganizationService organizationService) : IRestoreOrganizationUserCommand + IOrganizationService organizationService, + IFeatureService featureService, + IPolicyRequirementQuery policyRequirementQuery) : IRestoreOrganizationUserCommand { public async Task RestoreUserAsync(OrganizationUser organizationUser, Guid? restoringUserId) { @@ -270,12 +274,7 @@ public class RestoreOrganizationUserCommand( // Enforce 2FA Policy of organization user is trying to join if (!userHasTwoFactorEnabled) { - var invitedTwoFactorPolicies = await policyService.GetPoliciesApplicableToUserAsync(userId, - PolicyType.TwoFactorAuthentication, OrganizationUserStatusType.Revoked); - if (invitedTwoFactorPolicies.Any(p => p.OrganizationId == orgUser.OrganizationId)) - { - twoFactorCompliant = false; - } + twoFactorCompliant = !await IsTwoFactorRequiredForOrganizationAsync(userId, orgUser.OrganizationId); } var user = await userRepository.GetByIdAsync(userId); @@ -299,4 +298,17 @@ public class RestoreOrganizationUserCommand( throw new BadRequestException(user.Email + " is not compliant with the two-step login policy"); } } + + private async Task IsTwoFactorRequiredForOrganizationAsync(Guid userId, Guid organizationId) + { + if (featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements)) + { + var requirement = await policyRequirementQuery.GetAsync(userId); + return requirement.IsTwoFactorRequiredForOrganization(organizationId); + } + + var invitedTwoFactorPolicies = await policyService.GetPoliciesApplicableToUserAsync(userId, + PolicyType.TwoFactorAuthentication, OrganizationUserStatusType.Revoked); + return invitedTwoFactorPolicies.Any(p => p.OrganizationId == organizationId); + } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/CloudOrganizationSignUpCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/CloudOrganizationSignUpCommand.cs index 60e090de2a..f26061cbd2 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Organizations/CloudOrganizationSignUpCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/CloudOrganizationSignUpCommand.cs @@ -5,7 +5,6 @@ using Bit.Core.Billing.Enums; using Bit.Core.Billing.Models.Sales; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; -using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -15,9 +14,6 @@ using Bit.Core.Models.StaticStore; using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; -using Bit.Core.Tools.Enums; -using Bit.Core.Tools.Models.Business; -using Bit.Core.Tools.Services; using Bit.Core.Utilities; namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations; @@ -36,8 +32,6 @@ public class CloudOrganizationSignUpCommand( IOrganizationBillingService organizationBillingService, IPaymentService paymentService, IPolicyService policyService, - IReferenceEventService referenceEventService, - ICurrentContext currentContext, IOrganizationRepository organizationRepository, IOrganizationApiKeyRepository organizationApiKeyRepository, IApplicationCacheService applicationCacheService, @@ -104,7 +98,8 @@ public class CloudOrganizationSignUpCommand( RevisionDate = DateTime.UtcNow, Status = OrganizationStatusType.Created, UsePasswordManager = true, - UseSecretsManager = signup.UseSecretsManager + UseSecretsManager = signup.UseSecretsManager, + UseOrganizationDomains = plan.HasOrganizationDomains, }; if (signup.UseSecretsManager) @@ -131,17 +126,6 @@ public class CloudOrganizationSignUpCommand( var ownerId = signup.IsFromProvider ? default : signup.Owner.Id; var returnValue = await SignUpAsync(organization, ownerId, signup.OwnerKey, signup.CollectionName, true); - await referenceEventService.RaiseEventAsync( - new ReferenceEvent(ReferenceEventType.Signup, organization, currentContext) - { - PlanName = plan.Name, - PlanType = plan.Type, - Seats = returnValue.Item1.Seats, - SignupInitiationPath = signup.InitiationPath, - Storage = returnValue.Item1.MaxStorageGb, - // TODO: add reference events for SmSeats and Service Accounts - see AC-1481 - }); - return new SignUpOrganizationResponse(returnValue.organization, returnValue.organizationUser); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/OrganizationDeleteCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/OrganizationDeleteCommand.cs index 185d5c5ac0..6a81130402 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Organizations/OrganizationDeleteCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/OrganizationDeleteCommand.cs @@ -2,38 +2,28 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Repositories; -using Bit.Core.Context; using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.Services; -using Bit.Core.Tools.Enums; -using Bit.Core.Tools.Models.Business; -using Bit.Core.Tools.Services; namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations; public class OrganizationDeleteCommand : IOrganizationDeleteCommand { private readonly IApplicationCacheService _applicationCacheService; - private readonly ICurrentContext _currentContext; private readonly IOrganizationRepository _organizationRepository; private readonly IPaymentService _paymentService; - private readonly IReferenceEventService _referenceEventService; private readonly ISsoConfigRepository _ssoConfigRepository; public OrganizationDeleteCommand( IApplicationCacheService applicationCacheService, - ICurrentContext currentContext, IOrganizationRepository organizationRepository, IPaymentService paymentService, - IReferenceEventService referenceEventService, ISsoConfigRepository ssoConfigRepository) { _applicationCacheService = applicationCacheService; - _currentContext = currentContext; _organizationRepository = organizationRepository; _paymentService = paymentService; - _referenceEventService = referenceEventService; _ssoConfigRepository = ssoConfigRepository; } @@ -48,8 +38,6 @@ public class OrganizationDeleteCommand : IOrganizationDeleteCommand var eop = !organization.ExpirationDate.HasValue || organization.ExpirationDate.Value >= DateTime.UtcNow; await _paymentService.CancelSubscriptionAsync(organization, eop); - await _referenceEventService.RaiseEventAsync( - new ReferenceEvent(ReferenceEventType.DeleteAccount, organization, _currentContext)); } catch (GatewayException) { } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/ProviderClientOrganizationSignUpCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/ProviderClientOrganizationSignUpCommand.cs new file mode 100644 index 0000000000..c3e945b65f --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/ProviderClientOrganizationSignUpCommand.cs @@ -0,0 +1,171 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Pricing; +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Models.Business; +using Bit.Core.Models.StaticStore; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Utilities; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations; + +public record ProviderClientOrganizationSignUpResponse( + Organization Organization, + Collection DefaultCollection); + +public interface IProviderClientOrganizationSignUpCommand +{ + /// + /// Sign up a new client organization for a provider. + /// + /// The signup information. + /// A tuple containing the new organization and its default collection. + Task SignUpClientOrganizationAsync(OrganizationSignup signup); +} + +public class ProviderClientOrganizationSignUpCommand : IProviderClientOrganizationSignUpCommand +{ + public const string PlanNullErrorMessage = "Password Manager Plan was null."; + public const string PlanDisabledErrorMessage = "Password Manager Plan is disabled."; + public const string AdditionalSeatsNegativeErrorMessage = "You can't subtract Password Manager seats!"; + + private readonly ICurrentContext _currentContext; + private readonly IPricingClient _pricingClient; + private readonly IOrganizationRepository _organizationRepository; + private readonly IOrganizationApiKeyRepository _organizationApiKeyRepository; + private readonly IApplicationCacheService _applicationCacheService; + private readonly ICollectionRepository _collectionRepository; + + public ProviderClientOrganizationSignUpCommand( + ICurrentContext currentContext, + IPricingClient pricingClient, + IOrganizationRepository organizationRepository, + IOrganizationApiKeyRepository organizationApiKeyRepository, + IApplicationCacheService applicationCacheService, + ICollectionRepository collectionRepository) + { + _currentContext = currentContext; + _pricingClient = pricingClient; + _organizationRepository = organizationRepository; + _organizationApiKeyRepository = organizationApiKeyRepository; + _applicationCacheService = applicationCacheService; + _collectionRepository = collectionRepository; + } + + public async Task SignUpClientOrganizationAsync(OrganizationSignup signup) + { + var plan = await _pricingClient.GetPlanOrThrow(signup.Plan); + + ValidatePlan(plan, signup.AdditionalSeats); + + var organization = new Organization + { + // Pre-generate the org id so that we can save it with the Stripe subscription. + Id = CoreHelpers.GenerateComb(), + Name = signup.Name, + BillingEmail = signup.BillingEmail, + PlanType = plan!.Type, + Seats = signup.AdditionalSeats, + MaxCollections = plan.PasswordManager.MaxCollections, + MaxStorageGb = 1, + UsePolicies = plan.HasPolicies, + UseSso = plan.HasSso, + UseOrganizationDomains = plan.HasOrganizationDomains, + UseGroups = plan.HasGroups, + UseEvents = plan.HasEvents, + UseDirectory = plan.HasDirectory, + UseTotp = plan.HasTotp, + Use2fa = plan.Has2fa, + UseApi = plan.HasApi, + UseResetPassword = plan.HasResetPassword, + SelfHost = plan.HasSelfHost, + UsersGetPremium = plan.UsersGetPremium, + UseCustomPermissions = plan.HasCustomPermissions, + UseScim = plan.HasScim, + Plan = plan.Name, + Gateway = GatewayType.Stripe, + ReferenceData = signup.Owner.ReferenceData, + Enabled = true, + LicenseKey = CoreHelpers.SecureRandomString(20), + PublicKey = signup.PublicKey, + PrivateKey = signup.PrivateKey, + CreationDate = DateTime.UtcNow, + RevisionDate = DateTime.UtcNow, + Status = OrganizationStatusType.Created, + UsePasswordManager = true, + // Secrets Manager not available for purchase with Consolidated Billing. + UseSecretsManager = false, + }; + + var returnValue = await SignUpAsync(organization, signup.CollectionName); + + return returnValue; + } + + private static void ValidatePlan(Plan plan, int additionalSeats) + { + if (plan is null) + { + throw new BadRequestException(PlanNullErrorMessage); + } + + if (plan.Disabled) + { + throw new BadRequestException(PlanDisabledErrorMessage); + } + + if (additionalSeats < 0) + { + throw new BadRequestException(AdditionalSeatsNegativeErrorMessage); + } + } + + /// + /// Private helper method to create a new organization. + /// + private async Task SignUpAsync( + Organization organization, string collectionName) + { + try + { + await _organizationRepository.CreateAsync(organization); + await _organizationApiKeyRepository.CreateAsync(new OrganizationApiKey + { + OrganizationId = organization.Id, + ApiKey = CoreHelpers.SecureRandomString(30), + Type = OrganizationApiKeyType.Default, + RevisionDate = DateTime.UtcNow, + }); + await _applicationCacheService.UpsertOrganizationAbilityAsync(organization); + + Collection defaultCollection = null; + if (!string.IsNullOrWhiteSpace(collectionName)) + { + defaultCollection = new Collection + { + Name = collectionName, + OrganizationId = organization.Id, + CreationDate = organization.CreationDate, + RevisionDate = organization.CreationDate + }; + + await _collectionRepository.CreateAsync(defaultCollection, null, null); + } + + return new ProviderClientOrganizationSignUpResponse(organization, defaultCollection); + } + catch + { + if (organization.Id != default) + { + await _organizationRepository.DeleteAsync(organization); + await _applicationCacheService.DeleteOrganizationAbilityAsync(organization.Id); + } + + throw; + } + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/SavePolicyCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/SavePolicyCommand.cs index cf332e689a..71212aaf4c 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/SavePolicyCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/SavePolicyCommand.cs @@ -104,8 +104,8 @@ public class SavePolicyCommand : ISavePolicyCommand var dependentPolicyTypes = _policyValidators.Values .Where(otherValidator => otherValidator.RequiredPolicies.Contains(policyUpdate.Type)) .Select(otherValidator => otherValidator.Type) - .Where(otherPolicyType => savedPoliciesDict.ContainsKey(otherPolicyType) && - savedPoliciesDict[otherPolicyType].Enabled) + .Where(otherPolicyType => savedPoliciesDict.TryGetValue(otherPolicyType, out var savedPolicy) && + savedPolicy.Enabled) .ToList(); switch (dependentPolicyTypes) diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/RequireTwoFactorPolicyRequirement.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/RequireTwoFactorPolicyRequirement.cs new file mode 100644 index 0000000000..bbc997a83d --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/RequireTwoFactorPolicyRequirement.cs @@ -0,0 +1,52 @@ +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.Enums; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; + +/// +/// Policy requirements for the Require Two-Factor Authentication policy. +/// +public class RequireTwoFactorPolicyRequirement : IPolicyRequirement +{ + private readonly IEnumerable _policyDetails; + + public RequireTwoFactorPolicyRequirement(IEnumerable policyDetails) + { + _policyDetails = policyDetails; + } + + /// + /// Checks if two-factor authentication is required for the organization due to an active policy. + /// + /// The ID of the organization to check. + /// True if two-factor authentication is required for the organization, false otherwise. + /// + /// This should be used to check whether the member needs to have 2FA enabled before being + /// accepted, confirmed, or restored to the organization. + /// + public bool IsTwoFactorRequiredForOrganization(Guid organizationId) => + _policyDetails.Any(p => p.OrganizationId == organizationId); + + /// + /// Returns tuples of (OrganizationId, OrganizationUserId) for active memberships where two-factor authentication is required. + /// Users should be revoked from these organizations if they disable all 2FA methods. + /// + public IEnumerable<(Guid OrganizationId, Guid OrganizationUserId)> OrganizationsRequiringTwoFactor => + _policyDetails + .Where(p => p.OrganizationUserStatus is + OrganizationUserStatusType.Accepted or + OrganizationUserStatusType.Confirmed) + .Select(p => (p.OrganizationId, p.OrganizationUserId)); +} + +public class RequireTwoFactorPolicyRequirementFactory : BasePolicyRequirementFactory +{ + public override PolicyType PolicyType => PolicyType.TwoFactorAuthentication; + protected override IEnumerable ExemptStatuses => []; + + public override RequireTwoFactorPolicyRequirement Create(IEnumerable policyDetails) + { + return new RequireTwoFactorPolicyRequirement(policyDetails); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs index 1be0e61af7..f98135b70d 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs @@ -36,5 +36,6 @@ public static class PolicyServiceCollectionExtensions services.AddScoped, ResetPasswordPolicyRequirementFactory>(); services.AddScoped, PersonalOwnershipPolicyRequirementFactory>(); services.AddScoped, RequireSsoPolicyRequirementFactory>(); + services.AddScoped, RequireTwoFactorPolicyRequirementFactory>(); } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SingleOrgPolicyValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SingleOrgPolicyValidator.cs index a37deef3eb..49467eaae4 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SingleOrgPolicyValidator.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SingleOrgPolicyValidator.cs @@ -61,16 +61,9 @@ public class SingleOrgPolicyValidator : IPolicyValidator { if (currentPolicy is not { Enabled: true } && policyUpdate is { Enabled: true }) { - if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)) - { - var currentUser = _currentContext.UserId ?? Guid.Empty; - var isOwnerOrProvider = await _currentContext.OrganizationOwner(policyUpdate.OrganizationId); - await RevokeNonCompliantUsersAsync(policyUpdate.OrganizationId, policyUpdate.PerformedBy ?? new StandardUser(currentUser, isOwnerOrProvider)); - } - else - { - await RemoveNonCompliantUsersAsync(policyUpdate.OrganizationId); - } + var currentUser = _currentContext.UserId ?? Guid.Empty; + var isOwnerOrProvider = await _currentContext.OrganizationOwner(policyUpdate.OrganizationId); + await RevokeNonCompliantUsersAsync(policyUpdate.OrganizationId, policyUpdate.PerformedBy ?? new StandardUser(currentUser, isOwnerOrProvider)); } } @@ -116,42 +109,6 @@ public class SingleOrgPolicyValidator : IPolicyValidator _mailService.SendOrganizationUserRevokedForPolicySingleOrgEmailAsync(organization.DisplayName(), x.Email))); } - private async Task RemoveNonCompliantUsersAsync(Guid organizationId) - { - // Remove non-compliant users - var savingUserId = _currentContext.UserId; - // Note: must get OrganizationUserUserDetails so that Email is always populated from the User object - var orgUsers = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId); - var org = await _organizationRepository.GetByIdAsync(organizationId); - if (org == null) - { - throw new NotFoundException(OrganizationNotFoundErrorMessage); - } - - var removableOrgUsers = orgUsers.Where(ou => - ou.Status != OrganizationUserStatusType.Invited && - ou.Status != OrganizationUserStatusType.Revoked && - ou.Type != OrganizationUserType.Owner && - ou.Type != OrganizationUserType.Admin && - ou.UserId != savingUserId - ).ToList(); - - var userOrgs = await _organizationUserRepository.GetManyByManyUsersAsync( - removableOrgUsers.Select(ou => ou.UserId!.Value)); - foreach (var orgUser in removableOrgUsers) - { - if (userOrgs.Any(ou => ou.UserId == orgUser.UserId - && ou.OrganizationId != org.Id - && ou.Status != OrganizationUserStatusType.Invited)) - { - await _removeOrganizationUserCommand.RemoveUserAsync(organizationId, orgUser.Id, savingUserId); - - await _mailService.SendOrganizationUserRemovedForPolicySingleOrgEmailAsync( - org.DisplayName(), orgUser.Email); - } - } - } - public async Task ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) { if (policyUpdate is not { Enabled: true }) @@ -165,8 +122,7 @@ public class SingleOrgPolicyValidator : IPolicyValidator return validateDecryptionErrorMessage; } - if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) - && await _organizationHasVerifiedDomainsQuery.HasVerifiedDomainsAsync(policyUpdate.OrganizationId)) + if (await _organizationHasVerifiedDomainsQuery.HasVerifiedDomainsAsync(policyUpdate.OrganizationId)) { return ClaimedDomainSingleOrganizationRequiredErrorMessage; } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/TwoFactorAuthenticationPolicyValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/TwoFactorAuthenticationPolicyValidator.cs index c757a65913..13cc935eb9 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/TwoFactorAuthenticationPolicyValidator.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/TwoFactorAuthenticationPolicyValidator.cs @@ -23,8 +23,6 @@ public class TwoFactorAuthenticationPolicyValidator : IPolicyValidator private readonly IOrganizationRepository _organizationRepository; private readonly ICurrentContext _currentContext; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; - private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; - private readonly IFeatureService _featureService; private readonly IRevokeNonCompliantOrganizationUserCommand _revokeNonCompliantOrganizationUserCommand; public const string NonCompliantMembersWillLoseAccessMessage = "Policy could not be enabled. Non-compliant members will lose access to their accounts. Identify members without two-step login from the policies column in the members page."; @@ -38,8 +36,6 @@ public class TwoFactorAuthenticationPolicyValidator : IPolicyValidator IOrganizationRepository organizationRepository, ICurrentContext currentContext, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, - IRemoveOrganizationUserCommand removeOrganizationUserCommand, - IFeatureService featureService, IRevokeNonCompliantOrganizationUserCommand revokeNonCompliantOrganizationUserCommand) { _organizationUserRepository = organizationUserRepository; @@ -47,8 +43,6 @@ public class TwoFactorAuthenticationPolicyValidator : IPolicyValidator _organizationRepository = organizationRepository; _currentContext = currentContext; _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; - _removeOrganizationUserCommand = removeOrganizationUserCommand; - _featureService = featureService; _revokeNonCompliantOrganizationUserCommand = revokeNonCompliantOrganizationUserCommand; } @@ -56,16 +50,9 @@ public class TwoFactorAuthenticationPolicyValidator : IPolicyValidator { if (currentPolicy is not { Enabled: true } && policyUpdate is { Enabled: true }) { - if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)) - { - var currentUser = _currentContext.UserId ?? Guid.Empty; - var isOwnerOrProvider = await _currentContext.OrganizationOwner(policyUpdate.OrganizationId); - await RevokeNonCompliantUsersAsync(policyUpdate.OrganizationId, policyUpdate.PerformedBy ?? new StandardUser(currentUser, isOwnerOrProvider)); - } - else - { - await RemoveNonCompliantUsersAsync(policyUpdate.OrganizationId); - } + var currentUser = _currentContext.UserId ?? Guid.Empty; + var isOwnerOrProvider = await _currentContext.OrganizationOwner(policyUpdate.OrganizationId); + await RevokeNonCompliantUsersAsync(policyUpdate.OrganizationId, policyUpdate.PerformedBy ?? new StandardUser(currentUser, isOwnerOrProvider)); } } @@ -121,40 +108,6 @@ public class TwoFactorAuthenticationPolicyValidator : IPolicyValidator _mailService.SendOrganizationUserRevokedForTwoFactorPolicyEmailAsync(organization.DisplayName(), x.Email))); } - private async Task RemoveNonCompliantUsersAsync(Guid organizationId) - { - var org = await _organizationRepository.GetByIdAsync(organizationId); - var savingUserId = _currentContext.UserId; - - var orgUsers = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId); - var organizationUsersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(orgUsers); - var removableOrgUsers = orgUsers.Where(ou => - ou.Status != OrganizationUserStatusType.Invited && ou.Status != OrganizationUserStatusType.Revoked && - ou.Type != OrganizationUserType.Owner && ou.Type != OrganizationUserType.Admin && - ou.UserId != savingUserId); - - // Reorder by HasMasterPassword to prioritize checking users without a master if they have 2FA enabled - foreach (var orgUser in removableOrgUsers.OrderBy(ou => ou.HasMasterPassword)) - { - var userTwoFactorEnabled = organizationUsersTwoFactorEnabled.FirstOrDefault(u => u.user.Id == orgUser.Id) - .twoFactorIsEnabled; - if (!userTwoFactorEnabled) - { - if (!orgUser.HasMasterPassword) - { - throw new BadRequestException( - "Policy could not be enabled. Non-compliant members will lose access to their accounts. Identify members without two-step login from the policies column in the members page."); - } - - await _removeOrganizationUserCommand.RemoveUserAsync(organizationId, orgUser.Id, - savingUserId); - - await _mailService.SendOrganizationUserRemovedForPolicyTwoStepEmailAsync( - org!.DisplayName(), orgUser.Email); - } - } - } - private static bool MembersWithNoMasterPasswordWillLoseAccess( IEnumerable orgUserDetails, IEnumerable<(OrganizationUserUserDetails user, bool isTwoFactorEnabled)> organizationUsersTwoFactorEnabled) => diff --git a/src/Core/AdminConsole/Services/IIntegrationHandler.cs b/src/Core/AdminConsole/Services/IIntegrationHandler.cs new file mode 100644 index 0000000000..bf6e6791cf --- /dev/null +++ b/src/Core/AdminConsole/Services/IIntegrationHandler.cs @@ -0,0 +1,24 @@ +using Bit.Core.AdminConsole.Models.Data.Integrations; + +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); + } + + public abstract Task HandleAsync(IntegrationMessage message); +} diff --git a/src/Core/AdminConsole/Services/IIntegrationPublisher.cs b/src/Core/AdminConsole/Services/IIntegrationPublisher.cs new file mode 100644 index 0000000000..986ea776e1 --- /dev/null +++ b/src/Core/AdminConsole/Services/IIntegrationPublisher.cs @@ -0,0 +1,8 @@ +using Bit.Core.AdminConsole.Models.Data.Integrations; + +namespace Bit.Core.Services; + +public interface IIntegrationPublisher +{ + Task PublishAsync(IIntegrationMessage message); +} diff --git a/src/Core/AdminConsole/Services/IOrganizationService.cs b/src/Core/AdminConsole/Services/IOrganizationService.cs index d10094db05..1a297a4ae9 100644 --- a/src/Core/AdminConsole/Services/IOrganizationService.cs +++ b/src/Core/AdminConsole/Services/IOrganizationService.cs @@ -10,8 +10,6 @@ namespace Bit.Core.Services; public interface IOrganizationService { - Task ReplacePaymentMethodAsync(Guid organizationId, string paymentToken, PaymentMethodType paymentMethodType, - TaxInfo taxInfo); Task CancelSubscriptionAsync(Guid organizationId, bool? endOfPeriod = null); Task ReinstateSubscriptionAsync(Guid organizationId); Task AdjustStorageAsync(Guid organizationId, short storageAdjustmentGb); @@ -19,9 +17,6 @@ public interface IOrganizationService Task AutoAddSeatsAsync(Organization organization, int seatsToAdd); Task AdjustSeatsAsync(Guid organizationId, int seatAdjustment); Task VerifyBankAsync(Guid organizationId, int amount1, int amount2); -#nullable enable - Task<(Organization organization, OrganizationUser organizationUser, Collection defaultCollection)> SignupClientAsync(OrganizationSignup signup); -#nullable disable /// /// Create a new organization on a self-hosted instance /// diff --git a/src/Core/AdminConsole/Services/Implementations/AzureServiceBusEventListenerService.cs b/src/Core/AdminConsole/Services/Implementations/AzureServiceBusEventListenerService.cs index 4cd71ae77e..2ab10418a3 100644 --- a/src/Core/AdminConsole/Services/Implementations/AzureServiceBusEventListenerService.cs +++ b/src/Core/AdminConsole/Services/Implementations/AzureServiceBusEventListenerService.cs @@ -20,7 +20,7 @@ public class AzureServiceBusEventListenerService : EventLoggingListenerService string subscriptionName) : base(handler) { _client = new ServiceBusClient(globalSettings.EventLogging.AzureServiceBus.ConnectionString); - _processor = _client.CreateProcessor(globalSettings.EventLogging.AzureServiceBus.TopicName, subscriptionName, new ServiceBusProcessorOptions()); + _processor = _client.CreateProcessor(globalSettings.EventLogging.AzureServiceBus.EventTopicName, subscriptionName, new ServiceBusProcessorOptions()); _logger = logger; } diff --git a/src/Core/AdminConsole/Services/Implementations/AzureServiceBusEventWriteService.cs b/src/Core/AdminConsole/Services/Implementations/AzureServiceBusEventWriteService.cs index fc865b327c..224f86a802 100644 --- a/src/Core/AdminConsole/Services/Implementations/AzureServiceBusEventWriteService.cs +++ b/src/Core/AdminConsole/Services/Implementations/AzureServiceBusEventWriteService.cs @@ -14,7 +14,7 @@ public class AzureServiceBusEventWriteService : IEventWriteService, IAsyncDispos public AzureServiceBusEventWriteService(GlobalSettings globalSettings) { _client = new ServiceBusClient(globalSettings.EventLogging.AzureServiceBus.ConnectionString); - _sender = _client.CreateSender(globalSettings.EventLogging.AzureServiceBus.TopicName); + _sender = _client.CreateSender(globalSettings.EventLogging.AzureServiceBus.EventTopicName); } public async Task CreateAsync(IEvent e) diff --git a/src/Core/AdminConsole/Services/Implementations/AzureServiceBusIntegrationListenerService.cs b/src/Core/AdminConsole/Services/Implementations/AzureServiceBusIntegrationListenerService.cs new file mode 100644 index 0000000000..8244f39c09 --- /dev/null +++ b/src/Core/AdminConsole/Services/Implementations/AzureServiceBusIntegrationListenerService.cs @@ -0,0 +1,101 @@ +#nullable enable + +using Azure.Messaging.ServiceBus; +using Bit.Core.Settings; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.Services; + +public class AzureServiceBusIntegrationListenerService : BackgroundService +{ + private readonly int _maxRetries; + private readonly string _subscriptionName; + private readonly string _topicName; + private readonly IIntegrationHandler _handler; + private readonly ServiceBusClient _client; + private readonly ServiceBusProcessor _processor; + private readonly ServiceBusSender _sender; + private readonly ILogger _logger; + + public AzureServiceBusIntegrationListenerService( + IIntegrationHandler handler, + string subscriptionName, + GlobalSettings globalSettings, + ILogger logger) + { + _handler = handler; + _logger = logger; + _maxRetries = globalSettings.EventLogging.AzureServiceBus.MaxRetries; + _topicName = globalSettings.EventLogging.AzureServiceBus.IntegrationTopicName; + _subscriptionName = subscriptionName; + + _client = new ServiceBusClient(globalSettings.EventLogging.AzureServiceBus.ConnectionString); + _processor = _client.CreateProcessor(_topicName, _subscriptionName, new ServiceBusProcessorOptions()); + _sender = _client.CreateSender(_topicName); + } + + protected override async Task ExecuteAsync(CancellationToken cancellationToken) + { + _processor.ProcessMessageAsync += HandleMessageAsync; + _processor.ProcessErrorAsync += args => + { + _logger.LogError(args.Exception, "Azure Service Bus error"); + return Task.CompletedTask; + }; + + await _processor.StartProcessingAsync(cancellationToken); + } + + public override async Task StopAsync(CancellationToken cancellationToken) + { + await _processor.StopProcessingAsync(cancellationToken); + await _processor.DisposeAsync(); + await _sender.DisposeAsync(); + await _client.DisposeAsync(); + await base.StopAsync(cancellationToken); + } + + private async Task HandleMessageAsync(ProcessMessageEventArgs args) + { + var json = args.Message.Body.ToString(); + + try + { + var result = await _handler.HandleAsync(json); + var message = result.Message; + + if (result.Success) + { + await args.CompleteMessageAsync(args.Message); + return; + } + + message.ApplyRetry(result.DelayUntilDate); + + if (result.Retryable && message.RetryCount < _maxRetries) + { + var scheduledTime = (DateTime)message.DelayUntilDate!; + var retryMsg = new ServiceBusMessage(message.ToJson()) + { + Subject = args.Message.Subject, + ScheduledEnqueueTime = scheduledTime + }; + + await _sender.SendMessageAsync(retryMsg); + } + else + { + await args.DeadLetterMessageAsync(args.Message, "Retry limit exceeded or non-retryable"); + return; + } + + await args.CompleteMessageAsync(args.Message); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unhandled error processing ASB message"); + await args.CompleteMessageAsync(args.Message); + } + } +} diff --git a/src/Core/AdminConsole/Services/Implementations/AzureServiceBusIntegrationPublisher.cs b/src/Core/AdminConsole/Services/Implementations/AzureServiceBusIntegrationPublisher.cs new file mode 100644 index 0000000000..4a906e719f --- /dev/null +++ b/src/Core/AdminConsole/Services/Implementations/AzureServiceBusIntegrationPublisher.cs @@ -0,0 +1,36 @@ +using Azure.Messaging.ServiceBus; +using Bit.Core.AdminConsole.Models.Data.Integrations; +using Bit.Core.Enums; +using Bit.Core.Settings; + +namespace Bit.Core.Services; + +public class AzureServiceBusIntegrationPublisher : IIntegrationPublisher, IAsyncDisposable +{ + private readonly ServiceBusClient _client; + private readonly ServiceBusSender _sender; + + public AzureServiceBusIntegrationPublisher(GlobalSettings globalSettings) + { + _client = new ServiceBusClient(globalSettings.EventLogging.AzureServiceBus.ConnectionString); + _sender = _client.CreateSender(globalSettings.EventLogging.AzureServiceBus.IntegrationTopicName); + } + + public async Task PublishAsync(IIntegrationMessage message) + { + var json = message.ToJson(); + + var serviceBusMessage = new ServiceBusMessage(json) + { + Subject = message.IntegrationType.ToRoutingKey(), + }; + + await _sender.SendMessageAsync(serviceBusMessage); + } + + public async ValueTask DisposeAsync() + { + await _sender.DisposeAsync(); + await _client.DisposeAsync(); + } +} diff --git a/src/Core/AdminConsole/Services/Implementations/IntegrationEventHandlerBase.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrationHandler.cs similarity index 63% rename from src/Core/AdminConsole/Services/Implementations/IntegrationEventHandlerBase.cs rename to src/Core/AdminConsole/Services/Implementations/EventIntegrationHandler.cs index d8e521de97..9a80ed67b2 100644 --- a/src/Core/AdminConsole/Services/Implementations/IntegrationEventHandlerBase.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrationHandler.cs @@ -1,32 +1,53 @@ -using System.Text.Json.Nodes; +using System.Text.Json; +using Bit.Core.AdminConsole.Models.Data.Integrations; using Bit.Core.AdminConsole.Utilities; using Bit.Core.Enums; using Bit.Core.Models.Data; -using Bit.Core.Models.Data.Integrations; using Bit.Core.Repositories; namespace Bit.Core.Services; -public abstract class IntegrationEventHandlerBase( +#nullable enable + +public class EventIntegrationHandler( + IntegrationType integrationType, + IIntegrationPublisher integrationPublisher, + IOrganizationIntegrationConfigurationRepository configurationRepository, IUserRepository userRepository, - IOrganizationRepository organizationRepository, - IOrganizationIntegrationConfigurationRepository configurationRepository) + IOrganizationRepository organizationRepository) : IEventMessageHandler { public async Task HandleEventAsync(EventMessage eventMessage) { - var organizationId = eventMessage.OrganizationId ?? Guid.Empty; + if (eventMessage.OrganizationId is not Guid organizationId) + { + return; + } + var configurations = await configurationRepository.GetConfigurationDetailsAsync( organizationId, - GetIntegrationType(), + integrationType, eventMessage.Type); foreach (var configuration in configurations) { - var context = await BuildContextAsync(eventMessage, configuration.Template); - var renderedTemplate = IntegrationTemplateProcessor.ReplaceTokens(configuration.Template, context); + var template = configuration.Template ?? string.Empty; + var context = await BuildContextAsync(eventMessage, template); + var renderedTemplate = IntegrationTemplateProcessor.ReplaceTokens(template, context); - await ProcessEventIntegrationAsync(configuration.MergedConfiguration, renderedTemplate); + var config = configuration.MergedConfiguration.Deserialize() + ?? throw new InvalidOperationException($"Failed to deserialize to {typeof(T).Name}"); + + var message = new IntegrationMessage + { + IntegrationType = integrationType, + Configuration = config, + RenderedTemplate = renderedTemplate, + RetryCount = 0, + DelayUntilDate = null + }; + + await integrationPublisher.PublishAsync(message); } } @@ -59,8 +80,4 @@ public abstract class IntegrationEventHandlerBase( return context; } - - protected abstract IntegrationType GetIntegrationType(); - - protected abstract Task ProcessEventIntegrationAsync(JsonObject mergedConfiguration, string renderedTemplate); } diff --git a/src/Core/AdminConsole/Services/Implementations/EventService.cs b/src/Core/AdminConsole/Services/Implementations/EventService.cs index 0cecda61a7..88d9595b4a 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventService.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventService.cs @@ -462,13 +462,13 @@ public class EventService : IEventService private bool CanUseEvents(IDictionary orgAbilities, Guid orgId) { - return orgAbilities != null && orgAbilities.ContainsKey(orgId) && - orgAbilities[orgId].Enabled && orgAbilities[orgId].UseEvents; + return orgAbilities != null && orgAbilities.TryGetValue(orgId, out var orgAbility) && + orgAbility.Enabled && orgAbility.UseEvents; } private bool CanUseProviderEvents(IDictionary providerAbilities, Guid providerId) { - return providerAbilities != null && providerAbilities.ContainsKey(providerId) && - providerAbilities[providerId].Enabled && providerAbilities[providerId].UseEvents; + return providerAbilities != null && providerAbilities.TryGetValue(providerId, out var providerAbility) && + providerAbility.Enabled && providerAbility.UseEvents; } } diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationDomainService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationDomainService.cs index 9b99cf71f0..854e486b42 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationDomainService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationDomainService.cs @@ -93,16 +93,8 @@ public class OrganizationDomainService : IOrganizationDomainService //Send email to administrators if (adminEmails.Count > 0) { - if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)) - { - await _mailService.SendUnclaimedOrganizationDomainEmailAsync(adminEmails, - domain.OrganizationId.ToString(), domain.DomainName); - } - else - { - await _mailService.SendUnverifiedOrganizationDomainEmailAsync(adminEmails, - domain.OrganizationId.ToString(), domain.DomainName); - } + await _mailService.SendUnclaimedOrganizationDomainEmailAsync(adminEmails, + domain.OrganizationId.ToString(), domain.DomainName); } _logger.LogInformation(Constants.BypassFiltersEventId, "Expired domain: {domainName}", domain.DomainName); diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index 875f7ce29c..b82ed1d2fd 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -13,7 +13,6 @@ using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Repositories; -using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; @@ -28,9 +27,6 @@ using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Settings; -using Bit.Core.Tools.Enums; -using Bit.Core.Tools.Models.Business; -using Bit.Core.Tools.Services; using Bit.Core.Utilities; using Microsoft.Extensions.Logging; using Stripe; @@ -43,7 +39,6 @@ public class OrganizationService : IOrganizationService private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly ICollectionRepository _collectionRepository; - private readonly IUserRepository _userRepository; private readonly IGroupRepository _groupRepository; private readonly IMailService _mailService; private readonly IPushNotificationService _pushNotificationService; @@ -56,7 +51,6 @@ public class OrganizationService : IOrganizationService private readonly IPolicyRepository _policyRepository; private readonly IPolicyService _policyService; private readonly ISsoUserRepository _ssoUserRepository; - private readonly IReferenceEventService _referenceEventService; private readonly IGlobalSettings _globalSettings; private readonly IOrganizationApiKeyRepository _organizationApiKeyRepository; private readonly ICurrentContext _currentContext; @@ -67,7 +61,6 @@ public class OrganizationService : IOrganizationService private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand; private readonly IProviderRepository _providerRepository; private readonly IFeatureService _featureService; - private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery; private readonly IPricingClient _pricingClient; private readonly IPolicyRequirementQuery _policyRequirementQuery; @@ -77,7 +70,6 @@ public class OrganizationService : IOrganizationService IOrganizationRepository organizationRepository, IOrganizationUserRepository organizationUserRepository, ICollectionRepository collectionRepository, - IUserRepository userRepository, IGroupRepository groupRepository, IMailService mailService, IPushNotificationService pushNotificationService, @@ -90,7 +82,6 @@ public class OrganizationService : IOrganizationService IPolicyRepository policyRepository, IPolicyService policyService, ISsoUserRepository ssoUserRepository, - IReferenceEventService referenceEventService, IGlobalSettings globalSettings, IOrganizationApiKeyRepository organizationApiKeyRepository, ICurrentContext currentContext, @@ -101,7 +92,6 @@ public class OrganizationService : IOrganizationService IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand, IProviderRepository providerRepository, IFeatureService featureService, - ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery, IPricingClient pricingClient, IPolicyRequirementQuery policyRequirementQuery, @@ -111,7 +101,6 @@ public class OrganizationService : IOrganizationService _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; _collectionRepository = collectionRepository; - _userRepository = userRepository; _groupRepository = groupRepository; _mailService = mailService; _pushNotificationService = pushNotificationService; @@ -124,7 +113,6 @@ public class OrganizationService : IOrganizationService _policyRepository = policyRepository; _policyService = policyService; _ssoUserRepository = ssoUserRepository; - _referenceEventService = referenceEventService; _globalSettings = globalSettings; _organizationApiKeyRepository = organizationApiKeyRepository; _currentContext = currentContext; @@ -135,34 +123,12 @@ public class OrganizationService : IOrganizationService _updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand; _providerRepository = providerRepository; _featureService = featureService; - _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; _hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery; _pricingClient = pricingClient; _policyRequirementQuery = policyRequirementQuery; _sendOrganizationInvitesCommand = sendOrganizationInvitesCommand; } - public async Task ReplacePaymentMethodAsync(Guid organizationId, string paymentToken, - PaymentMethodType paymentMethodType, TaxInfo taxInfo) - { - var organization = await GetOrgById(organizationId); - if (organization == null) - { - throw new NotFoundException(); - } - - await _paymentService.SaveTaxInfoAsync(organization, taxInfo); - var updated = await _paymentService.UpdatePaymentMethodAsync( - organization, - paymentMethodType, - paymentToken, - taxInfo); - if (updated) - { - await ReplaceAndUpdateCacheAsync(organization); - } - } - public async Task CancelSubscriptionAsync(Guid organizationId, bool? endOfPeriod = null) { var organization = await GetOrgById(organizationId); @@ -179,11 +145,6 @@ public class OrganizationService : IOrganizationService } await _paymentService.CancelSubscriptionAsync(organization, eop); - await _referenceEventService.RaiseEventAsync( - new ReferenceEvent(ReferenceEventType.CancelSubscription, organization, _currentContext) - { - EndOfPeriod = endOfPeriod, - }); } public async Task ReinstateSubscriptionAsync(Guid organizationId) @@ -195,8 +156,6 @@ public class OrganizationService : IOrganizationService } await _paymentService.ReinstateSubscriptionAsync(organization); - await _referenceEventService.RaiseEventAsync( - new ReferenceEvent(ReferenceEventType.ReinstateSubscription, organization, _currentContext)); } public async Task AdjustStorageAsync(Guid organizationId, short storageAdjustmentGb) @@ -216,13 +175,6 @@ public class OrganizationService : IOrganizationService var secret = await BillingHelpers.AdjustStorageAsync(_paymentService, organization, storageAdjustmentGb, plan.PasswordManager.StripeStoragePlanId); - await _referenceEventService.RaiseEventAsync( - new ReferenceEvent(ReferenceEventType.AdjustStorage, organization, _currentContext) - { - PlanName = plan.Name, - PlanType = plan.Type, - Storage = storageAdjustmentGb, - }); await ReplaceAndUpdateCacheAsync(organization); return secret; } @@ -354,14 +306,6 @@ public class OrganizationService : IOrganizationService } var paymentIntentClientSecret = await _paymentService.AdjustSeatsAsync(organization, plan, additionalSeats); - await _referenceEventService.RaiseEventAsync( - new ReferenceEvent(ReferenceEventType.AdjustSeats, organization, _currentContext) - { - PlanName = plan.Name, - PlanType = plan.Type, - Seats = newSeatTotal, - PreviousSeats = organization.Seats - }); organization.Seats = (short?)newSeatTotal; await ReplaceAndUpdateCacheAsync(organization); @@ -429,65 +373,6 @@ public class OrganizationService : IOrganizationService } } - public async Task<(Organization organization, OrganizationUser organizationUser, Collection defaultCollection)> SignupClientAsync(OrganizationSignup signup) - { - var plan = await _pricingClient.GetPlanOrThrow(signup.Plan); - - ValidatePlan(plan, signup.AdditionalSeats, "Password Manager"); - - var organization = new Organization - { - // Pre-generate the org id so that we can save it with the Stripe subscription. - Id = CoreHelpers.GenerateComb(), - Name = signup.Name, - BillingEmail = signup.BillingEmail, - PlanType = plan!.Type, - Seats = signup.AdditionalSeats, - MaxCollections = plan.PasswordManager.MaxCollections, - MaxStorageGb = 1, - UsePolicies = plan.HasPolicies, - UseSso = plan.HasSso, - UseGroups = plan.HasGroups, - UseEvents = plan.HasEvents, - UseDirectory = plan.HasDirectory, - UseTotp = plan.HasTotp, - Use2fa = plan.Has2fa, - UseApi = plan.HasApi, - UseResetPassword = plan.HasResetPassword, - SelfHost = plan.HasSelfHost, - UsersGetPremium = plan.UsersGetPremium, - UseCustomPermissions = plan.HasCustomPermissions, - UseScim = plan.HasScim, - Plan = plan.Name, - Gateway = GatewayType.Stripe, - ReferenceData = signup.Owner.ReferenceData, - Enabled = true, - LicenseKey = CoreHelpers.SecureRandomString(20), - PublicKey = signup.PublicKey, - PrivateKey = signup.PrivateKey, - CreationDate = DateTime.UtcNow, - RevisionDate = DateTime.UtcNow, - Status = OrganizationStatusType.Created, - UsePasswordManager = true, - // Secrets Manager not available for purchase with Consolidated Billing. - UseSecretsManager = false, - }; - - var returnValue = await SignUpAsync(organization, default, signup.OwnerKey, signup.CollectionName, false); - - await _referenceEventService.RaiseEventAsync( - new ReferenceEvent(ReferenceEventType.Signup, organization, _currentContext) - { - PlanName = plan.Name, - PlanType = plan.Type, - Seats = returnValue.Item1.Seats, - SignupInitiationPath = signup.InitiationPath, - Storage = returnValue.Item1.MaxStorageGb, - }); - - return returnValue; - } - private async Task ValidateSignUpPoliciesAsync(Guid ownerId) { var anySingleOrgPolicies = await _policyService.AnyPoliciesApplicableToUserAsync(ownerId, PolicyType.SingleOrg); @@ -568,6 +453,8 @@ public class OrganizationService : IOrganizationService SmSeats = license.SmSeats, SmServiceAccounts = license.SmServiceAccounts, UseRiskInsights = license.UseRiskInsights, + UseOrganizationDomains = license.UseOrganizationDomains, + UseAdminSponsoredFamilies = license.UseAdminSponsoredFamilies, }; var result = await SignUpAsync(organization, owner.Id, ownerKey, collectionName, false); @@ -723,12 +610,12 @@ public class OrganizationService : IOrganizationService } var providers = organization.GetTwoFactorProviders(); - if (!providers?.ContainsKey(type) ?? true) + if (providers is null || !providers.TryGetValue(type, out var provider)) { return; } - providers[type].Enabled = true; + provider.Enabled = true; organization.SetTwoFactorProviders(providers); await UpdateAsync(organization); } @@ -969,12 +856,6 @@ public class OrganizationService : IOrganizationService } await SendInvitesAsync(allOrgUsers, organization); - - await _referenceEventService.RaiseEventAsync( - new ReferenceEvent(ReferenceEventType.InvitedUsers, organization, _currentContext) - { - Users = orgUserInvitedCount - }); } catch (Exception e) { @@ -1592,72 +1473,6 @@ public class OrganizationService : IOrganizationService return result; } - private async Task CheckPoliciesBeforeRestoreAsync(OrganizationUser orgUser, bool userHasTwoFactorEnabled) - { - // An invited OrganizationUser isn't linked with a user account yet, so these checks are irrelevant - // The user will be subject to the same checks when they try to accept the invite - if (GetPriorActiveOrganizationUserStatusType(orgUser) == OrganizationUserStatusType.Invited) - { - return; - } - - var userId = orgUser.UserId.Value; - - // Enforce Single Organization Policy of organization user is being restored to - var allOrgUsers = await _organizationUserRepository.GetManyByUserAsync(userId); - var hasOtherOrgs = allOrgUsers.Any(ou => ou.OrganizationId != orgUser.OrganizationId); - var singleOrgPoliciesApplyingToRevokedUsers = await _policyService.GetPoliciesApplicableToUserAsync(userId, - PolicyType.SingleOrg, OrganizationUserStatusType.Revoked); - var singleOrgPolicyApplies = singleOrgPoliciesApplyingToRevokedUsers.Any(p => p.OrganizationId == orgUser.OrganizationId); - - var singleOrgCompliant = true; - var belongsToOtherOrgCompliant = true; - var twoFactorCompliant = true; - - if (hasOtherOrgs && singleOrgPolicyApplies) - { - singleOrgCompliant = false; - } - - // Enforce Single Organization Policy of other organizations user is a member of - var anySingleOrgPolicies = await _policyService.AnyPoliciesApplicableToUserAsync(userId, - PolicyType.SingleOrg); - if (anySingleOrgPolicies) - { - belongsToOtherOrgCompliant = false; - } - - // Enforce Two Factor Authentication Policy of organization user is trying to join - if (!userHasTwoFactorEnabled) - { - var invitedTwoFactorPolicies = await _policyService.GetPoliciesApplicableToUserAsync(userId, - PolicyType.TwoFactorAuthentication, OrganizationUserStatusType.Revoked); - if (invitedTwoFactorPolicies.Any(p => p.OrganizationId == orgUser.OrganizationId)) - { - twoFactorCompliant = false; - } - } - - var user = await _userRepository.GetByIdAsync(userId); - - if (!singleOrgCompliant && !twoFactorCompliant) - { - throw new BadRequestException(user.Email + " is not compliant with the single organization and two-step login polciy"); - } - else if (!singleOrgCompliant) - { - throw new BadRequestException(user.Email + " is not compliant with the single organization policy"); - } - else if (!belongsToOtherOrgCompliant) - { - throw new BadRequestException(user.Email + " belongs to an organization that doesn't allow them to join multiple organizations"); - } - else if (!twoFactorCompliant) - { - throw new BadRequestException(user.Email + " is not compliant with the two-step login policy"); - } - } - public static OrganizationUserStatusType GetPriorActiveOrganizationUserStatusType(OrganizationUser organizationUser) { // Determine status to revert back to @@ -1697,11 +1512,5 @@ public class OrganizationService : IOrganizationService await SendInviteAsync(ownerOrganizationUser, organization, true); await _eventService.LogOrganizationUserEventAsync(ownerOrganizationUser, EventType.OrganizationUser_Invited); - - await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.OrganizationCreatedByAdmin, organization, _currentContext) - { - EventRaisedByUser = userService.GetUserName(user), - SalesAssistedTrialStarted = salesAssistedTrialStarted, - }); } } diff --git a/src/Core/AdminConsole/Services/Implementations/PolicyService.cs b/src/Core/AdminConsole/Services/Implementations/PolicyService.cs index c3eb2272d0..d424bd8fff 100644 --- a/src/Core/AdminConsole/Services/Implementations/PolicyService.cs +++ b/src/Core/AdminConsole/Services/Implementations/PolicyService.cs @@ -68,7 +68,7 @@ public class PolicyService : IPolicyService var excludedUserTypes = GetUserTypesExcludedFromPolicy(policyType); var orgAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync(); return organizationUserPolicyDetails.Where(o => - (!orgAbilities.ContainsKey(o.OrganizationId) || orgAbilities[o.OrganizationId].UsePolicies) && + (!orgAbilities.TryGetValue(o.OrganizationId, out var orgAbility) || orgAbility.UsePolicies) && o.PolicyEnabled && !excludedUserTypes.Contains(o.OrganizationUserType) && o.OrganizationUserStatus >= minStatus && diff --git a/src/Core/AdminConsole/Services/Implementations/RabbitMqEventListenerService.cs b/src/Core/AdminConsole/Services/Implementations/RabbitMqEventListenerService.cs index 1ee3fa5ea7..74833f38a0 100644 --- a/src/Core/AdminConsole/Services/Implementations/RabbitMqEventListenerService.cs +++ b/src/Core/AdminConsole/Services/Implementations/RabbitMqEventListenerService.cs @@ -29,7 +29,7 @@ public class RabbitMqEventListenerService : EventLoggingListenerService UserName = globalSettings.EventLogging.RabbitMq.Username, Password = globalSettings.EventLogging.RabbitMq.Password }; - _exchangeName = globalSettings.EventLogging.RabbitMq.ExchangeName; + _exchangeName = globalSettings.EventLogging.RabbitMq.EventExchangeName; _logger = logger; _queueName = queueName; } diff --git a/src/Core/AdminConsole/Services/Implementations/RabbitMqEventWriteService.cs b/src/Core/AdminConsole/Services/Implementations/RabbitMqEventWriteService.cs index 86abddec58..05fcf71a92 100644 --- a/src/Core/AdminConsole/Services/Implementations/RabbitMqEventWriteService.cs +++ b/src/Core/AdminConsole/Services/Implementations/RabbitMqEventWriteService.cs @@ -18,7 +18,7 @@ public class RabbitMqEventWriteService : IEventWriteService, IAsyncDisposable UserName = globalSettings.EventLogging.RabbitMq.Username, Password = globalSettings.EventLogging.RabbitMq.Password }; - _exchangeName = globalSettings.EventLogging.RabbitMq.ExchangeName; + _exchangeName = globalSettings.EventLogging.RabbitMq.EventExchangeName; _lazyConnection = new Lazy>(CreateConnectionAsync); } diff --git a/src/Core/AdminConsole/Services/Implementations/RabbitMqIntegrationListenerService.cs b/src/Core/AdminConsole/Services/Implementations/RabbitMqIntegrationListenerService.cs new file mode 100644 index 0000000000..1d6910db95 --- /dev/null +++ b/src/Core/AdminConsole/Services/Implementations/RabbitMqIntegrationListenerService.cs @@ -0,0 +1,191 @@ +using System.Text; +using Bit.Core.Settings; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using RabbitMQ.Client; +using RabbitMQ.Client.Events; + +namespace Bit.Core.Services; + +public class RabbitMqIntegrationListenerService : BackgroundService +{ + private const string _deadLetterRoutingKey = "dead-letter"; + private IChannel _channel; + private IConnection _connection; + private readonly string _exchangeName; + private readonly string _queueName; + private readonly string _retryQueueName; + private readonly string _deadLetterQueueName; + private readonly string _routingKey; + private readonly string _retryRoutingKey; + private readonly int _maxRetries; + private readonly IIntegrationHandler _handler; + private readonly ConnectionFactory _factory; + private readonly ILogger _logger; + private readonly int _retryTiming; + + public RabbitMqIntegrationListenerService(IIntegrationHandler handler, + string routingKey, + string queueName, + string retryQueueName, + string deadLetterQueueName, + GlobalSettings globalSettings, + ILogger logger) + { + _handler = handler; + _routingKey = routingKey; + _retryRoutingKey = $"{_routingKey}-retry"; + _queueName = queueName; + _retryQueueName = retryQueueName; + _deadLetterQueueName = deadLetterQueueName; + _logger = logger; + _exchangeName = globalSettings.EventLogging.RabbitMq.IntegrationExchangeName; + _maxRetries = globalSettings.EventLogging.RabbitMq.MaxRetries; + _retryTiming = globalSettings.EventLogging.RabbitMq.RetryTiming; + + _factory = new ConnectionFactory + { + HostName = globalSettings.EventLogging.RabbitMq.HostName, + UserName = globalSettings.EventLogging.RabbitMq.Username, + Password = globalSettings.EventLogging.RabbitMq.Password + }; + } + + public override async Task StartAsync(CancellationToken cancellationToken) + { + _connection = await _factory.CreateConnectionAsync(cancellationToken); + _channel = await _connection.CreateChannelAsync(cancellationToken: cancellationToken); + + await _channel.ExchangeDeclareAsync(exchange: _exchangeName, + type: ExchangeType.Direct, + durable: true, + cancellationToken: cancellationToken); + + // Declare main queue + await _channel.QueueDeclareAsync(queue: _queueName, + durable: true, + exclusive: false, + autoDelete: false, + arguments: null, + cancellationToken: cancellationToken); + await _channel.QueueBindAsync(queue: _queueName, + exchange: _exchangeName, + routingKey: _routingKey, + cancellationToken: cancellationToken); + + // Declare retry queue (Configurable TTL, dead-letters back to main queue) + await _channel.QueueDeclareAsync(queue: _retryQueueName, + durable: true, + exclusive: false, + autoDelete: false, + arguments: new Dictionary + { + { "x-dead-letter-exchange", _exchangeName }, + { "x-dead-letter-routing-key", _routingKey }, + { "x-message-ttl", _retryTiming } + }, + cancellationToken: cancellationToken); + await _channel.QueueBindAsync(queue: _retryQueueName, + exchange: _exchangeName, + routingKey: _retryRoutingKey, + cancellationToken: cancellationToken); + + // Declare dead letter queue + await _channel.QueueDeclareAsync(queue: _deadLetterQueueName, + durable: true, + exclusive: false, + autoDelete: false, + arguments: null, + cancellationToken: cancellationToken); + await _channel.QueueBindAsync(queue: _deadLetterQueueName, + exchange: _exchangeName, + routingKey: _deadLetterRoutingKey, + cancellationToken: cancellationToken); + + await base.StartAsync(cancellationToken); + } + + protected override async Task ExecuteAsync(CancellationToken cancellationToken) + { + var consumer = new AsyncEventingBasicConsumer(_channel); + consumer.ReceivedAsync += async (_, ea) => + { + var json = Encoding.UTF8.GetString(ea.Body.Span); + + try + { + var result = await _handler.HandleAsync(json); + var message = result.Message; + + if (result.Success) + { + // Successful integration send. Acknowledge message delivery and return + await _channel.BasicAckAsync(ea.DeliveryTag, false, cancellationToken); + return; + } + + if (result.Retryable) + { + // Integration failed, but is retryable - apply delay and check max retries + message.ApplyRetry(result.DelayUntilDate); + + if (message.RetryCount < _maxRetries) + { + // Publish message to the retry queue. It will be re-published for retry after a delay + await _channel.BasicPublishAsync( + exchange: _exchangeName, + routingKey: _retryRoutingKey, + body: Encoding.UTF8.GetBytes(message.ToJson()), + cancellationToken: cancellationToken); + } + else + { + // Exceeded the max number of retries; fail and send to dead letter queue + await PublishToDeadLetterAsync(message.ToJson()); + _logger.LogWarning("Max retry attempts reached. Sent to DLQ."); + } + } + else + { + // Fatal error (i.e. not retryable) occurred. Send message to dead letter queue without any retries + await PublishToDeadLetterAsync(message.ToJson()); + _logger.LogWarning("Non-retryable failure. Sent to DLQ."); + } + + // Message has been sent to retry or dead letter queues. + // Acknowledge receipt so Rabbit knows it's been processed + await _channel.BasicAckAsync(ea.DeliveryTag, false, cancellationToken); + } + catch (Exception ex) + { + // Unknown error occurred. Acknowledge so Rabbit doesn't keep attempting. Log the error + _logger.LogError(ex, "Unhandled error processing integration message."); + await _channel.BasicAckAsync(ea.DeliveryTag, false, cancellationToken); + } + }; + + await _channel.BasicConsumeAsync(queue: _queueName, autoAck: false, consumer: consumer, cancellationToken: cancellationToken); + } + + private async Task PublishToDeadLetterAsync(string json) + { + await _channel.BasicPublishAsync( + exchange: _exchangeName, + routingKey: _deadLetterRoutingKey, + body: Encoding.UTF8.GetBytes(json)); + } + + public override async Task StopAsync(CancellationToken cancellationToken) + { + await _channel.CloseAsync(cancellationToken); + await _connection.CloseAsync(cancellationToken); + await base.StopAsync(cancellationToken); + } + + public override void Dispose() + { + _channel.Dispose(); + _connection.Dispose(); + base.Dispose(); + } +} diff --git a/src/Core/AdminConsole/Services/Implementations/RabbitMqIntegrationPublisher.cs b/src/Core/AdminConsole/Services/Implementations/RabbitMqIntegrationPublisher.cs new file mode 100644 index 0000000000..12801e3216 --- /dev/null +++ b/src/Core/AdminConsole/Services/Implementations/RabbitMqIntegrationPublisher.cs @@ -0,0 +1,54 @@ +using System.Text; +using Bit.Core.AdminConsole.Models.Data.Integrations; +using Bit.Core.Enums; +using Bit.Core.Settings; +using RabbitMQ.Client; + +namespace Bit.Core.Services; + +public class RabbitMqIntegrationPublisher : IIntegrationPublisher, IAsyncDisposable +{ + private readonly ConnectionFactory _factory; + private readonly Lazy> _lazyConnection; + private readonly string _exchangeName; + + public RabbitMqIntegrationPublisher(GlobalSettings globalSettings) + { + _factory = new ConnectionFactory + { + HostName = globalSettings.EventLogging.RabbitMq.HostName, + UserName = globalSettings.EventLogging.RabbitMq.Username, + Password = globalSettings.EventLogging.RabbitMq.Password + }; + _exchangeName = globalSettings.EventLogging.RabbitMq.IntegrationExchangeName; + + _lazyConnection = new Lazy>(CreateConnectionAsync); + } + + public async Task PublishAsync(IIntegrationMessage message) + { + var routingKey = message.IntegrationType.ToRoutingKey(); + var connection = await _lazyConnection.Value; + await using var channel = await connection.CreateChannelAsync(); + + await channel.ExchangeDeclareAsync(exchange: _exchangeName, type: ExchangeType.Direct, durable: true); + + var body = Encoding.UTF8.GetBytes(message.ToJson()); + + await channel.BasicPublishAsync(exchange: _exchangeName, routingKey: routingKey, body: body); + } + + public async ValueTask DisposeAsync() + { + if (_lazyConnection.IsValueCreated) + { + var connection = await _lazyConnection.Value; + await connection.DisposeAsync(); + } + } + + private async Task CreateConnectionAsync() + { + return await _factory.CreateConnectionAsync(); + } +} diff --git a/src/Core/AdminConsole/Services/Implementations/SlackEventHandler.cs b/src/Core/AdminConsole/Services/Implementations/SlackEventHandler.cs deleted file mode 100644 index 3ddecc67f4..0000000000 --- a/src/Core/AdminConsole/Services/Implementations/SlackEventHandler.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Nodes; -using Bit.Core.Enums; -using Bit.Core.Models.Data.Integrations; -using Bit.Core.Repositories; - -#nullable enable - -namespace Bit.Core.Services; - -public class SlackEventHandler( - IUserRepository userRepository, - IOrganizationRepository organizationRepository, - IOrganizationIntegrationConfigurationRepository configurationRepository, - ISlackService slackService) - : IntegrationEventHandlerBase(userRepository, organizationRepository, configurationRepository) -{ - protected override IntegrationType GetIntegrationType() => IntegrationType.Slack; - - protected override async Task ProcessEventIntegrationAsync(JsonObject mergedConfiguration, - string renderedTemplate) - { - var config = mergedConfiguration.Deserialize(); - if (config is null) - { - return; - } - - await slackService.SendSlackMessageByChannelIdAsync( - config.token, - renderedTemplate, - config.channelId - ); - } -} diff --git a/src/Core/AdminConsole/Services/Implementations/SlackIntegrationHandler.cs b/src/Core/AdminConsole/Services/Implementations/SlackIntegrationHandler.cs new file mode 100644 index 0000000000..134e93e838 --- /dev/null +++ b/src/Core/AdminConsole/Services/Implementations/SlackIntegrationHandler.cs @@ -0,0 +1,19 @@ +using Bit.Core.AdminConsole.Models.Data.Integrations; + +namespace Bit.Core.Services; + +public class SlackIntegrationHandler( + ISlackService slackService) + : IntegrationHandlerBase +{ + public override async Task HandleAsync(IntegrationMessage message) + { + await slackService.SendSlackMessageByChannelIdAsync( + message.Configuration.token, + message.RenderedTemplate, + message.Configuration.channelId + ); + + return new IntegrationHandlerResult(success: true, message: message); + } +} diff --git a/src/Core/AdminConsole/Services/Implementations/WebhookEventHandler.cs b/src/Core/AdminConsole/Services/Implementations/WebhookEventHandler.cs deleted file mode 100644 index ec6924bb3e..0000000000 --- a/src/Core/AdminConsole/Services/Implementations/WebhookEventHandler.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System.Text; -using System.Text.Json; -using System.Text.Json.Nodes; -using Bit.Core.Enums; -using Bit.Core.Models.Data.Integrations; -using Bit.Core.Repositories; - -#nullable enable - -namespace Bit.Core.Services; - -public class WebhookEventHandler( - IHttpClientFactory httpClientFactory, - IUserRepository userRepository, - IOrganizationRepository organizationRepository, - IOrganizationIntegrationConfigurationRepository configurationRepository) - : IntegrationEventHandlerBase(userRepository, organizationRepository, configurationRepository) -{ - private readonly HttpClient _httpClient = httpClientFactory.CreateClient(HttpClientName); - - public const string HttpClientName = "WebhookEventHandlerHttpClient"; - - protected override IntegrationType GetIntegrationType() => IntegrationType.Webhook; - - protected override async Task ProcessEventIntegrationAsync(JsonObject mergedConfiguration, - string renderedTemplate) - { - var config = mergedConfiguration.Deserialize(); - if (config is null || string.IsNullOrEmpty(config.url)) - { - return; - } - - var content = new StringContent(renderedTemplate, Encoding.UTF8, "application/json"); - var response = await _httpClient.PostAsync(config.url, content); - response.EnsureSuccessStatusCode(); - } -} diff --git a/src/Core/AdminConsole/Services/Implementations/WebhookIntegrationHandler.cs b/src/Core/AdminConsole/Services/Implementations/WebhookIntegrationHandler.cs new file mode 100644 index 0000000000..5f9898afe8 --- /dev/null +++ b/src/Core/AdminConsole/Services/Implementations/WebhookIntegrationHandler.cs @@ -0,0 +1,61 @@ +using System.Globalization; +using System.Net; +using System.Text; +using Bit.Core.AdminConsole.Models.Data.Integrations; + +#nullable enable + +namespace Bit.Core.Services; + +public class WebhookIntegrationHandler(IHttpClientFactory httpClientFactory) + : IntegrationHandlerBase +{ + private readonly HttpClient _httpClient = httpClientFactory.CreateClient(HttpClientName); + + public const string HttpClientName = "WebhookIntegrationHandlerHttpClient"; + + public override async Task HandleAsync(IntegrationMessage message) + { + var content = new StringContent(message.RenderedTemplate, Encoding.UTF8, "application/json"); + var response = await _httpClient.PostAsync(message.Configuration.url, content); + var result = new IntegrationHandlerResult(success: response.IsSuccessStatusCode, message); + + 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; + + 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 = DateTime.UtcNow.AddSeconds(seconds); + } + 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; + break; + } + + return result; + } +} diff --git a/src/Core/AdminConsole/Utilities/IntegrationTemplateProcessor.cs b/src/Core/AdminConsole/Utilities/IntegrationTemplateProcessor.cs index 4fb5c15e63..aab4e448e5 100644 --- a/src/Core/AdminConsole/Utilities/IntegrationTemplateProcessor.cs +++ b/src/Core/AdminConsole/Utilities/IntegrationTemplateProcessor.cs @@ -1,4 +1,6 @@ -using System.Text.RegularExpressions; +#nullable enable + +using System.Text.RegularExpressions; namespace Bit.Core.AdminConsole.Utilities; @@ -9,7 +11,7 @@ public static partial class IntegrationTemplateProcessor public static string ReplaceTokens(string template, object values) { - if (string.IsNullOrEmpty(template) || values == null) + if (string.IsNullOrEmpty(template)) { return template; } diff --git a/src/Core/Auth/Entities/SsoUser.cs b/src/Core/Auth/Entities/SsoUser.cs index 3199f00221..2e457afbc6 100644 --- a/src/Core/Auth/Entities/SsoUser.cs +++ b/src/Core/Auth/Entities/SsoUser.cs @@ -8,7 +8,7 @@ public class SsoUser : ITableObject public long Id { get; set; } public Guid UserId { get; set; } public Guid? OrganizationId { get; set; } - [MaxLength(50)] + [MaxLength(300)] public string ExternalId { get; set; } public DateTime CreationDate { get; internal set; } = DateTime.UtcNow; diff --git a/src/Core/Auth/Enums/EmergencyAccessStatusType.cs b/src/Core/Auth/Enums/EmergencyAccessStatusType.cs index 7faaa11752..d817d6a950 100644 --- a/src/Core/Auth/Enums/EmergencyAccessStatusType.cs +++ b/src/Core/Auth/Enums/EmergencyAccessStatusType.cs @@ -2,9 +2,24 @@ public enum EmergencyAccessStatusType : byte { + /// + /// The user has been invited to be an emergency contact. + /// Invited = 0, + /// + /// The invited user, "grantee", has accepted the request to be an emergency contact. + /// Accepted = 1, + /// + /// The inviting user, "grantor", has approved the grantee's acceptance. + /// Confirmed = 2, + /// + /// The grantee has initiated the recovery process. + /// RecoveryInitiated = 3, + /// + /// The grantee has excercised their emergency access. + /// RecoveryApproved = 4, } diff --git a/src/Core/Auth/Enums/EmergencyAccessType.cs b/src/Core/Auth/Enums/EmergencyAccessType.cs index a3497cc287..6e4e6e7f56 100644 --- a/src/Core/Auth/Enums/EmergencyAccessType.cs +++ b/src/Core/Auth/Enums/EmergencyAccessType.cs @@ -2,6 +2,12 @@ public enum EmergencyAccessType : byte { + /// + /// Allows emergency contact to view the Grantor's data. + /// View = 0, + /// + /// Allows emergency contact to take over the Grantor's account by overwriting the Grantor's password. + /// Takeover = 1, } diff --git a/src/Core/Auth/Identity/TokenProviders/EmailTwoFactorTokenProvider.cs b/src/Core/Auth/Identity/TokenProviders/EmailTwoFactorTokenProvider.cs index 718e44ae5f..2f8481cea2 100644 --- a/src/Core/Auth/Identity/TokenProviders/EmailTwoFactorTokenProvider.cs +++ b/src/Core/Auth/Identity/TokenProviders/EmailTwoFactorTokenProvider.cs @@ -43,7 +43,7 @@ public class EmailTwoFactorTokenProvider : EmailTokenProvider private static bool HasProperMetaData(TwoFactorProvider provider) { - return provider?.MetaData != null && provider.MetaData.ContainsKey("Email") && - !string.IsNullOrWhiteSpace((string)provider.MetaData["Email"]); + return provider?.MetaData != null && provider.MetaData.TryGetValue("Email", out var emailValue) && + !string.IsNullOrWhiteSpace((string)emailValue); } } diff --git a/src/Core/Auth/Identity/TokenProviders/WebAuthnTokenProvider.cs b/src/Core/Auth/Identity/TokenProviders/WebAuthnTokenProvider.cs index 0bf75d0fc3..3b4b0fa520 100644 --- a/src/Core/Auth/Identity/TokenProviders/WebAuthnTokenProvider.cs +++ b/src/Core/Auth/Identity/TokenProviders/WebAuthnTokenProvider.cs @@ -80,7 +80,7 @@ public class WebAuthnTokenProvider : IUserTwoFactorTokenProvider var provider = user.GetTwoFactorProvider(TwoFactorProviderType.WebAuthn); var keys = LoadKeys(provider); - if (!provider.MetaData.TryGetValue("login", out var value)) + if (!provider.MetaData.TryGetValue("login", out var login)) { return false; } @@ -88,7 +88,7 @@ public class WebAuthnTokenProvider : IUserTwoFactorTokenProvider var clientResponse = JsonSerializer.Deserialize(token, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); - var jsonOptions = value.ToString(); + var jsonOptions = login.ToString(); var options = AssertionOptions.FromJson(jsonOptions); var webAuthCred = keys.Find(k => k.Item2.Descriptor.Id.SequenceEqual(clientResponse.Id)); @@ -148,9 +148,9 @@ public class WebAuthnTokenProvider : IUserTwoFactorTokenProvider for (var i = 1; i <= 5; i++) { var keyName = $"Key{i}"; - if (provider.MetaData.ContainsKey(keyName)) + if (provider.MetaData.TryGetValue(keyName, out var value)) { - var key = new TwoFactorProvider.WebAuthnData((dynamic)provider.MetaData[keyName]); + var key = new TwoFactorProvider.WebAuthnData((dynamic)value); keys.Add(new Tuple(keyName, key)); } diff --git a/src/Core/Auth/Services/Implementations/EmergencyAccessService.cs b/src/Core/Auth/Services/EmergencyAccess/EmergencyAccessService.cs similarity index 74% rename from src/Core/Auth/Services/Implementations/EmergencyAccessService.cs rename to src/Core/Auth/Services/EmergencyAccess/EmergencyAccessService.cs index dda16e29fe..6a8fe9dd17 100644 --- a/src/Core/Auth/Services/Implementations/EmergencyAccessService.cs +++ b/src/Core/Auth/Services/EmergencyAccess/EmergencyAccessService.cs @@ -3,7 +3,6 @@ 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; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Models.Data; using Bit.Core.Entities; @@ -16,7 +15,6 @@ using Bit.Core.Tokens; using Bit.Core.Vault.Models.Data; using Bit.Core.Vault.Repositories; using Bit.Core.Vault.Services; -using Microsoft.AspNetCore.Identity; namespace Bit.Core.Auth.Services; @@ -31,8 +29,6 @@ public class EmergencyAccessService : IEmergencyAccessService private readonly IMailService _mailService; private readonly IUserService _userService; private readonly GlobalSettings _globalSettings; - private readonly IPasswordHasher _passwordHasher; - private readonly IOrganizationService _organizationService; private readonly IDataProtectorTokenFactory _dataProtectorTokenizer; private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; @@ -45,9 +41,7 @@ public class EmergencyAccessService : IEmergencyAccessService ICipherService cipherService, IMailService mailService, IUserService userService, - IPasswordHasher passwordHasher, GlobalSettings globalSettings, - IOrganizationService organizationService, IDataProtectorTokenFactory dataProtectorTokenizer, IRemoveOrganizationUserCommand removeOrganizationUserCommand) { @@ -59,45 +53,43 @@ public class EmergencyAccessService : IEmergencyAccessService _cipherService = cipherService; _mailService = mailService; _userService = userService; - _passwordHasher = passwordHasher; _globalSettings = globalSettings; - _organizationService = organizationService; _dataProtectorTokenizer = dataProtectorTokenizer; _removeOrganizationUserCommand = removeOrganizationUserCommand; } - public async Task InviteAsync(User invitingUser, string email, EmergencyAccessType type, int waitTime) + public async Task InviteAsync(User grantorUser, string emergencyContactEmail, EmergencyAccessType accessType, int waitTime) { - if (!await _userService.CanAccessPremium(invitingUser)) + if (!await _userService.CanAccessPremium(grantorUser)) { throw new BadRequestException("Not a premium user."); } - if (type == EmergencyAccessType.Takeover && invitingUser.UsesKeyConnector) + if (accessType == EmergencyAccessType.Takeover && grantorUser.UsesKeyConnector) { throw new BadRequestException("You cannot use Emergency Access Takeover because you are using Key Connector."); } var emergencyAccess = new EmergencyAccess { - GrantorId = invitingUser.Id, - Email = email.ToLowerInvariant(), + GrantorId = grantorUser.Id, + Email = emergencyContactEmail.ToLowerInvariant(), Status = EmergencyAccessStatusType.Invited, - Type = type, + Type = accessType, WaitTimeDays = waitTime, CreationDate = DateTime.UtcNow, RevisionDate = DateTime.UtcNow, }; await _emergencyAccessRepository.CreateAsync(emergencyAccess); - await SendInviteAsync(emergencyAccess, NameOrEmail(invitingUser)); + await SendInviteAsync(emergencyAccess, NameOrEmail(grantorUser)); return emergencyAccess; } - public async Task GetAsync(Guid emergencyAccessId, Guid userId) + public async Task GetAsync(Guid emergencyAccessId, Guid grantorId) { - var emergencyAccess = await _emergencyAccessRepository.GetDetailsByIdGrantorIdAsync(emergencyAccessId, userId); + var emergencyAccess = await _emergencyAccessRepository.GetDetailsByIdGrantorIdAsync(emergencyAccessId, grantorId); if (emergencyAccess == null) { throw new BadRequestException("Emergency Access not valid."); @@ -106,19 +98,19 @@ public class EmergencyAccessService : IEmergencyAccessService return emergencyAccess; } - public async Task ResendInviteAsync(User invitingUser, Guid emergencyAccessId) + public async Task ResendInviteAsync(User grantorUser, Guid emergencyAccessId) { var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId); - if (emergencyAccess == null || emergencyAccess.GrantorId != invitingUser.Id || + if (emergencyAccess == null || emergencyAccess.GrantorId != grantorUser.Id || emergencyAccess.Status != EmergencyAccessStatusType.Invited) { throw new BadRequestException("Emergency Access not valid."); } - await SendInviteAsync(emergencyAccess, NameOrEmail(invitingUser)); + await SendInviteAsync(emergencyAccess, NameOrEmail(grantorUser)); } - public async Task AcceptUserAsync(Guid emergencyAccessId, User user, 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) @@ -126,7 +118,12 @@ public class EmergencyAccessService : IEmergencyAccessService throw new BadRequestException("Emergency Access not valid."); } - if (!_dataProtectorTokenizer.TryUnprotect(token, out var data) && data.IsValid(emergencyAccessId, user.Email)) + if (!_dataProtectorTokenizer.TryUnprotect(token, out var data)) + { + throw new BadRequestException("Invalid token."); + } + + if (!data.IsValid(emergencyAccessId, granteeUser.Email)) { throw new BadRequestException("Invalid token."); } @@ -140,8 +137,10 @@ public class EmergencyAccessService : IEmergencyAccessService throw new BadRequestException("Invitation already accepted."); } + // TODO PM-21687 + // Might not be reachable since the Tokenable.IsValid() does an email comparison if (string.IsNullOrWhiteSpace(emergencyAccess.Email) || - !emergencyAccess.Email.Equals(user.Email, StringComparison.InvariantCultureIgnoreCase)) + !emergencyAccess.Email.Equals(granteeUser.Email, StringComparison.InvariantCultureIgnoreCase)) { throw new BadRequestException("User email does not match invite."); } @@ -149,7 +148,7 @@ public class EmergencyAccessService : IEmergencyAccessService var granteeEmail = emergencyAccess.Email; emergencyAccess.Status = EmergencyAccessStatusType.Accepted; - emergencyAccess.GranteeId = user.Id; + emergencyAccess.GranteeId = granteeUser.Id; emergencyAccess.Email = null; var grantor = await userService.GetUserByIdAsync(emergencyAccess.GrantorId); @@ -163,6 +162,8 @@ public class EmergencyAccessService : IEmergencyAccessService public async Task DeleteAsync(Guid emergencyAccessId, Guid grantorId) { var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId); + // TODO PM-19438/PM-21687 + // Not sure why the GrantorId and the GranteeId are supposed to be the same? if (emergencyAccess == null || (emergencyAccess.GrantorId != grantorId && emergencyAccess.GranteeId != grantorId)) { throw new BadRequestException("Emergency Access not valid."); @@ -171,16 +172,16 @@ public class EmergencyAccessService : IEmergencyAccessService await _emergencyAccessRepository.DeleteAsync(emergencyAccess); } - public async Task ConfirmUserAsync(Guid emergencyAcccessId, string key, Guid confirmingUserId) + public async Task ConfirmUserAsync(Guid emergencyAccessId, string key, Guid grantorId) { - var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAcccessId); + var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId); if (emergencyAccess == null || emergencyAccess.Status != EmergencyAccessStatusType.Accepted || - emergencyAccess.GrantorId != confirmingUserId) + emergencyAccess.GrantorId != grantorId) { throw new BadRequestException("Emergency Access not valid."); } - var grantor = await _userRepository.GetByIdAsync(confirmingUserId); + var grantor = await _userRepository.GetByIdAsync(grantorId); if (emergencyAccess.Type == EmergencyAccessType.Takeover && grantor.UsesKeyConnector) { throw new BadRequestException("You cannot use Emergency Access Takeover because you are using Key Connector."); @@ -197,14 +198,14 @@ public class EmergencyAccessService : IEmergencyAccessService return emergencyAccess; } - public async Task SaveAsync(EmergencyAccess emergencyAccess, User savingUser) + public async Task SaveAsync(EmergencyAccess emergencyAccess, User grantorUser) { - if (!await _userService.CanAccessPremium(savingUser)) + if (!await _userService.CanAccessPremium(grantorUser)) { throw new BadRequestException("Not a premium user."); } - if (emergencyAccess.GrantorId != savingUser.Id) + if (emergencyAccess.GrantorId != grantorUser.Id) { throw new BadRequestException("Emergency Access not valid."); } @@ -221,11 +222,11 @@ public class EmergencyAccessService : IEmergencyAccessService await _emergencyAccessRepository.ReplaceAsync(emergencyAccess); } - public async Task InitiateAsync(Guid id, User initiatingUser) + // TODO PM-21687: rename this to something like InitiateRecoveryAsync, and something similar for Approve and Reject + public async Task InitiateAsync(Guid emergencyAccessId, User granteeUser) { - var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(id); - - if (emergencyAccess == null || emergencyAccess.GranteeId != initiatingUser.Id || + var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId); + if (emergencyAccess == null || emergencyAccess.GranteeId != granteeUser.Id || emergencyAccess.Status != EmergencyAccessStatusType.Confirmed) { throw new BadRequestException("Emergency Access not valid."); @@ -245,14 +246,14 @@ public class EmergencyAccessService : IEmergencyAccessService emergencyAccess.LastNotificationDate = now; await _emergencyAccessRepository.ReplaceAsync(emergencyAccess); - await _mailService.SendEmergencyAccessRecoveryInitiated(emergencyAccess, NameOrEmail(initiatingUser), grantor.Email); + await _mailService.SendEmergencyAccessRecoveryInitiated(emergencyAccess, NameOrEmail(granteeUser), grantor.Email); } - public async Task ApproveAsync(Guid id, User approvingUser) + public async Task ApproveAsync(Guid emergencyAccessId, User grantorUser) { - var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(id); + var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId); - if (emergencyAccess == null || emergencyAccess.GrantorId != approvingUser.Id || + if (emergencyAccess == null || emergencyAccess.GrantorId != grantorUser.Id || emergencyAccess.Status != EmergencyAccessStatusType.RecoveryInitiated) { throw new BadRequestException("Emergency Access not valid."); @@ -262,14 +263,14 @@ public class EmergencyAccessService : IEmergencyAccessService await _emergencyAccessRepository.ReplaceAsync(emergencyAccess); var grantee = await _userRepository.GetByIdAsync(emergencyAccess.GranteeId.Value); - await _mailService.SendEmergencyAccessRecoveryApproved(emergencyAccess, NameOrEmail(approvingUser), grantee.Email); + await _mailService.SendEmergencyAccessRecoveryApproved(emergencyAccess, NameOrEmail(grantorUser), grantee.Email); } - public async Task RejectAsync(Guid id, User rejectingUser) + public async Task RejectAsync(Guid emergencyAccessId, User grantorUser) { - var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(id); + var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId); - if (emergencyAccess == null || emergencyAccess.GrantorId != rejectingUser.Id || + if (emergencyAccess == null || emergencyAccess.GrantorId != grantorUser.Id || (emergencyAccess.Status != EmergencyAccessStatusType.RecoveryInitiated && emergencyAccess.Status != EmergencyAccessStatusType.RecoveryApproved)) { @@ -280,14 +281,17 @@ public class EmergencyAccessService : IEmergencyAccessService await _emergencyAccessRepository.ReplaceAsync(emergencyAccess); var grantee = await _userRepository.GetByIdAsync(emergencyAccess.GranteeId.Value); - await _mailService.SendEmergencyAccessRecoveryRejected(emergencyAccess, NameOrEmail(rejectingUser), grantee.Email); + await _mailService.SendEmergencyAccessRecoveryRejected(emergencyAccess, NameOrEmail(grantorUser), grantee.Email); } - public async Task> GetPoliciesAsync(Guid id, User requestingUser) + public async Task> GetPoliciesAsync(Guid emergencyAccessId, User granteeUser) { - var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(id); + // TODO PM-21687 + // Should we look up policies here or just verify the EmergencyAccess is correct + // and handle policy logic else where? Should this be a query/Command? + var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId); - if (!IsValidRequest(emergencyAccess, requestingUser, EmergencyAccessType.Takeover)) + if (!IsValidRequest(emergencyAccess, granteeUser, EmergencyAccessType.Takeover)) { throw new BadRequestException("Emergency Access not valid."); } @@ -295,23 +299,27 @@ public class EmergencyAccessService : IEmergencyAccessService var grantor = await _userRepository.GetByIdAsync(emergencyAccess.GrantorId); var grantorOrganizations = await _organizationUserRepository.GetManyByUserAsync(grantor.Id); - var isOrganizationOwner = grantorOrganizations.Any(organization => organization.Type == OrganizationUserType.Owner); + var isOrganizationOwner = grantorOrganizations + .Any(organization => organization.Type == OrganizationUserType.Owner); + var policies = isOrganizationOwner ? await _policyRepository.GetManyByUserIdAsync(grantor.Id) : null; return policies; } - public async Task<(EmergencyAccess, User)> TakeoverAsync(Guid id, User requestingUser) + // TODO PM-21687: rename this to something like InitiateRecoveryTakeoverAsync + public async Task<(EmergencyAccess, User)> TakeoverAsync(Guid emergencyAccessId, User granteeUser) { - var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(id); + var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId); - if (!IsValidRequest(emergencyAccess, requestingUser, EmergencyAccessType.Takeover)) + if (!IsValidRequest(emergencyAccess, granteeUser, EmergencyAccessType.Takeover)) { throw new BadRequestException("Emergency Access not valid."); } var grantor = await _userRepository.GetByIdAsync(emergencyAccess.GrantorId); - + // TODO PM-21687 + // Redundant check of the EmergencyAccessType -> checked in IsValidRequest() ln 308 if (emergencyAccess.Type == EmergencyAccessType.Takeover && grantor.UsesKeyConnector) { throw new BadRequestException("You cannot takeover an account that is using Key Connector."); @@ -320,11 +328,12 @@ public class EmergencyAccessService : IEmergencyAccessService return (emergencyAccess, grantor); } - public async Task PasswordAsync(Guid id, User requestingUser, string newMasterPasswordHash, string key) + // TODO PM-21687: rename this to something like FinishRecoveryTakeoverAsync + public async Task PasswordAsync(Guid emergencyAccessId, User granteeUser, string newMasterPasswordHash, string key) { - var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(id); + var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId); - if (!IsValidRequest(emergencyAccess, requestingUser, EmergencyAccessType.Takeover)) + if (!IsValidRequest(emergencyAccess, granteeUser, EmergencyAccessType.Takeover)) { throw new BadRequestException("Emergency Access not valid."); } @@ -336,7 +345,9 @@ public class EmergencyAccessService : IEmergencyAccessService grantor.LastPasswordChangeDate = grantor.RevisionDate; grantor.Key = key; // Disable TwoFactor providers since they will otherwise block logins - grantor.SetTwoFactorProviders(new Dictionary()); + grantor.SetTwoFactorProviders([]); + // Disable New Device Verification since it will otherwise block logins + grantor.VerifyDevices = false; await _userRepository.ReplaceAsync(grantor); // Remove grantor from all organizations unless Owner @@ -384,11 +395,11 @@ public class EmergencyAccessService : IEmergencyAccessService } } - public async Task ViewAsync(Guid id, User requestingUser) + public async Task ViewAsync(Guid emergencyAccessId, User granteeUser) { - var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(id); + var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId); - if (!IsValidRequest(emergencyAccess, requestingUser, EmergencyAccessType.View)) + if (!IsValidRequest(emergencyAccess, granteeUser, EmergencyAccessType.View)) { throw new BadRequestException("Emergency Access not valid."); } @@ -402,11 +413,11 @@ public class EmergencyAccessService : IEmergencyAccessService }; } - public async Task GetAttachmentDownloadAsync(Guid id, Guid cipherId, string attachmentId, User requestingUser) + public async Task GetAttachmentDownloadAsync(Guid emergencyAccessId, Guid cipherId, string attachmentId, User granteeUser) { - var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(id); + var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId); - if (!IsValidRequest(emergencyAccess, requestingUser, EmergencyAccessType.View)) + if (!IsValidRequest(emergencyAccess, granteeUser, EmergencyAccessType.View)) { throw new BadRequestException("Emergency Access not valid."); } @@ -421,12 +432,23 @@ public class EmergencyAccessService : IEmergencyAccessService await _mailService.SendEmergencyAccessInviteEmailAsync(emergencyAccess, invitingUsersName, token); } - private string NameOrEmail(User user) + // TODO PM-21687: move this to the user entity -> User.GetNameOrEmail()? + private static string NameOrEmail(User user) { return string.IsNullOrWhiteSpace(user.Name) ? user.Email : user.Name; } - private bool IsValidRequest(EmergencyAccess availableAccess, User requestingUser, EmergencyAccessType requestedAccessType) + /* + * Checks if EmergencyAccess Object is null + * Checks the requesting user is the same as the granteeUser (So we are checking for proper grantee action) + * Status _must_ equal RecoveryApproved (This means the grantor has invited, the grantee has accepted, and the grantor has approved so the shared key exists but hasn't been exercised yet) + * request type must equal the type of access requested (View or Takeover) + */ + //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, + User requestingUser, + EmergencyAccessType requestedAccessType) { return availableAccess != null && availableAccess.GranteeId == requestingUser.Id && diff --git a/src/Core/Auth/Services/EmergencyAccess/IEmergencyAccessService.cs b/src/Core/Auth/Services/EmergencyAccess/IEmergencyAccessService.cs new file mode 100644 index 0000000000..de695bbd7d --- /dev/null +++ b/src/Core/Auth/Services/EmergencyAccess/IEmergencyAccessService.cs @@ -0,0 +1,147 @@ +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; +using Bit.Core.Enums; +using Bit.Core.Services; +using Bit.Core.Vault.Models.Data; + +namespace Bit.Core.Auth.Services; + +public interface IEmergencyAccessService +{ + /// + /// Invites a user via email to become an emergency contact for the Grantor user. The Grantor must have a premium subscription. + /// the grantor user must not be a member of the organization that uses KeyConnector. + /// + /// The user initiating the emergency contact request + /// Emergency contact + /// 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); + /// + /// Sends an invite to the emergency contact associated with the emergency access id. + /// + /// The grantor. This must be the owner of the Emergency Access object + /// The Id of the emergency access being requested. + /// void + Task ResendInviteAsync(User grantorUser, Guid emergencyAccessId); + /// + /// A grantee user accepts the emergency contact request. This updates the emergency access status to be + /// "Accepted", this is the middle step before the grantor user confirms the request. + /// + /// Id of the emergency access object being acted on. + /// User being invited to be an emergency contact + /// the tokenable that was sent via email + /// service dependency + /// void + Task AcceptUserAsync(Guid emergencyAccessId, User granteeUser, string token, IUserService userService); + /// + /// The creator of the emergency access request can delete the request. + /// + /// Id of the emergency access being acted on + /// Id of the owner trying to delete the emergency access request + /// void + Task DeleteAsync(Guid emergencyAccessId, Guid grantorId); + /// + /// The grantor user confirms the acceptance of the emergency contact request. This stores the encrypted key allowing the grantee + /// access based on the emergency access type. + /// + /// Id of the emergency access being acted on. + /// 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); + /// + /// Fetches an emergency access object. The grantor user must own the object being fetched. + /// + /// Id of emergency access object + /// Id of the owner of the emergency access object. + /// Details of the emergency access object + Task GetAsync(Guid emergencyAccessId, Guid grantorId); + /// + /// Updates the emergency access object. + /// + /// emergency access entity being updated + /// grantor user + /// void + Task SaveAsync(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. + /// + /// EmergencyAccess Id + /// grantee user + /// void + Task InitiateAsync(Guid emergencyAccessId, User granteeUser); + /// + /// Approves a recovery request. Sets the EmergencyAccess.Status to RecoveryApproved. + /// + /// emergency access id + /// grantor user + /// void + Task ApproveAsync(Guid emergencyAccessId, User grantorUser); + /// + /// Rejects a recovery request. Sets the EmergencyAccess.Status to Confirmed. This does not remove the emergency access entity. The + /// Grantee user can still initiate another recovery request. + /// + /// emergency access id + /// grantor user + /// void + Task RejectAsync(Guid emergencyAccessId, User grantorUser); + /// + /// This request is made by the Grantee user to fetch the policies for the Grantor User. + /// The Grantor User has to be the owner of the organization. + /// If the Grantor user has OrganizationUserType.Owner then the policies for the _Grantor_ user + /// are returned. This is used to ensure the password is of the proper complexity for the organization. + /// + /// EmergencyAccess.Id being acted on + /// User making the request, this is the Grantee + /// null if the GrantorUser is not an organization owner; A list of policies otherwise. + Task> GetPoliciesAsync(Guid emergencyAccessId, User granteeUser); + /// + /// Fetches the emergency access entity and grantor user. The grantor user is returned so the correct KDF configuration is + /// used for the new password. + /// + /// 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); + /// + /// Updates the grantor's password hash and updates the key for the EmergencyAccess entity. + /// + /// Emergency Access Id being acted on + /// user making the request + /// new password hash set by grantee user + /// new encrypted user key + /// void + Task PasswordAsync(Guid emergencyAccessId, User granteeUser, string newMasterPasswordHash, string key); + /// + /// sends a reminder email that there is a pending request for recovery. + /// + /// void + Task SendNotificationsAsync(); + /// + /// This handles the auto approval of recovery requests started in the method. + /// An email will be sent to the Grantee and the Grantor notifying each the recovery has been approved. + /// + /// void + Task HandleTimedOutRequestsAsync(); + /// + /// Fetched ciphers from the grantors account for the grantee to view. + /// + /// Emergency access entity being acted on + /// user requesting cipher items + /// ciphers associated with the emergency access request + Task ViewAsync(Guid emergencyAccessId, User granteeUser); + /// + /// Returns attachment if the grantee user has access to the cipher through the emergency access entity. + /// + /// EmergencyAccess entity being acted on + /// cipher entity containing the attachment + /// Attachment entity + /// user making the request + /// attachment response + Task GetAttachmentDownloadAsync(Guid emergencyAccessId, Guid cipherId, string attachmentId, User granteeUser); +} diff --git a/src/Core/Auth/Services/EmergencyAccess/readme.md b/src/Core/Auth/Services/EmergencyAccess/readme.md new file mode 100644 index 0000000000..e2bdec3916 --- /dev/null +++ b/src/Core/Auth/Services/EmergencyAccess/readme.md @@ -0,0 +1,95 @@ +# Emergency Access System +This system allows users to share their `User.Key` with other users using public key exchange. An emergency contact (a grantee user) can view or takeover (reset the password) of the grantor user. + +When an account is taken over all two factor methods are turned off and device verification is disabled. + +This system is affected by the Key Rotation feature. The `EmergencyAccess.KeyEncrypted` is the `Grantor.UserKey` encrypted by the `Grantee.PublicKey`. So if the `User.Key` is rotated then all `EmergencyAccess` entities will need to be updated. + +## Special Cases +Users who use `KeyConnector` are not able to allow `Takeover` of their accounts. However, they can allow `View`. + +When a grantee user _takes over_ a grantor user's account, the grantor user will be removed from all organizations where the grantor user is not the `OrganizationUserType.Owner`. A grantor user will not be removed from organizations if the `EmergencyAccessType` is `View`. The grantee user will only be able to `View` the grantor user's ciphers, and not any of the organization ciphers, if any exist. + +## Step 1. Invitation + +A grantor user invites another user to be their emergency contact, the grantee. This will create a new `EmergencyAccess` entity in the database with the `EmergencyAccessStatusType` set to `Invited`. +The `EmergencyAccess.KeyEncrypted` field is empty, and the `GranteeId` is `null` since the user being invited via email may not have an account yet. + +### code +```csharp +// creates entity. +Task InviteAsync(User grantorUser, string emergencyContactEmail, EmergencyAccessType accessType, int waitTime); +// resend email to the EmergencyAccess.Email. +Task ResendInviteAsync(User grantorUser, Guid emergencyAccessId); +``` + +## Step 2. Acceptance + +The grantee user receives an email they have been invited to be an emergency contact for a grantor user. + +At this point the grantee user can accept the request. This will set the `EmergencyAccess.GranteeId` to the `User.Id` of the grantee user. The `EmergencyAccess.Status` is set to `Accepted`. + +If the grantee user does not have an account then they can create an account and accept the invitation. + +### Code +```csharp +// accepts the request to be an emergency contact. +Task AcceptUserAsync(Guid emergencyAccessId, User granteeUser, string token, IUserService userService); +``` + +## Step 3. Confirmation + +Once the grantee user has accepted, the `EmergencyAccess.GranteeId` allows the grantor user the ability to query for the `GranteeUser.PublicKey`. With the `Grantee.PublicKey`, the grantor on the client is able to safely encrypt their `User.Key` and save the encrypted string to the database. + +The `EmergencyAccess.Status` is set to `Confirmed`, and the `EmergencyAccess.KeyEncrypted` is set. + +### Code +```csharp +Task ConfirmUserAsync(Guid emergencyAccessId, string key, Guid grantorId); +``` + +## Step 4. Recovery Approval + +The grantee user can now exercise the ability to view or takeover the account. This is done by initiating the recovery. Initiating recovery has a time delay specified by `EmergencyAccess.WaitTime`. `WaitTime` is set in the initial invite. The grantor user can approve the request before the `WaitTime`, but they _cannot_ reject the request _after_ the `WaitTime` has completed. If the recovery request is not rejected then once the `WaitTime` has passed the grantee user will be able to access the emergency access entity. + +### Code +```csharp +// Initiates the recovery process; Will set EmergencyAccess.Status to RecoveryInitiated. +Task InitiateAsync(Guid id, User granteeUser); +// Approved the recovery request; Will set EmergencyAccess.Status to RecoveryApproved. +Task ApproveAsync(Guid id, User approvingUser); +// Rejects the recovery request; Will set EmergencyAccess.Status to Confirmed. +Task RejectAsync(Guid id, User rejectingUser); +// Automatically set the EmergencyAccess.Status to RecoveryApproved after WaitTime has passed. +Task HandleTimedOutRequestsAsync(); +``` + +## Step 5. Recovering the account + +Once the `EmergencyAccess.Status` is `RecoveryApproved` the grantee user is able to exercise their ability to view or takeover the grantor account. Viewing allows the grantee user to view the vault data of the grantor user. Takeover allows the grantee to change the password of the grantor user. + +### Takeover +`TakeoverAsync(Guid, User)` returns the grantor user object along with the `EmergencyAccess` entity. The grantor user object is required since to update the password the client needs access to the grantor kdf configuration. Once the password has been set in the `PasswordAsync(Guid, User, string, string)` the account has been successfully recovered. + +Taking over the account will change the password of the grantor user, empty the two factor array on the grantor user, and disable device verification. + +```csharp +// Takeover returns the grantor user and the emergency access entity. +Task<(EmergencyAccess, User)> TakeoverAsync(Guid emergencyAccessId, User granteeUser); +// Password sets the password for the grantor user. +Task PasswordAsync(Guid emergencyAccessId, User granteeUser, string newMasterPasswordHash, string key); +// Returns Ciphers the Grantee is allowed to view based on the EmergencyAccess status. +Task ViewAsync(Guid emergencyAccessId, User granteeUser); +// Returns downloadable cipher attachments based on the EmergencyAccess status. +Task GetAttachmentDownloadAsync(Guid emergencyAccessId, Guid cipherId, string attachmentId, User granteeUser); +``` + +## Optional steps + +The grantor user is able to delete an emergency contact at anytime, at any point in the recovery process. + +### Code +```csharp +// deletes the associated EmergencyAccess Entity +Task DeleteAsync(Guid emergencyAccessId, Guid grantorId); +``` diff --git a/src/Core/Auth/Services/IEmergencyAccessService.cs b/src/Core/Auth/Services/IEmergencyAccessService.cs deleted file mode 100644 index 2c94632510..0000000000 --- a/src/Core/Auth/Services/IEmergencyAccessService.cs +++ /dev/null @@ -1,30 +0,0 @@ -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; -using Bit.Core.Services; -using Bit.Core.Vault.Models.Data; - -namespace Bit.Core.Auth.Services; - -public interface IEmergencyAccessService -{ - Task InviteAsync(User invitingUser, string email, EmergencyAccessType type, int waitTime); - Task ResendInviteAsync(User invitingUser, Guid emergencyAccessId); - Task AcceptUserAsync(Guid emergencyAccessId, User user, string token, IUserService userService); - Task DeleteAsync(Guid emergencyAccessId, Guid grantorId); - Task ConfirmUserAsync(Guid emergencyAccessId, string key, Guid grantorId); - Task GetAsync(Guid emergencyAccessId, Guid userId); - Task SaveAsync(EmergencyAccess emergencyAccess, User savingUser); - Task InitiateAsync(Guid id, User initiatingUser); - Task ApproveAsync(Guid id, User approvingUser); - Task RejectAsync(Guid id, User rejectingUser); - Task> GetPoliciesAsync(Guid id, User requestingUser); - Task<(EmergencyAccess, User)> TakeoverAsync(Guid id, User initiatingUser); - Task PasswordAsync(Guid id, User user, string newMasterPasswordHash, string key); - Task SendNotificationsAsync(); - Task HandleTimedOutRequestsAsync(); - Task ViewAsync(Guid id, User user); - Task GetAttachmentDownloadAsync(Guid id, Guid cipherId, string attachmentId, User user); -} diff --git a/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs b/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs index 834d2722cc..289bbff7f8 100644 --- a/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs +++ b/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs @@ -3,7 +3,6 @@ using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; using Bit.Core.Auth.Models.Business.Tokenables; -using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Exceptions; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; @@ -11,9 +10,6 @@ using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Tokens; -using Bit.Core.Tools.Enums; -using Bit.Core.Tools.Models.Business; -using Bit.Core.Tools.Services; using Bit.Core.Utilities; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Identity; @@ -26,15 +22,12 @@ public class RegisterUserCommand : IRegisterUserCommand private readonly IGlobalSettings _globalSettings; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IPolicyRepository _policyRepository; - private readonly IReferenceEventService _referenceEventService; private readonly IDataProtectorTokenFactory _orgUserInviteTokenDataFactory; private readonly IDataProtectorTokenFactory _registrationEmailVerificationTokenDataFactory; private readonly IDataProtector _organizationServiceDataProtector; private readonly IDataProtector _providerServiceDataProtector; - private readonly ICurrentContext _currentContext; - private readonly IUserService _userService; private readonly IMailService _mailService; @@ -48,11 +41,9 @@ public class RegisterUserCommand : IRegisterUserCommand IGlobalSettings globalSettings, IOrganizationUserRepository organizationUserRepository, IPolicyRepository policyRepository, - IReferenceEventService referenceEventService, IDataProtectionProvider dataProtectionProvider, IDataProtectorTokenFactory orgUserInviteTokenDataFactory, IDataProtectorTokenFactory registrationEmailVerificationTokenDataFactory, - ICurrentContext currentContext, IUserService userService, IMailService mailService, IValidateRedemptionTokenCommand validateRedemptionTokenCommand, @@ -62,14 +53,12 @@ public class RegisterUserCommand : IRegisterUserCommand _globalSettings = globalSettings; _organizationUserRepository = organizationUserRepository; _policyRepository = policyRepository; - _referenceEventService = referenceEventService; _organizationServiceDataProtector = dataProtectionProvider.CreateProtector( "OrganizationServiceDataProtector"); _orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory; _registrationEmailVerificationTokenDataFactory = registrationEmailVerificationTokenDataFactory; - _currentContext = currentContext; _userService = userService; _mailService = mailService; @@ -86,7 +75,6 @@ public class RegisterUserCommand : IRegisterUserCommand if (result == IdentityResult.Success) { await _mailService.SendWelcomeEmailAsync(user); - await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.Signup, user, _currentContext)); } return result; @@ -108,6 +96,7 @@ public class RegisterUserCommand : IRegisterUserCommand var result = await _userService.CreateUserAsync(user, masterPasswordHash); if (result == IdentityResult.Success) { + var sentWelcomeEmail = false; if (!string.IsNullOrEmpty(user.ReferenceData)) { var referenceData = JsonConvert.DeserializeObject>(user.ReferenceData); @@ -115,20 +104,18 @@ public class RegisterUserCommand : IRegisterUserCommand { var initiationPath = value.ToString(); await SendAppropriateWelcomeEmailAsync(user, initiationPath); + sentWelcomeEmail = true; if (!string.IsNullOrEmpty(initiationPath)) { - await _referenceEventService.RaiseEventAsync( - new ReferenceEvent(ReferenceEventType.Signup, user, _currentContext) - { - SignupInitiationPath = initiationPath - }); - return result; } } } - await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.Signup, user, _currentContext)); + if (!sentWelcomeEmail) + { + await _mailService.SendWelcomeEmailAsync(user); + } } return result; @@ -256,10 +243,6 @@ public class RegisterUserCommand : IRegisterUserCommand if (result == IdentityResult.Success) { await _mailService.SendWelcomeEmailAsync(user); - await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.Signup, user, _currentContext) - { - ReceiveMarketingEmails = tokenable.ReceiveMarketingEmails - }); } return result; @@ -278,7 +261,6 @@ public class RegisterUserCommand : IRegisterUserCommand if (result == IdentityResult.Success) { await _mailService.SendWelcomeEmailAsync(user); - await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.Signup, user, _currentContext)); } return result; @@ -299,7 +281,6 @@ public class RegisterUserCommand : IRegisterUserCommand if (result == IdentityResult.Success) { await _mailService.SendWelcomeEmailAsync(user); - await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.Signup, user, _currentContext)); } return result; @@ -318,7 +299,6 @@ public class RegisterUserCommand : IRegisterUserCommand if (result == IdentityResult.Success) { await _mailService.SendWelcomeEmailAsync(user); - await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.Signup, user, _currentContext)); } return result; diff --git a/src/Core/Billing/Constants/StripeConstants.cs b/src/Core/Billing/Constants/StripeConstants.cs index c3e3ec6c30..28f4dea4b2 100644 --- a/src/Core/Billing/Constants/StripeConstants.cs +++ b/src/Core/Billing/Constants/StripeConstants.cs @@ -2,10 +2,6 @@ public static class StripeConstants { - public static class Prices - { - public const string StoragePlanPersonal = "personal-storage-gb-annually"; - } public static class AutomaticTaxStatus { public const string Failed = "failed"; @@ -69,6 +65,11 @@ public static class StripeConstants public const string USBankAccount = "us_bank_account"; } + public static class Prices + { + public const string StoragePlanPersonal = "personal-storage-gb-annually"; + } + public static class ProrationBehavior { public const string AlwaysInvoice = "always_invoice"; @@ -88,6 +89,13 @@ public static class StripeConstants public const string Paused = "paused"; } + public static class TaxExempt + { + public const string Exempt = "exempt"; + public const string None = "none"; + public const string Reverse = "reverse"; + } + public static class ValidateTaxLocationTiming { public const string Deferred = "deferred"; diff --git a/src/Core/Billing/Extensions/CustomerExtensions.cs b/src/Core/Billing/Extensions/CustomerExtensions.cs index 3e0c1ea0fb..aa22331f7c 100644 --- a/src/Core/Billing/Extensions/CustomerExtensions.cs +++ b/src/Core/Billing/Extensions/CustomerExtensions.cs @@ -15,12 +15,7 @@ public static class CustomerExtensions } }; - /// - /// Determines if a Stripe customer supports automatic tax - /// - /// - /// - public static bool HasTaxLocationVerified(this Customer customer) => + public static bool HasRecognizedTaxLocation(this Customer customer) => customer?.Tax?.AutomaticTax != StripeConstants.AutomaticTaxStatus.UnrecognizedLocation; public static decimal GetBillingBalance(this Customer customer) diff --git a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs index 17285e0676..5c7a42e9b8 100644 --- a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs +++ b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs @@ -4,7 +4,9 @@ using Bit.Core.Billing.Licenses.Extensions; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Billing.Services.Implementations; -using Bit.Core.Billing.Services.Implementations.AutomaticTax; +using Bit.Core.Billing.Tax.Commands; +using Bit.Core.Billing.Tax.Services; +using Bit.Core.Billing.Tax.Services.Implementations; namespace Bit.Core.Billing.Extensions; @@ -24,5 +26,6 @@ public static class ServiceCollectionExtensions services.AddTransient(); services.AddLicenseServices(); services.AddPricingClient(); + services.AddTransient(); } } diff --git a/src/Core/Billing/Extensions/SubscriptionUpdateOptionsExtensions.cs b/src/Core/Billing/Extensions/SubscriptionUpdateOptionsExtensions.cs index d70af78fa8..22a715733b 100644 --- a/src/Core/Billing/Extensions/SubscriptionUpdateOptionsExtensions.cs +++ b/src/Core/Billing/Extensions/SubscriptionUpdateOptionsExtensions.cs @@ -22,7 +22,7 @@ public static class SubscriptionUpdateOptionsExtensions } // We might only need to check the automatic tax status. - if (!customer.HasTaxLocationVerified() && string.IsNullOrWhiteSpace(customer.Address?.Country)) + if (!customer.HasRecognizedTaxLocation() && string.IsNullOrWhiteSpace(customer.Address?.Country)) { return false; } diff --git a/src/Core/Billing/Extensions/UpcomingInvoiceOptionsExtensions.cs b/src/Core/Billing/Extensions/UpcomingInvoiceOptionsExtensions.cs index 88df5638c9..d00b5b46a4 100644 --- a/src/Core/Billing/Extensions/UpcomingInvoiceOptionsExtensions.cs +++ b/src/Core/Billing/Extensions/UpcomingInvoiceOptionsExtensions.cs @@ -22,7 +22,7 @@ public static class UpcomingInvoiceOptionsExtensions } // We might only need to check the automatic tax status. - if (!customer.HasTaxLocationVerified() && string.IsNullOrWhiteSpace(customer.Address?.Country)) + if (!customer.HasRecognizedTaxLocation() && string.IsNullOrWhiteSpace(customer.Address?.Country)) { return false; } diff --git a/src/Core/Billing/Licenses/LicenseConstants.cs b/src/Core/Billing/Licenses/LicenseConstants.cs index 8ef896d6f9..cdfac76614 100644 --- a/src/Core/Billing/Licenses/LicenseConstants.cs +++ b/src/Core/Billing/Licenses/LicenseConstants.cs @@ -34,7 +34,6 @@ public static class OrganizationLicenseConstants public const string UseSecretsManager = nameof(UseSecretsManager); public const string SmSeats = nameof(SmSeats); public const string SmServiceAccounts = nameof(SmServiceAccounts); - public const string SmMaxProjects = nameof(SmMaxProjects); public const string LimitCollectionCreationDeletion = nameof(LimitCollectionCreationDeletion); public const string AllowAdminAccessToAllCollectionItems = nameof(AllowAdminAccessToAllCollectionItems); public const string UseRiskInsights = nameof(UseRiskInsights); @@ -43,6 +42,7 @@ public static class OrganizationLicenseConstants public const string ExpirationWithoutGracePeriod = nameof(ExpirationWithoutGracePeriod); public const string Trial = nameof(Trial); public const string UseAdminSponsoredFamilies = nameof(UseAdminSponsoredFamilies); + public const string UseOrganizationDomains = nameof(UseOrganizationDomains); } public static class UserLicenseConstants diff --git a/src/Core/Billing/Licenses/Models/LicenseContext.cs b/src/Core/Billing/Licenses/Models/LicenseContext.cs index 01eb3ac80c..8dcc24e939 100644 --- a/src/Core/Billing/Licenses/Models/LicenseContext.cs +++ b/src/Core/Billing/Licenses/Models/LicenseContext.cs @@ -7,5 +7,4 @@ public class LicenseContext { public Guid? InstallationId { get; init; } public required SubscriptionInfo SubscriptionInfo { get; init; } - public int? SmMaxProjects { get; set; } } diff --git a/src/Core/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactory.cs b/src/Core/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactory.cs index 7406ac16d9..b3f2ab4ec9 100644 --- a/src/Core/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactory.cs +++ b/src/Core/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactory.cs @@ -54,6 +54,7 @@ public class OrganizationLicenseClaimsFactory : ILicenseClaimsFactory new(BillingErrorTranslationKeys.TaxIdInvalid); + public static BadRequest TaxLocationInvalid => new(BillingErrorTranslationKeys.CustomerTaxLocationInvalid); + public static BadRequest UnknownTaxIdType => new(BillingErrorTranslationKeys.UnknownTaxIdType); +} + +public record Unhandled(string TranslationKey = BillingErrorTranslationKeys.UnhandledError); + +public class BillingCommandResult : OneOfBase +{ + private BillingCommandResult(OneOf input) : base(input) { } + + public static implicit operator BillingCommandResult(T output) => new(output); + public static implicit operator BillingCommandResult(BadRequest badRequest) => new(badRequest); + public static implicit operator BillingCommandResult(Unhandled unhandled) => new(unhandled); +} + +public static class BillingErrorTranslationKeys +{ + // "The tax ID number you provided was invalid. Please try again or contact support." + public const string TaxIdInvalid = "taxIdInvalid"; + + // "Your location wasn't recognized. Please ensure your country and postal code are valid and try again." + public const string CustomerTaxLocationInvalid = "customerTaxLocationInvalid"; + + // "Something went wrong with your request. Please contact support." + public const string UnhandledError = "unhandledBillingError"; + + // "We couldn't find a corresponding tax ID type for the tax ID you provided. Please try again or contact support." + public const string UnknownTaxIdType = "unknownTaxIdType"; +} diff --git a/src/Core/Billing/Models/OrganizationMetadata.cs b/src/Core/Billing/Models/OrganizationMetadata.cs index 41666949bf..0f2bf9a454 100644 --- a/src/Core/Billing/Models/OrganizationMetadata.cs +++ b/src/Core/Billing/Models/OrganizationMetadata.cs @@ -10,7 +10,8 @@ public record OrganizationMetadata( bool IsSubscriptionCanceled, DateTime? InvoiceDueDate, DateTime? InvoiceCreatedDate, - DateTime? SubPeriodEndDate) + DateTime? SubPeriodEndDate, + int OrganizationOccupiedSeats) { public static OrganizationMetadata Default => new OrganizationMetadata( false, @@ -22,5 +23,6 @@ public record OrganizationMetadata( false, null, null, - null); + null, + 0); } diff --git a/src/Core/Billing/Models/PaymentMethod.cs b/src/Core/Billing/Models/PaymentMethod.cs index b07fe82e46..2b8c59fa05 100644 --- a/src/Core/Billing/Models/PaymentMethod.cs +++ b/src/Core/Billing/Models/PaymentMethod.cs @@ -1,4 +1,6 @@ -namespace Bit.Core.Billing.Models; +using Bit.Core.Billing.Tax.Models; + +namespace Bit.Core.Billing.Models; public record PaymentMethod( long AccountCredit, diff --git a/src/Core/Billing/Models/Sales/CustomerSetup.cs b/src/Core/Billing/Models/Sales/CustomerSetup.cs index bb4f2352e3..aa67c712b5 100644 --- a/src/Core/Billing/Models/Sales/CustomerSetup.cs +++ b/src/Core/Billing/Models/Sales/CustomerSetup.cs @@ -1,4 +1,6 @@ -namespace Bit.Core.Billing.Models.Sales; +using Bit.Core.Billing.Tax.Models; + +namespace Bit.Core.Billing.Models.Sales; #nullable enable diff --git a/src/Core/Billing/Models/Sales/OrganizationSale.cs b/src/Core/Billing/Models/Sales/OrganizationSale.cs index 0602cf1dd9..78ad26871b 100644 --- a/src/Core/Billing/Models/Sales/OrganizationSale.cs +++ b/src/Core/Billing/Models/Sales/OrganizationSale.cs @@ -1,5 +1,6 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Tax.Models; using Bit.Core.Models.Business; namespace Bit.Core.Billing.Models.Sales; @@ -26,12 +27,21 @@ public class OrganizationSale public static OrganizationSale From( Organization organization, - OrganizationSignup signup) => new() + OrganizationSignup signup) + { + var customerSetup = string.IsNullOrEmpty(organization.GatewayCustomerId) ? GetCustomerSetup(signup) : null; + + var subscriptionSetup = GetSubscriptionSetup(signup); + + subscriptionSetup.SkipTrial = signup.SkipTrial; + + return new OrganizationSale { Organization = organization, - CustomerSetup = string.IsNullOrEmpty(organization.GatewayCustomerId) ? GetCustomerSetup(signup) : null, - SubscriptionSetup = GetSubscriptionSetup(signup) + CustomerSetup = customerSetup, + SubscriptionSetup = subscriptionSetup }; + } public static OrganizationSale From( Organization organization, diff --git a/src/Core/Billing/Models/Sales/PremiumUserSale.cs b/src/Core/Billing/Models/Sales/PremiumUserSale.cs index 6bc054eac5..8c9b696aa3 100644 --- a/src/Core/Billing/Models/Sales/PremiumUserSale.cs +++ b/src/Core/Billing/Models/Sales/PremiumUserSale.cs @@ -1,4 +1,5 @@ -using Bit.Core.Entities; +using Bit.Core.Billing.Tax.Models; +using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Business; diff --git a/src/Core/Billing/Models/StaticStore/Plan.cs b/src/Core/Billing/Models/StaticStore/Plan.cs index 17aa78aa06..d710594f46 100644 --- a/src/Core/Billing/Models/StaticStore/Plan.cs +++ b/src/Core/Billing/Models/StaticStore/Plan.cs @@ -24,6 +24,7 @@ public abstract record Plan public bool Has2fa { get; protected init; } public bool HasApi { get; protected init; } public bool HasSso { get; protected init; } + public bool HasOrganizationDomains { get; protected init; } public bool HasKeyConnector { get; protected init; } public bool HasScim { get; protected init; } public bool HasResetPassword { get; protected init; } diff --git a/src/Core/Billing/Models/StaticStore/Plans/Enterprise2019Plan.cs b/src/Core/Billing/Models/StaticStore/Plans/Enterprise2019Plan.cs index 72db7897b4..b584647a26 100644 --- a/src/Core/Billing/Models/StaticStore/Plans/Enterprise2019Plan.cs +++ b/src/Core/Billing/Models/StaticStore/Plans/Enterprise2019Plan.cs @@ -26,6 +26,7 @@ public record Enterprise2019Plan : Plan Has2fa = true; HasApi = true; HasSso = true; + HasOrganizationDomains = true; HasKeyConnector = true; HasScim = true; HasResetPassword = true; diff --git a/src/Core/Billing/Models/StaticStore/Plans/Enterprise2020Plan.cs b/src/Core/Billing/Models/StaticStore/Plans/Enterprise2020Plan.cs index 42b984e7e5..a1a6113cbc 100644 --- a/src/Core/Billing/Models/StaticStore/Plans/Enterprise2020Plan.cs +++ b/src/Core/Billing/Models/StaticStore/Plans/Enterprise2020Plan.cs @@ -26,6 +26,7 @@ public record Enterprise2020Plan : Plan Has2fa = true; HasApi = true; HasSso = true; + HasOrganizationDomains = true; HasKeyConnector = true; HasScim = true; HasResetPassword = true; diff --git a/src/Core/Billing/Models/StaticStore/Plans/EnterprisePlan.cs b/src/Core/Billing/Models/StaticStore/Plans/EnterprisePlan.cs index 2d498a7654..8aeca521d1 100644 --- a/src/Core/Billing/Models/StaticStore/Plans/EnterprisePlan.cs +++ b/src/Core/Billing/Models/StaticStore/Plans/EnterprisePlan.cs @@ -26,6 +26,7 @@ public record EnterprisePlan : Plan Has2fa = true; HasApi = true; HasSso = true; + HasOrganizationDomains = true; HasKeyConnector = true; HasScim = true; HasResetPassword = true; diff --git a/src/Core/Billing/Models/StaticStore/Plans/EnterprisePlan2023.cs b/src/Core/Billing/Models/StaticStore/Plans/EnterprisePlan2023.cs index 8cd8335425..dce1719a49 100644 --- a/src/Core/Billing/Models/StaticStore/Plans/EnterprisePlan2023.cs +++ b/src/Core/Billing/Models/StaticStore/Plans/EnterprisePlan2023.cs @@ -26,6 +26,7 @@ public record Enterprise2023Plan : Plan Has2fa = true; HasApi = true; HasSso = true; + HasOrganizationDomains = true; HasKeyConnector = true; HasScim = true; HasResetPassword = true; diff --git a/src/Core/Billing/Pricing/PlanAdapter.cs b/src/Core/Billing/Pricing/PlanAdapter.cs index c38eb0501d..f719fd1e87 100644 --- a/src/Core/Billing/Pricing/PlanAdapter.cs +++ b/src/Core/Billing/Pricing/PlanAdapter.cs @@ -26,6 +26,7 @@ public record PlanAdapter : Plan Has2fa = HasFeature("2fa"); HasApi = HasFeature("api"); HasSso = HasFeature("sso"); + HasOrganizationDomains = HasFeature("organizationDomains"); HasKeyConnector = HasFeature("keyConnector"); HasScim = HasFeature("scim"); HasResetPassword = HasFeature("resetPassword"); diff --git a/src/Core/Billing/Entities/ClientOrganizationMigrationRecord.cs b/src/Core/Billing/Providers/Entities/ClientOrganizationMigrationRecord.cs similarity index 88% rename from src/Core/Billing/Entities/ClientOrganizationMigrationRecord.cs rename to src/Core/Billing/Providers/Entities/ClientOrganizationMigrationRecord.cs index 1e719b3ceb..bbb0a90b04 100644 --- a/src/Core/Billing/Entities/ClientOrganizationMigrationRecord.cs +++ b/src/Core/Billing/Providers/Entities/ClientOrganizationMigrationRecord.cs @@ -1,12 +1,12 @@ -using System.ComponentModel.DataAnnotations; +#nullable enable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Billing.Enums; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Utilities; -#nullable enable - -namespace Bit.Core.Billing.Entities; +namespace Bit.Core.Billing.Providers.Entities; public class ClientOrganizationMigrationRecord : ITableObject { diff --git a/src/Core/Billing/Entities/ProviderInvoiceItem.cs b/src/Core/Billing/Providers/Entities/ProviderInvoiceItem.cs similarity index 87% rename from src/Core/Billing/Entities/ProviderInvoiceItem.cs rename to src/Core/Billing/Providers/Entities/ProviderInvoiceItem.cs index 566d7514e7..9d9eeda754 100644 --- a/src/Core/Billing/Entities/ProviderInvoiceItem.cs +++ b/src/Core/Billing/Providers/Entities/ProviderInvoiceItem.cs @@ -1,10 +1,10 @@ -using System.ComponentModel.DataAnnotations; +#nullable enable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Entities; using Bit.Core.Utilities; -#nullable enable - -namespace Bit.Core.Billing.Entities; +namespace Bit.Core.Billing.Providers.Entities; public class ProviderInvoiceItem : ITableObject { diff --git a/src/Core/Billing/Entities/ProviderPlan.cs b/src/Core/Billing/Providers/Entities/ProviderPlan.cs similarity index 86% rename from src/Core/Billing/Entities/ProviderPlan.cs rename to src/Core/Billing/Providers/Entities/ProviderPlan.cs index fd131f64e6..d06c81e9ce 100644 --- a/src/Core/Billing/Entities/ProviderPlan.cs +++ b/src/Core/Billing/Providers/Entities/ProviderPlan.cs @@ -1,10 +1,10 @@ -using Bit.Core.Billing.Enums; +#nullable enable + +using Bit.Core.Billing.Enums; using Bit.Core.Entities; using Bit.Core.Utilities; -#nullable enable - -namespace Bit.Core.Billing.Entities; +namespace Bit.Core.Billing.Providers.Entities; public class ProviderPlan : ITableObject { diff --git a/src/Core/Billing/Migration/Models/ClientMigrationTracker.cs b/src/Core/Billing/Providers/Migration/Models/ClientMigrationTracker.cs similarity index 90% rename from src/Core/Billing/Migration/Models/ClientMigrationTracker.cs rename to src/Core/Billing/Providers/Migration/Models/ClientMigrationTracker.cs index 69398004fd..ae0c28de86 100644 --- a/src/Core/Billing/Migration/Models/ClientMigrationTracker.cs +++ b/src/Core/Billing/Providers/Migration/Models/ClientMigrationTracker.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.Billing.Migration.Models; +namespace Bit.Core.Billing.Providers.Migration.Models; public enum ClientMigrationProgress { diff --git a/src/Core/Billing/Migration/Models/ProviderMigrationResult.cs b/src/Core/Billing/Providers/Migration/Models/ProviderMigrationResult.cs similarity index 93% rename from src/Core/Billing/Migration/Models/ProviderMigrationResult.cs rename to src/Core/Billing/Providers/Migration/Models/ProviderMigrationResult.cs index 137ba8bd0d..6f3c3be11d 100644 --- a/src/Core/Billing/Migration/Models/ProviderMigrationResult.cs +++ b/src/Core/Billing/Providers/Migration/Models/ProviderMigrationResult.cs @@ -1,6 +1,6 @@ -using Bit.Core.Billing.Entities; +using Bit.Core.Billing.Providers.Entities; -namespace Bit.Core.Billing.Migration.Models; +namespace Bit.Core.Billing.Providers.Migration.Models; public class ProviderMigrationResult { diff --git a/src/Core/Billing/Migration/Models/ProviderMigrationTracker.cs b/src/Core/Billing/Providers/Migration/Models/ProviderMigrationTracker.cs similarity index 90% rename from src/Core/Billing/Migration/Models/ProviderMigrationTracker.cs rename to src/Core/Billing/Providers/Migration/Models/ProviderMigrationTracker.cs index 7bfef8a931..f4708d4cbd 100644 --- a/src/Core/Billing/Migration/Models/ProviderMigrationTracker.cs +++ b/src/Core/Billing/Providers/Migration/Models/ProviderMigrationTracker.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.Billing.Migration.Models; +namespace Bit.Core.Billing.Providers.Migration.Models; public enum ProviderMigrationProgress { diff --git a/src/Core/Billing/Migration/ServiceCollectionExtensions.cs b/src/Core/Billing/Providers/Migration/ServiceCollectionExtensions.cs similarity index 71% rename from src/Core/Billing/Migration/ServiceCollectionExtensions.cs rename to src/Core/Billing/Providers/Migration/ServiceCollectionExtensions.cs index 109259d59a..1061c82888 100644 --- a/src/Core/Billing/Migration/ServiceCollectionExtensions.cs +++ b/src/Core/Billing/Providers/Migration/ServiceCollectionExtensions.cs @@ -1,8 +1,8 @@ -using Bit.Core.Billing.Migration.Services; -using Bit.Core.Billing.Migration.Services.Implementations; +using Bit.Core.Billing.Providers.Migration.Services; +using Bit.Core.Billing.Providers.Migration.Services.Implementations; using Microsoft.Extensions.DependencyInjection; -namespace Bit.Core.Billing.Migration; +namespace Bit.Core.Billing.Providers.Migration; public static class ServiceCollectionExtensions { diff --git a/src/Core/Billing/Migration/Services/IMigrationTrackerCache.cs b/src/Core/Billing/Providers/Migration/Services/IMigrationTrackerCache.cs similarity index 85% rename from src/Core/Billing/Migration/Services/IMigrationTrackerCache.cs rename to src/Core/Billing/Providers/Migration/Services/IMigrationTrackerCache.cs index 6734c69566..70649590df 100644 --- a/src/Core/Billing/Migration/Services/IMigrationTrackerCache.cs +++ b/src/Core/Billing/Providers/Migration/Services/IMigrationTrackerCache.cs @@ -1,8 +1,8 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; -using Bit.Core.Billing.Migration.Models; +using Bit.Core.Billing.Providers.Migration.Models; -namespace Bit.Core.Billing.Migration.Services; +namespace Bit.Core.Billing.Providers.Migration.Services; public interface IMigrationTrackerCache { diff --git a/src/Core/Billing/Migration/Services/IOrganizationMigrator.cs b/src/Core/Billing/Providers/Migration/Services/IOrganizationMigrator.cs similarity index 72% rename from src/Core/Billing/Migration/Services/IOrganizationMigrator.cs rename to src/Core/Billing/Providers/Migration/Services/IOrganizationMigrator.cs index 7bc9443717..a0548277b4 100644 --- a/src/Core/Billing/Migration/Services/IOrganizationMigrator.cs +++ b/src/Core/Billing/Providers/Migration/Services/IOrganizationMigrator.cs @@ -1,6 +1,6 @@ using Bit.Core.AdminConsole.Entities; -namespace Bit.Core.Billing.Migration.Services; +namespace Bit.Core.Billing.Providers.Migration.Services; public interface IOrganizationMigrator { diff --git a/src/Core/Billing/Migration/Services/IProviderMigrator.cs b/src/Core/Billing/Providers/Migration/Services/IProviderMigrator.cs similarity index 55% rename from src/Core/Billing/Migration/Services/IProviderMigrator.cs rename to src/Core/Billing/Providers/Migration/Services/IProviderMigrator.cs index 9ca14e7fd9..328c2419f4 100644 --- a/src/Core/Billing/Migration/Services/IProviderMigrator.cs +++ b/src/Core/Billing/Providers/Migration/Services/IProviderMigrator.cs @@ -1,6 +1,6 @@ -using Bit.Core.Billing.Migration.Models; +using Bit.Core.Billing.Providers.Migration.Models; -namespace Bit.Core.Billing.Migration.Services; +namespace Bit.Core.Billing.Providers.Migration.Services; public interface IProviderMigrator { diff --git a/src/Core/Billing/Migration/Services/Implementations/MigrationTrackerDistributedCache.cs b/src/Core/Billing/Providers/Migration/Services/Implementations/MigrationTrackerDistributedCache.cs similarity index 96% rename from src/Core/Billing/Migration/Services/Implementations/MigrationTrackerDistributedCache.cs rename to src/Core/Billing/Providers/Migration/Services/Implementations/MigrationTrackerDistributedCache.cs index 920bc55392..ea7d118cfa 100644 --- a/src/Core/Billing/Migration/Services/Implementations/MigrationTrackerDistributedCache.cs +++ b/src/Core/Billing/Providers/Migration/Services/Implementations/MigrationTrackerDistributedCache.cs @@ -1,11 +1,11 @@ using System.Text.Json; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; -using Bit.Core.Billing.Migration.Models; +using Bit.Core.Billing.Providers.Migration.Models; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.DependencyInjection; -namespace Bit.Core.Billing.Migration.Services.Implementations; +namespace Bit.Core.Billing.Providers.Migration.Services.Implementations; public class MigrationTrackerDistributedCache( [FromKeyedServices("persistent")] diff --git a/src/Core/Billing/Migration/Services/Implementations/OrganizationMigrator.cs b/src/Core/Billing/Providers/Migration/Services/Implementations/OrganizationMigrator.cs similarity index 97% rename from src/Core/Billing/Migration/Services/Implementations/OrganizationMigrator.cs rename to src/Core/Billing/Providers/Migration/Services/Implementations/OrganizationMigrator.cs index 4d93c0119a..3b874579e5 100644 --- a/src/Core/Billing/Migration/Services/Implementations/OrganizationMigrator.cs +++ b/src/Core/Billing/Providers/Migration/Services/Implementations/OrganizationMigrator.cs @@ -1,10 +1,10 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Constants; -using Bit.Core.Billing.Entities; using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Migration.Models; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Repositories; +using Bit.Core.Billing.Providers.Entities; +using Bit.Core.Billing.Providers.Migration.Models; +using Bit.Core.Billing.Providers.Repositories; using Bit.Core.Enums; using Bit.Core.Repositories; using Bit.Core.Services; @@ -12,7 +12,7 @@ using Microsoft.Extensions.Logging; using Stripe; using Plan = Bit.Core.Models.StaticStore.Plan; -namespace Bit.Core.Billing.Migration.Services.Implementations; +namespace Bit.Core.Billing.Providers.Migration.Services.Implementations; public class OrganizationMigrator( IClientOrganizationMigrationRecordRepository clientOrganizationMigrationRecordRepository, @@ -309,6 +309,7 @@ public class OrganizationMigrator( organization.MaxStorageGb = plan.PasswordManager.BaseStorageGb; organization.UsePolicies = plan.HasPolicies; organization.UseSso = plan.HasSso; + organization.UseOrganizationDomains = plan.HasOrganizationDomains; organization.UseGroups = plan.HasGroups; organization.UseEvents = plan.HasEvents; organization.UseDirectory = plan.HasDirectory; diff --git a/src/Core/Billing/Migration/Services/Implementations/ProviderMigrator.cs b/src/Core/Billing/Providers/Migration/Services/Implementations/ProviderMigrator.cs similarity index 98% rename from src/Core/Billing/Migration/Services/Implementations/ProviderMigrator.cs rename to src/Core/Billing/Providers/Migration/Services/Implementations/ProviderMigrator.cs index 384cfca1d1..3a0b579dcf 100644 --- a/src/Core/Billing/Migration/Services/Implementations/ProviderMigrator.cs +++ b/src/Core/Billing/Providers/Migration/Services/Implementations/ProviderMigrator.cs @@ -3,18 +3,18 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Constants; -using Bit.Core.Billing.Entities; using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Migration.Models; -using Bit.Core.Billing.Repositories; -using Bit.Core.Billing.Services; -using Bit.Core.Billing.Services.Contracts; +using Bit.Core.Billing.Providers.Entities; +using Bit.Core.Billing.Providers.Migration.Models; +using Bit.Core.Billing.Providers.Models; +using Bit.Core.Billing.Providers.Repositories; +using Bit.Core.Billing.Providers.Services; using Bit.Core.Repositories; using Bit.Core.Services; using Microsoft.Extensions.Logging; using Stripe; -namespace Bit.Core.Billing.Migration.Services.Implementations; +namespace Bit.Core.Billing.Providers.Migration.Services.Implementations; public class ProviderMigrator( IClientOrganizationMigrationRecordRepository clientOrganizationMigrationRecordRepository, diff --git a/src/Core/Billing/Models/AddableOrganization.cs b/src/Core/Billing/Providers/Models/AddableOrganization.cs similarity index 72% rename from src/Core/Billing/Models/AddableOrganization.cs rename to src/Core/Billing/Providers/Models/AddableOrganization.cs index fe6d5458bd..aca7a158b0 100644 --- a/src/Core/Billing/Models/AddableOrganization.cs +++ b/src/Core/Billing/Providers/Models/AddableOrganization.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.Billing.Models; +namespace Bit.Core.Billing.Providers.Models; public record AddableOrganization( Guid Id, diff --git a/src/Core/Billing/Services/Contracts/ChangeProviderPlansCommand.cs b/src/Core/Billing/Providers/Models/ChangeProviderPlansCommand.cs similarity index 80% rename from src/Core/Billing/Services/Contracts/ChangeProviderPlansCommand.cs rename to src/Core/Billing/Providers/Models/ChangeProviderPlansCommand.cs index 385782c8ad..053d912291 100644 --- a/src/Core/Billing/Services/Contracts/ChangeProviderPlansCommand.cs +++ b/src/Core/Billing/Providers/Models/ChangeProviderPlansCommand.cs @@ -1,7 +1,7 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.Billing.Enums; -namespace Bit.Core.Billing.Services.Contracts; +namespace Bit.Core.Billing.Providers.Models; public record ChangeProviderPlanCommand( Provider Provider, diff --git a/src/Core/Billing/Models/ConfiguredProviderPlan.cs b/src/Core/Billing/Providers/Models/ConfiguredProviderPlan.cs similarity index 75% rename from src/Core/Billing/Models/ConfiguredProviderPlan.cs rename to src/Core/Billing/Providers/Models/ConfiguredProviderPlan.cs index 72c1ec5b07..d875106a9e 100644 --- a/src/Core/Billing/Models/ConfiguredProviderPlan.cs +++ b/src/Core/Billing/Providers/Models/ConfiguredProviderPlan.cs @@ -1,11 +1,12 @@ using Bit.Core.Models.StaticStore; -namespace Bit.Core.Billing.Models; +namespace Bit.Core.Billing.Providers.Models; public record ConfiguredProviderPlan( Guid Id, Guid ProviderId, Plan Plan, + decimal Price, int SeatMinimum, int PurchasedSeats, int AssignedSeats); diff --git a/src/Core/Billing/Services/Contracts/UpdateProviderSeatMinimumsCommand.cs b/src/Core/Billing/Providers/Models/UpdateProviderSeatMinimumsCommand.cs similarity index 89% rename from src/Core/Billing/Services/Contracts/UpdateProviderSeatMinimumsCommand.cs rename to src/Core/Billing/Providers/Models/UpdateProviderSeatMinimumsCommand.cs index 2d2535b60a..dfd04e6605 100644 --- a/src/Core/Billing/Services/Contracts/UpdateProviderSeatMinimumsCommand.cs +++ b/src/Core/Billing/Providers/Models/UpdateProviderSeatMinimumsCommand.cs @@ -1,7 +1,7 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.Billing.Enums; -namespace Bit.Core.Billing.Services.Contracts; +namespace Bit.Core.Billing.Providers.Models; /// The provider to update the seat minimums for. /// The new seat minimums for the provider. diff --git a/src/Core/Billing/Repositories/IClientOrganizationMigrationRecordRepository.cs b/src/Core/Billing/Providers/Repositories/IClientOrganizationMigrationRecordRepository.cs similarity index 77% rename from src/Core/Billing/Repositories/IClientOrganizationMigrationRecordRepository.cs rename to src/Core/Billing/Providers/Repositories/IClientOrganizationMigrationRecordRepository.cs index 2165984383..53eb51403f 100644 --- a/src/Core/Billing/Repositories/IClientOrganizationMigrationRecordRepository.cs +++ b/src/Core/Billing/Providers/Repositories/IClientOrganizationMigrationRecordRepository.cs @@ -1,7 +1,7 @@ -using Bit.Core.Billing.Entities; +using Bit.Core.Billing.Providers.Entities; using Bit.Core.Repositories; -namespace Bit.Core.Billing.Repositories; +namespace Bit.Core.Billing.Providers.Repositories; public interface IClientOrganizationMigrationRecordRepository : IRepository { diff --git a/src/Core/Billing/Repositories/IProviderInvoiceItemRepository.cs b/src/Core/Billing/Providers/Repositories/IProviderInvoiceItemRepository.cs similarity index 74% rename from src/Core/Billing/Repositories/IProviderInvoiceItemRepository.cs rename to src/Core/Billing/Providers/Repositories/IProviderInvoiceItemRepository.cs index a722d4cf9d..931d8a9186 100644 --- a/src/Core/Billing/Repositories/IProviderInvoiceItemRepository.cs +++ b/src/Core/Billing/Providers/Repositories/IProviderInvoiceItemRepository.cs @@ -1,7 +1,7 @@ -using Bit.Core.Billing.Entities; +using Bit.Core.Billing.Providers.Entities; using Bit.Core.Repositories; -namespace Bit.Core.Billing.Repositories; +namespace Bit.Core.Billing.Providers.Repositories; public interface IProviderInvoiceItemRepository : IRepository { diff --git a/src/Core/Billing/Repositories/IProviderPlanRepository.cs b/src/Core/Billing/Providers/Repositories/IProviderPlanRepository.cs similarity index 64% rename from src/Core/Billing/Repositories/IProviderPlanRepository.cs rename to src/Core/Billing/Providers/Repositories/IProviderPlanRepository.cs index eccbad82bb..d1cf91ea56 100644 --- a/src/Core/Billing/Repositories/IProviderPlanRepository.cs +++ b/src/Core/Billing/Providers/Repositories/IProviderPlanRepository.cs @@ -1,7 +1,7 @@ -using Bit.Core.Billing.Entities; +using Bit.Core.Billing.Providers.Entities; using Bit.Core.Repositories; -namespace Bit.Core.Billing.Repositories; +namespace Bit.Core.Billing.Providers.Repositories; public interface IProviderPlanRepository : IRepository { diff --git a/src/Core/Billing/Services/IBusinessUnitConverter.cs b/src/Core/Billing/Providers/Services/IBusinessUnitConverter.cs similarity index 98% rename from src/Core/Billing/Services/IBusinessUnitConverter.cs rename to src/Core/Billing/Providers/Services/IBusinessUnitConverter.cs index 06ff883eae..99df6b1bef 100644 --- a/src/Core/Billing/Services/IBusinessUnitConverter.cs +++ b/src/Core/Billing/Providers/Services/IBusinessUnitConverter.cs @@ -3,7 +3,7 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using OneOf; -namespace Bit.Core.Billing.Services; +namespace Bit.Core.Billing.Providers.Services; public interface IBusinessUnitConverter { diff --git a/src/Core/Billing/Services/IProviderBillingService.cs b/src/Core/Billing/Providers/Services/IProviderBillingService.cs similarity index 97% rename from src/Core/Billing/Services/IProviderBillingService.cs rename to src/Core/Billing/Providers/Services/IProviderBillingService.cs index 0171a7e1c3..b634f1a81c 100644 --- a/src/Core/Billing/Services/IProviderBillingService.cs +++ b/src/Core/Billing/Providers/Services/IProviderBillingService.cs @@ -1,13 +1,14 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; -using Bit.Core.Billing.Entities; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Models; -using Bit.Core.Billing.Services.Contracts; +using Bit.Core.Billing.Providers.Entities; +using Bit.Core.Billing.Providers.Models; +using Bit.Core.Billing.Tax.Models; using Bit.Core.Models.Business; using Stripe; -namespace Bit.Core.Billing.Services; +namespace Bit.Core.Billing.Providers.Services; public interface IProviderBillingService { diff --git a/src/Core/Billing/Services/IOrganizationBillingService.cs b/src/Core/Billing/Services/IOrganizationBillingService.cs index db62d545e3..5f7d33f118 100644 --- a/src/Core/Billing/Services/IOrganizationBillingService.cs +++ b/src/Core/Billing/Services/IOrganizationBillingService.cs @@ -1,6 +1,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Models; using Bit.Core.Billing.Models.Sales; +using Bit.Core.Billing.Tax.Models; namespace Bit.Core.Billing.Services; diff --git a/src/Core/Billing/Services/IPremiumUserBillingService.cs b/src/Core/Billing/Services/IPremiumUserBillingService.cs index b3bb580e2d..ed7a003599 100644 --- a/src/Core/Billing/Services/IPremiumUserBillingService.cs +++ b/src/Core/Billing/Services/IPremiumUserBillingService.cs @@ -1,5 +1,6 @@ using Bit.Core.Billing.Models; using Bit.Core.Billing.Models.Sales; +using Bit.Core.Billing.Tax.Models; using Bit.Core.Entities; namespace Bit.Core.Billing.Services; diff --git a/src/Core/Billing/Services/ISubscriberService.cs b/src/Core/Billing/Services/ISubscriberService.cs index bb0a23020c..6910948436 100644 --- a/src/Core/Billing/Services/ISubscriberService.cs +++ b/src/Core/Billing/Services/ISubscriberService.cs @@ -1,4 +1,5 @@ using Bit.Core.Billing.Models; +using Bit.Core.Billing.Tax.Models; using Bit.Core.Entities; using Bit.Core.Enums; using Stripe; diff --git a/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs b/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs index 2e902ca028..c647e825b6 100644 --- a/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs +++ b/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs @@ -1,11 +1,13 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Caches; using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Models; using Bit.Core.Billing.Models.Sales; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Services.Contracts; +using Bit.Core.Billing.Tax.Models; +using Bit.Core.Billing.Tax.Services; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; @@ -29,20 +31,20 @@ public class OrganizationBillingService( IGlobalSettings globalSettings, ILogger logger, IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, IPricingClient pricingClient, ISetupIntentCache setupIntentCache, IStripeAdapter stripeAdapter, ISubscriberService subscriberService, - ITaxService taxService, - IAutomaticTaxFactory automaticTaxFactory) : IOrganizationBillingService + ITaxService taxService) : IOrganizationBillingService { public async Task Finalize(OrganizationSale sale) { var (organization, customerSetup, subscriptionSetup) = sale; var customer = string.IsNullOrEmpty(organization.GatewayCustomerId) && customerSetup != null - ? await CreateCustomerAsync(organization, customerSetup) - : await subscriberService.GetCustomerOrThrow(organization, new CustomerGetOptions { Expand = ["tax", "tax_ids"] }); + ? await CreateCustomerAsync(organization, customerSetup, subscriptionSetup.PlanType) + : await GetCustomerWhileEnsuringCorrectTaxExemptionAsync(organization, subscriptionSetup); var subscription = await CreateSubscriptionAsync(organization.Id, customer, subscriptionSetup); @@ -106,6 +108,8 @@ public class OrganizationBillingService( ? await stripeAdapter.InvoiceGetAsync(subscription.LatestInvoiceId, new InvoiceGetOptions()) : null; + var orgOccupiedSeats = await organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); + return new OrganizationMetadata( isEligibleForSelfHost, isManaged, @@ -116,10 +120,12 @@ public class OrganizationBillingService( subscription.Status == StripeConstants.SubscriptionStatus.Canceled, invoice?.DueDate, invoice?.Created, - subscription.CurrentPeriodEnd); + subscription.CurrentPeriodEnd, + orgOccupiedSeats); } - public async Task UpdatePaymentMethod( + public async Task + UpdatePaymentMethod( Organization organization, TokenizedPaymentSource tokenizedPaymentSource, TaxInformation taxInformation) @@ -149,8 +155,11 @@ public class OrganizationBillingService( private async Task CreateCustomerAsync( Organization organization, - CustomerSetup customerSetup) + CustomerSetup customerSetup, + PlanType? updatedPlanType = null) { + var planType = updatedPlanType ?? organization.PlanType; + var displayName = organization.DisplayName(); var customerCreateOptions = new CustomerCreateOptions @@ -210,13 +219,24 @@ public class OrganizationBillingService( City = customerSetup.TaxInformation.City, PostalCode = customerSetup.TaxInformation.PostalCode, State = customerSetup.TaxInformation.State, - Country = customerSetup.TaxInformation.Country, + Country = customerSetup.TaxInformation.Country }; + customerCreateOptions.Tax = new CustomerTaxOptions { ValidateLocation = StripeConstants.ValidateTaxLocationTiming.Immediately }; + var setNonUSBusinessUseToReverseCharge = + featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge); + + if (setNonUSBusinessUseToReverseCharge && + planType.GetProductTier() is not ProductTierType.Free and not ProductTierType.Families && + customerSetup.TaxInformation.Country != "US") + { + customerCreateOptions.TaxExempt = StripeConstants.TaxExempt.Reverse; + } + if (!string.IsNullOrEmpty(customerSetup.TaxInformation.TaxId)) { var taxIdType = taxService.GetStripeTaxCode(customerSetup.TaxInformation.Country, @@ -397,21 +417,68 @@ public class OrganizationBillingService( TrialPeriodDays = subscriptionSetup.SkipTrial ? 0 : plan.TrialPeriodDays }; - if (featureService.IsEnabled(FeatureFlagKeys.PM19147_AutomaticTaxImprovements)) + var setNonUSBusinessUseToReverseCharge = + featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge); + + if (setNonUSBusinessUseToReverseCharge) { - var automaticTaxParameters = new AutomaticTaxFactoryParameters(subscriptionSetup.PlanType); - var automaticTaxStrategy = await automaticTaxFactory.CreateAsync(automaticTaxParameters); - automaticTaxStrategy.SetCreateOptions(subscriptionCreateOptions, customer); + subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }; } - else + else if (customer.HasRecognizedTaxLocation()) { - subscriptionCreateOptions.AutomaticTax ??= new SubscriptionAutomaticTaxOptions(); - subscriptionCreateOptions.AutomaticTax.Enabled = customer.HasBillingLocation(); + subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions + { + Enabled = + subscriptionSetup.PlanType.GetProductTier() == ProductTierType.Families || + customer.Address.Country == "US" || + customer.TaxIds.Any() + }; } return await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions); } + private async Task GetCustomerWhileEnsuringCorrectTaxExemptionAsync( + Organization organization, + SubscriptionSetup subscriptionSetup) + { + var customer = await subscriberService.GetCustomerOrThrow(organization, + new CustomerGetOptions { Expand = ["tax", "tax_ids"] }); + + var setNonUSBusinessUseToReverseCharge = featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge); + + if (!setNonUSBusinessUseToReverseCharge || subscriptionSetup.PlanType.GetProductTier() is + not (ProductTierType.Teams or + ProductTierType.TeamsStarter or + ProductTierType.Enterprise)) + { + return customer; + } + + List expansions = ["tax", "tax_ids"]; + + customer = customer switch + { + { Address.Country: not "US", TaxExempt: not StripeConstants.TaxExempt.Reverse } => await + stripeAdapter.CustomerUpdateAsync(customer.Id, + new CustomerUpdateOptions + { + Expand = expansions, + TaxExempt = StripeConstants.TaxExempt.Reverse + }), + { Address.Country: "US", TaxExempt: StripeConstants.TaxExempt.Reverse } => await + stripeAdapter.CustomerUpdateAsync(customer.Id, + new CustomerUpdateOptions + { + Expand = expansions, + TaxExempt = StripeConstants.TaxExempt.None + }), + _ => customer + }; + + return customer; + } + private async Task IsEligibleForSelfHostAsync( Organization organization) { diff --git a/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs b/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs index cbd4dbbdff..7496157aaa 100644 --- a/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs +++ b/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs @@ -2,7 +2,7 @@ using Bit.Core.Billing.Constants; using Bit.Core.Billing.Models; using Bit.Core.Billing.Models.Sales; -using Bit.Core.Billing.Services.Implementations.AutomaticTax; +using Bit.Core.Billing.Tax.Models; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -10,7 +10,6 @@ using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; using Braintree; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Stripe; using Customer = Stripe.Customer; @@ -22,20 +21,18 @@ using static Utilities; public class PremiumUserBillingService( IBraintreeGateway braintreeGateway, - IFeatureService featureService, IGlobalSettings globalSettings, ILogger logger, ISetupIntentCache setupIntentCache, IStripeAdapter stripeAdapter, ISubscriberService subscriberService, - IUserRepository userRepository, - [FromKeyedServices(AutomaticTaxFactory.PersonalUse)] IAutomaticTaxStrategy automaticTaxStrategy) : IPremiumUserBillingService + IUserRepository userRepository) : IPremiumUserBillingService { public async Task Credit(User user, decimal amount) { var customer = await subscriberService.GetCustomer(user); - // Negative credit represents a balance and all Stripe denomination is in cents. + // Negative credit represents a balance, and all Stripe denomination is in cents. var credit = (long)(amount * -100); if (customer == null) @@ -182,7 +179,7 @@ public class PremiumUserBillingService( City = customerSetup.TaxInformation.City, PostalCode = customerSetup.TaxInformation.PostalCode, State = customerSetup.TaxInformation.State, - Country = customerSetup.TaxInformation.Country, + Country = customerSetup.TaxInformation.Country }, Description = user.Name, Email = user.Email, @@ -322,6 +319,10 @@ public class PremiumUserBillingService( var subscriptionCreateOptions = new SubscriptionCreateOptions { + AutomaticTax = new SubscriptionAutomaticTaxOptions + { + Enabled = true + }, CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically, Customer = customer.Id, Items = subscriptionItemOptionsList, @@ -335,18 +336,6 @@ public class PremiumUserBillingService( OffSession = true }; - if (featureService.IsEnabled(FeatureFlagKeys.PM19147_AutomaticTaxImprovements)) - { - automaticTaxStrategy.SetCreateOptions(subscriptionCreateOptions, customer); - } - else - { - subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions - { - Enabled = customer.Tax?.AutomaticTax == StripeConstants.AutomaticTaxStatus.Supported, - }; - } - var subscription = await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions); if (usingPayPal) @@ -378,7 +367,7 @@ public class PremiumUserBillingService( City = taxInformation.City, PostalCode = taxInformation.PostalCode, State = taxInformation.State, - Country = taxInformation.Country, + Country = taxInformation.Country }, Expand = ["tax"], Tax = new CustomerTaxOptions diff --git a/src/Core/Billing/Services/Implementations/SubscriberService.cs b/src/Core/Billing/Services/Implementations/SubscriberService.cs index 1b0e5b665b..75a1bf76ec 100644 --- a/src/Core/Billing/Services/Implementations/SubscriberService.cs +++ b/src/Core/Billing/Services/Implementations/SubscriberService.cs @@ -1,7 +1,12 @@ -using Bit.Core.Billing.Caches; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.Billing.Caches; using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Models; -using Bit.Core.Billing.Services.Contracts; +using Bit.Core.Billing.Tax.Models; +using Bit.Core.Billing.Tax.Services; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -26,8 +31,7 @@ public class SubscriberService( ILogger logger, ISetupIntentCache setupIntentCache, IStripeAdapter stripeAdapter, - ITaxService taxService, - IAutomaticTaxFactory automaticTaxFactory) : ISubscriberService + ITaxService taxService) : ISubscriberService { public async Task CancelSubscription( ISubscriber subscriber, @@ -126,7 +130,7 @@ public class SubscriberService( [subscriber.BraintreeCloudRegionField()] = globalSettings.BaseServiceUri.CloudRegion }, Email = subscriber.BillingEmailAddress(), - PaymentMethodNonce = paymentMethodNonce, + PaymentMethodNonce = paymentMethodNonce }); if (customerResult.IsSuccess()) @@ -480,7 +484,7 @@ public class SubscriberService( var matchingSetupIntent = setupIntentsForUpdatedPaymentMethod.First(); - // Find the customer's existing setup intents that should be cancelled. + // Find the customer's existing setup intents that should be canceled. var existingSetupIntentsForCustomer = (await getExistingSetupIntentsForCustomer) .Where(si => si.Status is "requires_payment_method" or "requires_confirmation" or "requires_action"); @@ -517,7 +521,7 @@ public class SubscriberService( await stripeAdapter.PaymentMethodAttachAsync(token, new PaymentMethodAttachOptions { Customer = subscriber.GatewayCustomerId }); - // Find the customer's existing setup intents that should be cancelled. + // Find the customer's existing setup intents that should be canceled. var existingSetupIntentsForCustomer = (await getExistingSetupIntentsForCustomer) .Where(si => si.Status is "requires_payment_method" or "requires_confirmation" or "requires_action"); @@ -635,7 +639,8 @@ public class SubscriberService( logger.LogWarning("Could not infer tax ID type in country '{Country}' with tax ID '{TaxID}'.", taxInformation.Country, taxInformation.TaxId); - throw new Exceptions.BadRequestException("billingTaxIdTypeInferenceError"); + + throw new BadRequestException("billingTaxIdTypeInferenceError"); } } @@ -652,53 +657,84 @@ public class SubscriberService( logger.LogWarning("Invalid tax ID '{TaxID}' for country '{Country}'.", taxInformation.TaxId, taxInformation.Country); - throw new Exceptions.BadRequestException("billingInvalidTaxIdError"); + + throw new BadRequestException("billingInvalidTaxIdError"); + default: logger.LogError(e, "Error creating tax ID '{TaxId}' in country '{Country}' for customer '{CustomerID}'.", taxInformation.TaxId, taxInformation.Country, customer.Id); - throw new Exceptions.BadRequestException("billingTaxIdCreationError"); + + throw new BadRequestException("billingTaxIdCreationError"); } } } - if (featureService.IsEnabled(FeatureFlagKeys.PM19147_AutomaticTaxImprovements)) + var subscription = + customer.Subscriptions.First(subscription => subscription.Id == subscriber.GatewaySubscriptionId); + + var isBusinessUseSubscriber = subscriber switch { - if (!string.IsNullOrEmpty(subscriber.GatewaySubscriptionId)) + Organization organization => organization.PlanType.GetProductTier() is not ProductTierType.Free and not ProductTierType.Families, + Provider => true, + _ => false + }; + + var setNonUSBusinessUseToReverseCharge = + featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge); + + if (setNonUSBusinessUseToReverseCharge && isBusinessUseSubscriber) + { + switch (customer) { - var subscriptionGetOptions = new SubscriptionGetOptions + case { - Expand = ["customer.tax", "customer.tax_ids"] - }; - var subscription = await stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId, subscriptionGetOptions); - var automaticTaxParameters = new AutomaticTaxFactoryParameters(subscriber, subscription.Items.Select(x => x.Price.Id)); - var automaticTaxStrategy = await automaticTaxFactory.CreateAsync(automaticTaxParameters); - var automaticTaxOptions = automaticTaxStrategy.GetUpdateOptions(subscription); - if (automaticTaxOptions?.AutomaticTax?.Enabled != null) + Address.Country: not "US", + TaxExempt: not StripeConstants.TaxExempt.Reverse + }: + await stripeAdapter.CustomerUpdateAsync(customer.Id, + new CustomerUpdateOptions { TaxExempt = StripeConstants.TaxExempt.Reverse }); + break; + case { - await stripeAdapter.SubscriptionUpdateAsync(subscriber.GatewaySubscriptionId, automaticTaxOptions); - } + Address.Country: "US", + TaxExempt: StripeConstants.TaxExempt.Reverse + }: + await stripeAdapter.CustomerUpdateAsync(customer.Id, + new CustomerUpdateOptions { TaxExempt = StripeConstants.TaxExempt.None }); + break; } - } - else - { - if (SubscriberIsEligibleForAutomaticTax(subscriber, customer)) + + if (!subscription.AutomaticTax.Enabled) { - await stripeAdapter.SubscriptionUpdateAsync(subscriber.GatewaySubscriptionId, + await stripeAdapter.SubscriptionUpdateAsync(subscription.Id, new SubscriptionUpdateOptions { AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true } }); } + } + else + { + var automaticTaxShouldBeEnabled = subscriber switch + { + User => true, + Organization organization => organization.PlanType.GetProductTier() == ProductTierType.Families || + customer.Address.Country == "US" || (customer.TaxIds?.Any() ?? false), + Provider => customer.Address.Country == "US" || (customer.TaxIds?.Any() ?? false), + _ => false + }; - return; - - bool SubscriberIsEligibleForAutomaticTax(ISubscriber localSubscriber, Customer localCustomer) - => !string.IsNullOrEmpty(localSubscriber.GatewaySubscriptionId) && - (localCustomer.Subscriptions?.Any(sub => sub.Id == localSubscriber.GatewaySubscriptionId && !sub.AutomaticTax.Enabled) ?? false) && - localCustomer.Tax?.AutomaticTax == StripeConstants.AutomaticTaxStatus.Supported; + if (automaticTaxShouldBeEnabled && !subscription.AutomaticTax.Enabled) + { + await stripeAdapter.SubscriptionUpdateAsync(subscription.Id, + new SubscriptionUpdateOptions + { + AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true } + }); + } } } diff --git a/src/Core/Billing/Tax/Commands/PreviewTaxAmountCommand.cs b/src/Core/Billing/Tax/Commands/PreviewTaxAmountCommand.cs new file mode 100644 index 0000000000..304abbaae0 --- /dev/null +++ b/src/Core/Billing/Tax/Commands/PreviewTaxAmountCommand.cs @@ -0,0 +1,147 @@ +#nullable enable +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Extensions; +using Bit.Core.Billing.Models; +using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Tax.Services; +using Bit.Core.Services; +using Microsoft.Extensions.Logging; +using Stripe; + +namespace Bit.Core.Billing.Tax.Commands; + +public interface IPreviewTaxAmountCommand +{ + Task> Run(OrganizationTrialParameters parameters); +} + +public class PreviewTaxAmountCommand( + ILogger logger, + IPricingClient pricingClient, + IStripeAdapter stripeAdapter, + ITaxService taxService) : IPreviewTaxAmountCommand +{ + public async Task> Run(OrganizationTrialParameters parameters) + { + var (planType, productType, taxInformation) = parameters; + + var plan = await pricingClient.GetPlanOrThrow(planType); + + var options = new InvoiceCreatePreviewOptions + { + Currency = "usd", + CustomerDetails = new InvoiceCustomerDetailsOptions + { + Address = new AddressOptions + { + Country = taxInformation.Country, + PostalCode = taxInformation.PostalCode + } + }, + SubscriptionDetails = new InvoiceSubscriptionDetailsOptions + { + Items = [ + new InvoiceSubscriptionDetailsItemOptions + { + Price = plan.HasNonSeatBasedPasswordManagerPlan() ? plan.PasswordManager.StripePlanId : plan.PasswordManager.StripeSeatPlanId, + Quantity = 1 + } + ] + } + }; + + if (productType == ProductType.SecretsManager) + { + options.SubscriptionDetails.Items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = plan.SecretsManager.StripeSeatPlanId, + Quantity = 1 + }); + + options.Coupon = StripeConstants.CouponIDs.SecretsManagerStandalone; + } + + if (!string.IsNullOrEmpty(taxInformation.TaxId)) + { + var taxIdType = taxService.GetStripeTaxCode( + taxInformation.Country, + taxInformation.TaxId); + + if (string.IsNullOrEmpty(taxIdType)) + { + return BadRequest.UnknownTaxIdType; + } + + options.CustomerDetails.TaxIds = [ + new InvoiceCustomerDetailsTaxIdOptions + { + Type = taxIdType, + Value = taxInformation.TaxId + } + ]; + } + + if (planType.GetProductTier() == ProductTierType.Families) + { + options.AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true }; + } + else + { + options.AutomaticTax = new InvoiceAutomaticTaxOptions + { + Enabled = options.CustomerDetails.Address.Country == "US" || + options.CustomerDetails.TaxIds is [_, ..] + }; + } + + try + { + var invoice = await stripeAdapter.InvoiceCreatePreviewAsync(options); + return Convert.ToDecimal(invoice.Tax) / 100; + } + catch (StripeException stripeException) when (stripeException.StripeError.Code == + StripeConstants.ErrorCodes.CustomerTaxLocationInvalid) + { + return BadRequest.TaxLocationInvalid; + } + catch (StripeException stripeException) when (stripeException.StripeError.Code == + StripeConstants.ErrorCodes.TaxIdInvalid) + { + return BadRequest.TaxIdNumberInvalid; + } + catch (StripeException stripeException) + { + logger.LogError(stripeException, "Stripe responded with an error during {Operation}. Code: {Code}", nameof(PreviewTaxAmountCommand), stripeException.StripeError.Code); + return new Unhandled(); + } + } +} + +#region Command Parameters + +public record OrganizationTrialParameters +{ + public required PlanType PlanType { get; set; } + public required ProductType ProductType { get; set; } + public required TaxInformationDTO TaxInformation { get; set; } + + public void Deconstruct( + out PlanType planType, + out ProductType productType, + out TaxInformationDTO taxInformation) + { + planType = PlanType; + productType = ProductType; + taxInformation = TaxInformation; + } + + public record TaxInformationDTO + { + public required string Country { get; set; } + public required string PostalCode { get; set; } + public string? TaxId { get; set; } + } +} + +#endregion diff --git a/src/Core/Billing/Services/Contracts/AutomaticTaxFactoryParameters.cs b/src/Core/Billing/Tax/Models/AutomaticTaxFactoryParameters.cs similarity index 93% rename from src/Core/Billing/Services/Contracts/AutomaticTaxFactoryParameters.cs rename to src/Core/Billing/Tax/Models/AutomaticTaxFactoryParameters.cs index 19a4f0bdfa..a58daa9c48 100644 --- a/src/Core/Billing/Services/Contracts/AutomaticTaxFactoryParameters.cs +++ b/src/Core/Billing/Tax/Models/AutomaticTaxFactoryParameters.cs @@ -2,7 +2,7 @@ using Bit.Core.Billing.Enums; using Bit.Core.Entities; -namespace Bit.Core.Billing.Services.Contracts; +namespace Bit.Core.Billing.Tax.Models; public class AutomaticTaxFactoryParameters { diff --git a/src/Core/Billing/Models/TaxIdType.cs b/src/Core/Billing/Tax/Models/TaxIdType.cs similarity index 92% rename from src/Core/Billing/Models/TaxIdType.cs rename to src/Core/Billing/Tax/Models/TaxIdType.cs index 3fc246d68b..6f8cfdde99 100644 --- a/src/Core/Billing/Models/TaxIdType.cs +++ b/src/Core/Billing/Tax/Models/TaxIdType.cs @@ -1,6 +1,6 @@ using System.Text.RegularExpressions; -namespace Bit.Core.Billing.Models; +namespace Bit.Core.Billing.Tax.Models; public class TaxIdType { diff --git a/src/Core/Billing/Models/TaxInformation.cs b/src/Core/Billing/Tax/Models/TaxInformation.cs similarity index 93% rename from src/Core/Billing/Models/TaxInformation.cs rename to src/Core/Billing/Tax/Models/TaxInformation.cs index 23ed3e5faa..2408ee0ecd 100644 --- a/src/Core/Billing/Models/TaxInformation.cs +++ b/src/Core/Billing/Tax/Models/TaxInformation.cs @@ -1,6 +1,6 @@ using Bit.Core.Models.Business; -namespace Bit.Core.Billing.Models; +namespace Bit.Core.Billing.Tax.Models; public record TaxInformation( string Country, diff --git a/src/Core/Billing/Models/Api/Requests/Accounts/PreviewIndividualInvoiceRequestModel.cs b/src/Core/Billing/Tax/Requests/PreviewIndividualInvoiceRequestModel.cs similarity index 87% rename from src/Core/Billing/Models/Api/Requests/Accounts/PreviewIndividualInvoiceRequestModel.cs rename to src/Core/Billing/Tax/Requests/PreviewIndividualInvoiceRequestModel.cs index 8597cea09b..340f07b56c 100644 --- a/src/Core/Billing/Models/Api/Requests/Accounts/PreviewIndividualInvoiceRequestModel.cs +++ b/src/Core/Billing/Tax/Requests/PreviewIndividualInvoiceRequestModel.cs @@ -1,6 +1,6 @@ using System.ComponentModel.DataAnnotations; -namespace Bit.Core.Billing.Models.Api.Requests.Accounts; +namespace Bit.Core.Billing.Tax.Requests; public class PreviewIndividualInvoiceRequestBody { diff --git a/src/Core/Billing/Models/Api/Requests/Organizations/PreviewOrganizationInvoiceRequestModel.cs b/src/Core/Billing/Tax/Requests/PreviewOrganizationInvoiceRequestModel.cs similarity index 93% rename from src/Core/Billing/Models/Api/Requests/Organizations/PreviewOrganizationInvoiceRequestModel.cs rename to src/Core/Billing/Tax/Requests/PreviewOrganizationInvoiceRequestModel.cs index 461a6dca65..bfb47e7b2c 100644 --- a/src/Core/Billing/Models/Api/Requests/Organizations/PreviewOrganizationInvoiceRequestModel.cs +++ b/src/Core/Billing/Tax/Requests/PreviewOrganizationInvoiceRequestModel.cs @@ -2,7 +2,7 @@ using Bit.Core.Billing.Enums; using Bit.Core.Enums; -namespace Bit.Core.Billing.Models.Api.Requests.Organizations; +namespace Bit.Core.Billing.Tax.Requests; public class PreviewOrganizationInvoiceRequestBody { diff --git a/src/Core/Billing/Models/Api/Requests/TaxInformationRequestModel.cs b/src/Core/Billing/Tax/Requests/TaxInformationRequestModel.cs similarity index 84% rename from src/Core/Billing/Models/Api/Requests/TaxInformationRequestModel.cs rename to src/Core/Billing/Tax/Requests/TaxInformationRequestModel.cs index 9cb43645c6..13d4870ac5 100644 --- a/src/Core/Billing/Models/Api/Requests/TaxInformationRequestModel.cs +++ b/src/Core/Billing/Tax/Requests/TaxInformationRequestModel.cs @@ -1,6 +1,6 @@ using System.ComponentModel.DataAnnotations; -namespace Bit.Core.Billing.Models.Api.Requests; +namespace Bit.Core.Billing.Tax.Requests; public class TaxInformationRequestModel { diff --git a/src/Core/Billing/Models/Api/Responses/PreviewInvoiceResponseModel.cs b/src/Core/Billing/Tax/Responses/PreviewInvoiceResponseModel.cs similarity index 74% rename from src/Core/Billing/Models/Api/Responses/PreviewInvoiceResponseModel.cs rename to src/Core/Billing/Tax/Responses/PreviewInvoiceResponseModel.cs index fdde7dae1e..2753487e2f 100644 --- a/src/Core/Billing/Models/Api/Responses/PreviewInvoiceResponseModel.cs +++ b/src/Core/Billing/Tax/Responses/PreviewInvoiceResponseModel.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.Billing.Models.Api.Responses; +namespace Bit.Core.Billing.Tax.Responses; public record PreviewInvoiceResponseModel( decimal EffectiveTaxRate, diff --git a/src/Core/Billing/Services/IAutomaticTaxFactory.cs b/src/Core/Billing/Tax/Services/IAutomaticTaxFactory.cs similarity index 76% rename from src/Core/Billing/Services/IAutomaticTaxFactory.cs rename to src/Core/Billing/Tax/Services/IAutomaticTaxFactory.cs index c52a8f2671..c0a31efb3c 100644 --- a/src/Core/Billing/Services/IAutomaticTaxFactory.cs +++ b/src/Core/Billing/Tax/Services/IAutomaticTaxFactory.cs @@ -1,6 +1,6 @@ -using Bit.Core.Billing.Services.Contracts; +using Bit.Core.Billing.Tax.Models; -namespace Bit.Core.Billing.Services; +namespace Bit.Core.Billing.Tax.Services; /// /// Responsible for defining the correct automatic tax strategy for either personal use of business use. diff --git a/src/Core/Billing/Services/IAutomaticTaxStrategy.cs b/src/Core/Billing/Tax/Services/IAutomaticTaxStrategy.cs similarity index 96% rename from src/Core/Billing/Services/IAutomaticTaxStrategy.cs rename to src/Core/Billing/Tax/Services/IAutomaticTaxStrategy.cs index 292f2d0939..557bb1d30c 100644 --- a/src/Core/Billing/Services/IAutomaticTaxStrategy.cs +++ b/src/Core/Billing/Tax/Services/IAutomaticTaxStrategy.cs @@ -1,7 +1,7 @@ #nullable enable using Stripe; -namespace Bit.Core.Billing.Services; +namespace Bit.Core.Billing.Tax.Services; public interface IAutomaticTaxStrategy { diff --git a/src/Core/Billing/Services/ITaxService.cs b/src/Core/Billing/Tax/Services/ITaxService.cs similarity index 94% rename from src/Core/Billing/Services/ITaxService.cs rename to src/Core/Billing/Tax/Services/ITaxService.cs index beee113d17..00cbf56a9b 100644 --- a/src/Core/Billing/Services/ITaxService.cs +++ b/src/Core/Billing/Tax/Services/ITaxService.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.Billing.Services; +namespace Bit.Core.Billing.Tax.Services; public interface ITaxService { diff --git a/src/Core/Billing/Services/Implementations/AutomaticTax/AutomaticTaxFactory.cs b/src/Core/Billing/Tax/Services/Implementations/AutomaticTaxFactory.cs similarity index 93% rename from src/Core/Billing/Services/Implementations/AutomaticTax/AutomaticTaxFactory.cs rename to src/Core/Billing/Tax/Services/Implementations/AutomaticTaxFactory.cs index 133cd2c7a7..6086a16b79 100644 --- a/src/Core/Billing/Services/Implementations/AutomaticTax/AutomaticTaxFactory.cs +++ b/src/Core/Billing/Tax/Services/Implementations/AutomaticTaxFactory.cs @@ -1,11 +1,11 @@ #nullable enable using Bit.Core.Billing.Enums; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Services.Contracts; +using Bit.Core.Billing.Tax.Models; using Bit.Core.Entities; using Bit.Core.Services; -namespace Bit.Core.Billing.Services.Implementations.AutomaticTax; +namespace Bit.Core.Billing.Tax.Services.Implementations; public class AutomaticTaxFactory( IFeatureService featureService, diff --git a/src/Core/Billing/Services/Implementations/AutomaticTax/BusinessUseAutomaticTaxStrategy.cs b/src/Core/Billing/Tax/Services/Implementations/BusinessUseAutomaticTaxStrategy.cs similarity index 95% rename from src/Core/Billing/Services/Implementations/AutomaticTax/BusinessUseAutomaticTaxStrategy.cs rename to src/Core/Billing/Tax/Services/Implementations/BusinessUseAutomaticTaxStrategy.cs index 40eb6e4540..6affc57354 100644 --- a/src/Core/Billing/Services/Implementations/AutomaticTax/BusinessUseAutomaticTaxStrategy.cs +++ b/src/Core/Billing/Tax/Services/Implementations/BusinessUseAutomaticTaxStrategy.cs @@ -3,7 +3,7 @@ using Bit.Core.Billing.Extensions; using Bit.Core.Services; using Stripe; -namespace Bit.Core.Billing.Services.Implementations.AutomaticTax; +namespace Bit.Core.Billing.Tax.Services.Implementations; public class BusinessUseAutomaticTaxStrategy(IFeatureService featureService) : IAutomaticTaxStrategy { @@ -76,7 +76,7 @@ public class BusinessUseAutomaticTaxStrategy(IFeatureService featureService) : I private bool ShouldBeEnabled(Customer customer) { - if (!customer.HasTaxLocationVerified()) + if (!customer.HasRecognizedTaxLocation()) { return false; } diff --git a/src/Core/Billing/Services/Implementations/AutomaticTax/PersonalUseAutomaticTaxStrategy.cs b/src/Core/Billing/Tax/Services/Implementations/PersonalUseAutomaticTaxStrategy.cs similarity index 93% rename from src/Core/Billing/Services/Implementations/AutomaticTax/PersonalUseAutomaticTaxStrategy.cs rename to src/Core/Billing/Tax/Services/Implementations/PersonalUseAutomaticTaxStrategy.cs index 15ee1adf8f..615222259e 100644 --- a/src/Core/Billing/Services/Implementations/AutomaticTax/PersonalUseAutomaticTaxStrategy.cs +++ b/src/Core/Billing/Tax/Services/Implementations/PersonalUseAutomaticTaxStrategy.cs @@ -3,7 +3,7 @@ using Bit.Core.Billing.Extensions; using Bit.Core.Services; using Stripe; -namespace Bit.Core.Billing.Services.Implementations.AutomaticTax; +namespace Bit.Core.Billing.Tax.Services.Implementations; public class PersonalUseAutomaticTaxStrategy(IFeatureService featureService) : IAutomaticTaxStrategy { @@ -59,6 +59,6 @@ public class PersonalUseAutomaticTaxStrategy(IFeatureService featureService) : I private static bool ShouldBeEnabled(Customer customer) { - return customer.HasTaxLocationVerified(); + return customer.HasRecognizedTaxLocation(); } } diff --git a/src/Core/Billing/Services/TaxService.cs b/src/Core/Billing/Tax/Services/Implementations/TaxService.cs similarity index 99% rename from src/Core/Billing/Services/TaxService.cs rename to src/Core/Billing/Tax/Services/Implementations/TaxService.cs index 3066be92d1..204c997335 100644 --- a/src/Core/Billing/Services/TaxService.cs +++ b/src/Core/Billing/Tax/Services/Implementations/TaxService.cs @@ -1,7 +1,7 @@ using System.Text.RegularExpressions; -using Bit.Core.Billing.Models; +using Bit.Core.Billing.Tax.Models; -namespace Bit.Core.Billing.Services; +namespace Bit.Core.Billing.Tax.Services.Implementations; public class TaxService : ITaxService { diff --git a/src/Core/Billing/Utilities.cs b/src/Core/Billing/Utilities.cs index 695a3b1bb4..ebb7b0e525 100644 --- a/src/Core/Billing/Utilities.cs +++ b/src/Core/Billing/Utilities.cs @@ -1,4 +1,5 @@ using Bit.Core.Billing.Models; +using Bit.Core.Billing.Tax.Models; using Bit.Core.Services; using Stripe; diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 3399a729d1..e6a822452a 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -23,6 +23,7 @@ public static class Constants public const string Fido2KeyCipherMinimumVersion = "2023.10.0"; public const string SSHKeyCipherMinimumVersion = "2024.12.0"; + public const string DenyLegacyUserMinimumVersion = "2025.6.0"; /// /// Used by IdentityServer to identify our own provider. @@ -109,12 +110,15 @@ public static class FeatureFlagKeys public const string SsoExternalIdVisibility = "pm-18630-sso-external-id-visibility"; public const string ScimInviteUserOptimization = "pm-16811-optimize-invite-user-flow-to-fail-fast"; public const string EventBasedOrganizationIntegrations = "event-based-organization-integrations"; + public const string OptimizeNestedTraverseTypescript = "pm-21695-optimize-nested-traverse-typescript"; + public const string SeparateCustomRolePermissions = "pm-19917-separate-custom-role-permissions"; /* Auth Team */ public const string PM9112DeviceApprovalPersistence = "pm-9112-device-approval-persistence"; public const string TwoFactorExtensionDataPersistence = "pm-9115-two-factor-extension-data-persistence"; public const string EmailVerification = "email-verification"; public const string UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh"; + public const string BrowserExtensionLoginApproval = "pm-14938-browser-extension-login-approvals"; public const string SetInitialPasswordRefactor = "pm-16117-set-initial-password-refactor"; public const string ChangeExistingPasswordRefactor = "pm-16117-change-existing-password-refactor"; public const string RecoveryCodeLogin = "pm-17128-recovery-code-login"; @@ -141,14 +145,13 @@ public static class FeatureFlagKeys public const string PM17772_AdminInitiatedSponsorships = "pm-17772-admin-initiated-sponsorships"; public const string UsePricingService = "use-pricing-service"; public const string PM12276Breadcrumbing = "pm-12276-breadcrumbing-for-business-features"; - public const string PM18794_ProviderPaymentMethod = "pm-18794-provider-payment-method"; - public const string PM19147_AutomaticTaxImprovements = "pm-19147-automatic-tax-improvements"; public const string PM19422_AllowAutomaticTaxUpdates = "pm-19422-allow-automatic-tax-updates"; - public const string PM18770_EnableOrganizationBusinessUnitConversion = "pm-18770-enable-organization-business-unit-conversion"; public const string PM199566_UpdateMSPToChargeAutomatically = "pm-199566-update-msp-to-charge-automatically"; public const string PM19956_RequireProviderPaymentMethodDuringSetup = "pm-19956-require-provider-payment-method-during-setup"; public const string UseOrganizationWarningsService = "use-organization-warnings-service"; public const string PM20322_AllowTrialLength0 = "pm-20322-allow-trial-length-0"; + public const string PM21092_SetNonUSBusinessUseToReverseCharge = "pm-21092-set-non-us-business-use-to-reverse-charge"; + public const string PM21383_GetProviderPriceFromStripe = "pm-21383-get-provider-price-from-stripe"; /* Data Insights and Reporting Team */ public const string RiskInsightsCriticalApplication = "pm-14466-risk-insights-critical-application"; @@ -169,8 +172,6 @@ public static class FeatureFlagKeys public const string NativeCreateAccountFlow = "native-create-account-flow"; public const string AndroidImportLoginsFlow = "import-logins-flow"; public const string AppReviewPrompt = "app-review-prompt"; - public const string EnablePasswordManagerSyncAndroid = "enable-password-manager-sync-android"; - public const string EnablePasswordManagerSynciOS = "enable-password-manager-sync-ios"; public const string AndroidMutualTls = "mutual-tls"; public const string SingleTapPasskeyCreation = "single-tap-passkey-creation"; public const string SingleTapPasskeyAuthentication = "single-tap-passkey-authentication"; @@ -195,14 +196,13 @@ public static class FeatureFlagKeys /* Vault Team */ public const string PM8851_BrowserOnboardingNudge = "pm-8851-browser-onboarding-nudge"; public const string PM9111ExtensionPersistAddEditForm = "pm-9111-extension-persist-add-edit-form"; - public const string RestrictProviderAccess = "restrict-provider-access"; public const string SecurityTasks = "security-tasks"; public const string CipherKeyEncryption = "cipher-key-encryption"; public const string DesktopCipherForms = "pm-18520-desktop-cipher-forms"; public const string PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk"; public const string EndUserNotifications = "pm-10609-end-user-notifications"; - public const string SeparateCustomRolePermissions = "pm-19917-separate-custom-role-permissions"; public const string PhishingDetection = "phishing-detection"; + public const string RemoveCardItemTypePolicy = "pm-16442-remove-card-item-type-policy"; public static List GetAllKeys() { diff --git a/src/Core/Context/CurrentContext.cs b/src/Core/Context/CurrentContext.cs index cbd90055b0..68d4606907 100644 --- a/src/Core/Context/CurrentContext.cs +++ b/src/Core/Context/CurrentContext.cs @@ -64,39 +64,39 @@ public class CurrentContext : ICurrentContext HttpContext = httpContext; await BuildAsync(httpContext.User, globalSettings); - if (DeviceIdentifier == null && httpContext.Request.Headers.ContainsKey("Device-Identifier")) + if (DeviceIdentifier == null && httpContext.Request.Headers.TryGetValue("Device-Identifier", out var deviceIdentifier)) { - DeviceIdentifier = httpContext.Request.Headers["Device-Identifier"]; + DeviceIdentifier = deviceIdentifier; } - if (httpContext.Request.Headers.ContainsKey("Device-Type") && - Enum.TryParse(httpContext.Request.Headers["Device-Type"].ToString(), out DeviceType dType)) + if (httpContext.Request.Headers.TryGetValue("Device-Type", out var deviceType) && + Enum.TryParse(deviceType.ToString(), out DeviceType dType)) { DeviceType = dType; } - if (!BotScore.HasValue && httpContext.Request.Headers.ContainsKey("X-Cf-Bot-Score") && - int.TryParse(httpContext.Request.Headers["X-Cf-Bot-Score"], out var parsedBotScore)) + 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.ContainsKey("X-Cf-Worked-Proxied")) + if (httpContext.Request.Headers.TryGetValue("X-Cf-Worked-Proxied", out var cfWorkedProxied)) { - CloudflareWorkerProxied = httpContext.Request.Headers["X-Cf-Worked-Proxied"] == "1"; + CloudflareWorkerProxied = cfWorkedProxied == "1"; } - if (httpContext.Request.Headers.ContainsKey("X-Cf-Is-Bot")) + if (httpContext.Request.Headers.TryGetValue("X-Cf-Is-Bot", out var cfIsBot)) { - IsBot = httpContext.Request.Headers["X-Cf-Is-Bot"] == "1"; + IsBot = cfIsBot == "1"; } - if (httpContext.Request.Headers.ContainsKey("X-Cf-Maybe-Bot")) + if (httpContext.Request.Headers.TryGetValue("X-Cf-Maybe-Bot", out var cfMaybeBot)) { - MaybeBot = httpContext.Request.Headers["X-Cf-Maybe-Bot"] == "1"; + MaybeBot = cfMaybeBot == "1"; } - if (httpContext.Request.Headers.ContainsKey("Bitwarden-Client-Version") && Version.TryParse(httpContext.Request.Headers["Bitwarden-Client-Version"], out var cVersion)) + if (httpContext.Request.Headers.TryGetValue("Bitwarden-Client-Version", out var bitWardenClientVersion) && Version.TryParse(bitWardenClientVersion, out var cVersion)) { ClientVersion = cVersion; } @@ -190,14 +190,14 @@ public class CurrentContext : ICurrentContext private List GetOrganizations(Dictionary> claimsDict, bool orgApi) { - var accessSecretsManager = claimsDict.ContainsKey(Claims.SecretsManagerAccess) - ? claimsDict[Claims.SecretsManagerAccess].ToDictionary(s => s.Value, _ => true) + var accessSecretsManager = claimsDict.TryGetValue(Claims.SecretsManagerAccess, out var secretsManagerAccessClaim) + ? secretsManagerAccessClaim.ToDictionary(s => s.Value, _ => true) : new Dictionary(); var organizations = new List(); - if (claimsDict.ContainsKey(Claims.OrganizationOwner)) + if (claimsDict.TryGetValue(Claims.OrganizationOwner, out var organizationOwnerClaim)) { - organizations.AddRange(claimsDict[Claims.OrganizationOwner].Select(c => + organizations.AddRange(organizationOwnerClaim.Select(c => new CurrentContextOrganization { Id = new Guid(c.Value), @@ -214,9 +214,9 @@ public class CurrentContext : ICurrentContext }); } - if (claimsDict.ContainsKey(Claims.OrganizationAdmin)) + if (claimsDict.TryGetValue(Claims.OrganizationAdmin, out var organizationAdminClaim)) { - organizations.AddRange(claimsDict[Claims.OrganizationAdmin].Select(c => + organizations.AddRange(organizationAdminClaim.Select(c => new CurrentContextOrganization { Id = new Guid(c.Value), @@ -225,9 +225,9 @@ public class CurrentContext : ICurrentContext })); } - if (claimsDict.ContainsKey(Claims.OrganizationUser)) + if (claimsDict.TryGetValue(Claims.OrganizationUser, out var organizationUserClaim)) { - organizations.AddRange(claimsDict[Claims.OrganizationUser].Select(c => + organizations.AddRange(organizationUserClaim.Select(c => new CurrentContextOrganization { Id = new Guid(c.Value), @@ -236,9 +236,9 @@ public class CurrentContext : ICurrentContext })); } - if (claimsDict.ContainsKey(Claims.OrganizationCustom)) + if (claimsDict.TryGetValue(Claims.OrganizationCustom, out var organizationCustomClaim)) { - organizations.AddRange(claimsDict[Claims.OrganizationCustom].Select(c => + organizations.AddRange(organizationCustomClaim.Select(c => new CurrentContextOrganization { Id = new Guid(c.Value), @@ -254,9 +254,9 @@ public class CurrentContext : ICurrentContext private List GetProviders(Dictionary> claimsDict) { var providers = new List(); - if (claimsDict.ContainsKey(Claims.ProviderAdmin)) + if (claimsDict.TryGetValue(Claims.ProviderAdmin, out var providerAdminClaim)) { - providers.AddRange(claimsDict[Claims.ProviderAdmin].Select(c => + providers.AddRange(providerAdminClaim.Select(c => new CurrentContextProvider { Id = new Guid(c.Value), @@ -264,9 +264,9 @@ public class CurrentContext : ICurrentContext })); } - if (claimsDict.ContainsKey(Claims.ProviderServiceUser)) + if (claimsDict.TryGetValue(Claims.ProviderServiceUser, out var providerServiceUserClaim)) { - providers.AddRange(claimsDict[Claims.ProviderServiceUser].Select(c => + providers.AddRange(providerServiceUserClaim.Select(c => new CurrentContextProvider { Id = new Guid(c.Value), @@ -504,20 +504,20 @@ public class CurrentContext : ICurrentContext private string GetClaimValue(Dictionary> claims, string type) { - if (!claims.ContainsKey(type)) + if (!claims.TryGetValue(type, out var claim)) { return null; } - return claims[type].FirstOrDefault()?.Value; + return claim.FirstOrDefault()?.Value; } private Permissions SetOrganizationPermissionsFromClaims(string organizationId, Dictionary> claimsDict) { bool hasClaim(string claimKey) { - return claimsDict.ContainsKey(claimKey) ? - claimsDict[claimKey].Any(x => x.Value == organizationId) : false; + return claimsDict.TryGetValue(claimKey, out var claim) ? + claim.Any(x => x.Value == organizationId) : false; } return new Permissions diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index 6397e0b8ea..88ecaf8cef 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -34,9 +34,9 @@ - + - + @@ -60,10 +60,10 @@ - - - - + + + + diff --git a/src/Core/Entities/User.cs b/src/Core/Entities/User.cs index b3a6a9592e..b92d22b0e3 100644 --- a/src/Core/Entities/User.cs +++ b/src/Core/Entities/User.cs @@ -3,7 +3,6 @@ using System.Text.Json; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; using Bit.Core.Enums; -using Bit.Core.Tools.Entities; using Bit.Core.Utilities; using Microsoft.AspNetCore.Identity; @@ -11,7 +10,7 @@ using Microsoft.AspNetCore.Identity; namespace Bit.Core.Entities; -public class User : ITableObject, IStorableSubscriber, IRevisable, ITwoFactorProvidersUser, IReferenceable +public class User : ITableObject, IStorableSubscriber, IRevisable, ITwoFactorProvidersUser { private Dictionary? _twoFactorProviders; @@ -36,6 +35,11 @@ public class User : ITableObject, IStorableSubscriber, IRevisable, ITwoFac public string? TwoFactorRecoveryCode { get; set; } public string? EquivalentDomains { get; set; } public string? ExcludedGlobalEquivalentDomains { get; set; } + /// + /// The Account Revision Date is used to check if new sync needs to occur. It should be updated + /// whenever a change is made that affects a client's sync data; for example, updating their vault or + /// organization membership. + /// public DateTime AccountRevisionDate { get; set; } = DateTime.UtcNow; public string? Key { get; set; } public string? PublicKey { get; set; } @@ -191,12 +195,7 @@ public class User : ITableObject, IStorableSubscriber, IRevisable, ITwoFac public TwoFactorProvider? GetTwoFactorProvider(TwoFactorProviderType provider) { var providers = GetTwoFactorProviders(); - if (providers == null || !providers.TryGetValue(provider, out var value)) - { - return null; - } - - return value; + return providers?.GetValueOrDefault(provider); } public long StorageBytesRemaining() diff --git a/src/Core/IdentityServer/DistributedCacheCookieManager.cs b/src/Core/IdentityServer/DistributedCacheCookieManager.cs index 9771b40662..5d6717ac41 100644 --- a/src/Core/IdentityServer/DistributedCacheCookieManager.cs +++ b/src/Core/IdentityServer/DistributedCacheCookieManager.cs @@ -63,6 +63,6 @@ public class DistributedCacheCookieManager : ICookieManager private string GetKey(string key, string id) => $"{CacheKeyPrefix}-{key}-{id}"; private string GetId(HttpContext context, string key) => - context.Request.Cookies.ContainsKey(key) ? - context.Request.Cookies[key] : null; + context.Request.Cookies.TryGetValue(key, out var cookie) ? + cookie : null; } diff --git a/src/Core/MailTemplates/Handlebars/Layouts/Full.html.hbs b/src/Core/MailTemplates/Handlebars/Layouts/Full.html.hbs index 7ed9fb7d1a..bcf6be62c9 100644 --- a/src/Core/MailTemplates/Handlebars/Layouts/Full.html.hbs +++ b/src/Core/MailTemplates/Handlebars/Layouts/Full.html.hbs @@ -148,7 +148,7 @@ - + diff --git a/src/Core/MailTemplates/Handlebars/Layouts/Full.text.hbs b/src/Core/MailTemplates/Handlebars/Layouts/Full.text.hbs index bf4ec50796..72f669bf34 100644 --- a/src/Core/MailTemplates/Handlebars/Layouts/Full.text.hbs +++ b/src/Core/MailTemplates/Handlebars/Layouts/Full.text.hbs @@ -2,7 +2,7 @@ ---------------------------- -- Twitter: https://twitter.com/bitwarden +- X: https://x.com/bitwarden - Reddit: https://www.reddit.com/r/Bitwarden/ - Community Forums: https://community.bitwarden.com/ - GitHub: https://github.com/bitwarden diff --git a/src/Core/MailTemplates/Handlebars/Layouts/FullUpdated.html.hbs b/src/Core/MailTemplates/Handlebars/Layouts/FullUpdated.html.hbs index f5772d61f6..f79e5f7043 100644 --- a/src/Core/MailTemplates/Handlebars/Layouts/FullUpdated.html.hbs +++ b/src/Core/MailTemplates/Handlebars/Layouts/FullUpdated.html.hbs @@ -177,7 +177,7 @@
    TwitterX Reddit CommunityForums GitHub