diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 26a347781..1d092d8b4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,18 +7,27 @@ on: - "main" - "rc" - "hotfix-rc" - pull_request: + pull_request_target: + types: [opened, synchronize] env: _AZ_REGISTRY: "bitwardenprod.azurecr.io" jobs: + check-run: + name: Check PR run + uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main + lint: name: Lint runs-on: ubuntu-22.04 + needs: + - check-run steps: - 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@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0 @@ -68,6 +77,8 @@ jobs: steps: - 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@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0 @@ -115,24 +126,6 @@ jobs: path: ${{ matrix.base_path }}/${{ matrix.project_name }}/${{ matrix.project_name }}.zip if-no-files-found: error - check-akv-secrets: - name: Check for AKV secrets - runs-on: ubuntu-22.04 - outputs: - available: ${{ steps.check-akv-secrets.outputs.available }} - permissions: - contents: read - - steps: - - name: Check - id: check-akv-secrets - run: | - if [ "${{ secrets.AZURE_PROD_KV_CREDENTIALS }}" != '' ]; then - echo "available=true" >> $GITHUB_OUTPUT; - else - echo "available=false" >> $GITHUB_OUTPUT; - fi - build-docker: name: Build Docker images runs-on: ubuntu-22.04 @@ -140,8 +133,6 @@ jobs: security-events: write needs: - build-artifacts - - check-akv-secrets - if: ${{ needs.check-akv-secrets.outputs.available == 'true' }} strategy: fail-fast: false matrix: @@ -194,6 +185,8 @@ jobs: steps: - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.pull_request.head.sha }} - name: Check branch to publish env: @@ -233,7 +226,7 @@ jobs: - name: Generate Docker image tag id: tag run: | - if [[ $(grep "pull" <<< "${GITHUB_REF}") ]]; then + if [[ "${GITHUB_EVENT_NAME}" == "pull_request_target" ]]; then IMAGE_TAG=$(echo "${GITHUB_HEAD_REF}" | sed "s#/#-#g") else IMAGE_TAG=$(echo "${GITHUB_REF:11}" | sed "s#/#-#g") @@ -313,6 +306,8 @@ jobs: steps: - 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@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0 @@ -326,9 +321,9 @@ jobs: run: az acr login -n $_AZ_REGISTRY --only-show-errors - name: Make Docker stubs - if: github.ref == 'refs/heads/main' || - github.ref == 'refs/heads/rc' || - github.ref == 'refs/heads/hotfix-rc' + if: | + github.event_name != 'pull_request_target' + && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') run: | # Set proper setup image based on branch case "$GITHUB_REF" in @@ -368,13 +363,17 @@ jobs: cd docker-stub/EU; zip -r ../../docker-stub-EU.zip *; cd ../.. - name: Make Docker stub checksums - if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc' + if: | + github.event_name != 'pull_request_target' + && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') run: | sha256sum docker-stub-US.zip > docker-stub-US-sha256.txt sha256sum docker-stub-EU.zip > docker-stub-EU-sha256.txt - name: Upload Docker stub US artifact - if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc' + if: | + github.event_name != 'pull_request_target' + && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 with: name: docker-stub-US.zip @@ -382,7 +381,9 @@ jobs: if-no-files-found: error - name: Upload Docker stub EU artifact - if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc' + if: | + github.event_name != 'pull_request_target' + && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 with: name: docker-stub-EU.zip @@ -390,7 +391,9 @@ jobs: if-no-files-found: error - name: Upload Docker stub US checksum artifact - if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc' + if: | + github.event_name != 'pull_request_target' + && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 with: name: docker-stub-US-sha256.txt @@ -398,7 +401,9 @@ jobs: if-no-files-found: error - name: Upload Docker stub EU checksum artifact - if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc' + if: | + github.event_name != 'pull_request_target' + && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 with: name: docker-stub-EU-sha256.txt @@ -473,7 +478,8 @@ jobs: build-mssqlmigratorutility: name: Build MSSQL migrator utility runs-on: ubuntu-22.04 - needs: lint + needs: + - lint defaults: run: shell: bash @@ -488,6 +494,8 @@ jobs: steps: - 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@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0 @@ -522,8 +530,10 @@ jobs: self-host-build: name: Trigger self-host build + if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/main' runs-on: ubuntu-22.04 - needs: build-docker + needs: + - build-docker steps: - name: Log in to Azure - CI subscription uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 @@ -554,9 +564,10 @@ jobs: trigger-k8s-deploy: name: Trigger k8s deploy - if: github.ref == 'refs/heads/main' + if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/main' runs-on: ubuntu-22.04 - needs: build-docker + needs: + - build-docker steps: - name: Log in to Azure - CI subscription uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 @@ -588,9 +599,12 @@ jobs: trigger-ee-updates: name: Trigger Ephemeral Environment updates - if: github.ref != 'refs/heads/main' && contains(github.event.pull_request.labels.*.name, 'ephemeral-environment') + if: | + github.event_name == 'pull_request_target' + && contains(github.event.pull_request.labels.*.name, 'ephemeral-environment') runs-on: ubuntu-24.04 - needs: build-docker + needs: + - build-docker steps: - name: Log in to Azure - CI subscription uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 @@ -634,9 +648,8 @@ jobs: steps: - name: Check if any job failed if: | - (github.ref == 'refs/heads/main' - || github.ref == 'refs/heads/rc' - || github.ref == 'refs/heads/hotfix-rc') + github.event_name != 'pull_request_target' + && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') && contains(needs.*.result, 'failure') run: exit 1 diff --git a/.github/workflows/repository-management.yml b/.github/workflows/repository-management.yml index 4cbf90c00..5cf7a91b0 100644 --- a/.github/workflows/repository-management.yml +++ b/.github/workflows/repository-management.yml @@ -28,7 +28,6 @@ jobs: runs-on: ubuntu-24.04 outputs: branch: ${{ steps.set-branch.outputs.branch }} - token: ${{ steps.app-token.outputs.token }} steps: - name: Set branch id: set-branch @@ -45,13 +44,6 @@ jobs: echo "branch=$BRANCH" >> $GITHUB_OUTPUT - - name: Generate GH App token - uses: actions/create-github-app-token@5d869da34e18e7287c1daad50e0b8ea0f506ce69 # v1.11.0 - id: app-token - with: - app-id: ${{ secrets.BW_GHAPP_ID }} - private-key: ${{ secrets.BW_GHAPP_KEY }} - cut_branch: name: Cut branch @@ -59,11 +51,18 @@ jobs: needs: setup runs-on: ubuntu-24.04 steps: + - name: Generate GH App token + uses: actions/create-github-app-token@5d869da34e18e7287c1daad50e0b8ea0f506ce69 # v1.11.0 + id: app-token + with: + app-id: ${{ secrets.BW_GHAPP_ID }} + private-key: ${{ secrets.BW_GHAPP_KEY }} + - name: Check out target ref uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: ref: ${{ inputs.target_ref }} - token: ${{ needs.setup.outputs.token }} + token: ${{ steps.app-token.outputs.token }} - name: Check if ${{ needs.setup.outputs.branch }} branch exists env: @@ -98,11 +97,18 @@ jobs: with: version: ${{ inputs.version_number_override }} + - name: Generate GH App token + uses: actions/create-github-app-token@5d869da34e18e7287c1daad50e0b8ea0f506ce69 # v1.11.0 + id: app-token + with: + app-id: ${{ secrets.BW_GHAPP_ID }} + private-key: ${{ secrets.BW_GHAPP_KEY }} + - name: Check out branch uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: ref: main - token: ${{ needs.setup.outputs.token }} + token: ${{ steps.app-token.outputs.token }} - name: Configure Git run: | @@ -190,11 +196,18 @@ jobs: - bump_version - setup steps: + - name: Generate GH App token + uses: actions/create-github-app-token@5d869da34e18e7287c1daad50e0b8ea0f506ce69 # v1.11.0 + id: app-token + with: + app-id: ${{ secrets.BW_GHAPP_ID }} + private-key: ${{ secrets.BW_GHAPP_KEY }} + - name: Check out main branch uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: ref: main - token: ${{ needs.setup.outputs.token }} + token: ${{ steps.app-token.outputs.token }} - name: Configure Git run: | diff --git a/.github/workflows/test-database.yml b/.github/workflows/test-database.yml index f053e81a5..134e96b33 100644 --- a/.github/workflows/test-database.yml +++ b/.github/workflows/test-database.yml @@ -70,6 +70,11 @@ jobs: docker compose --profile mssql --profile postgres --profile mysql up -d shell: pwsh + - name: Add MariaDB for unified + # Use a different port than MySQL + run: | + docker run --detach --name mariadb --env MARIADB_ROOT_PASSWORD=mariadb-password -p 4306:3306 mariadb:10 + # I've seen the SQL Server container not be ready for commands right after starting up and just needing a bit longer to be ready - name: Sleep run: sleep 15s @@ -102,6 +107,12 @@ jobs: run: 'dotnet ef database update --connection "$CONN_STR" -- --GlobalSettings:MySql:ConnectionString="$CONN_STR"' env: CONN_STR: "server=localhost;uid=root;pwd=SET_A_PASSWORD_HERE_123;database=vault_dev;Allow User Variables=true" + + - name: Migrate MariaDB + working-directory: "util/MySqlMigrations" + run: 'dotnet ef database update --connection "$CONN_STR" -- --GlobalSettings:MySql:ConnectionString="$CONN_STR"' + env: + CONN_STR: "server=localhost;port=4306;uid=root;pwd=mariadb-password;database=vault_dev;Allow User Variables=true" - name: Migrate Postgres working-directory: "util/PostgresMigrations" @@ -130,6 +141,9 @@ jobs: # Default Sqlite BW_TEST_DATABASES__3__TYPE: "Sqlite" BW_TEST_DATABASES__3__CONNECTIONSTRING: "Data Source=${{ runner.temp }}/test.db" + # Unified MariaDB + BW_TEST_DATABASES__4__TYPE: "MySql" + BW_TEST_DATABASES__4__CONNECTIONSTRING: "server=localhost;port=4306;uid=root;pwd=mariadb-password;database=vault_dev;Allow User Variables=true" run: dotnet test --logger "trx;LogFileName=infrastructure-test-results.trx" shell: pwsh @@ -137,6 +151,10 @@ jobs: if: failure() run: 'docker logs $(docker ps --quiet --filter "name=mysql")' + - name: Print MariaDB Logs + if: failure() + run: 'docker logs $(docker ps --quiet --filter "name=mariadb")' + - name: Print Postgres Logs if: failure() run: 'docker logs $(docker ps --quiet --filter "name=postgres")' diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/CreateProviderCommand.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/CreateProviderCommand.cs index 3b01370ef..69b7e67ec 100644 --- a/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/CreateProviderCommand.cs +++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/CreateProviderCommand.cs @@ -1,5 +1,4 @@ -using Bit.Core; -using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Repositories; @@ -10,7 +9,6 @@ using Bit.Core.Billing.Repositories; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; -using Bit.Core.Services; namespace Bit.Commercial.Core.AdminConsole.Providers; @@ -21,35 +19,28 @@ public class CreateProviderCommand : ICreateProviderCommand private readonly IProviderService _providerService; private readonly IUserRepository _userRepository; private readonly IProviderPlanRepository _providerPlanRepository; - private readonly IFeatureService _featureService; public CreateProviderCommand( IProviderRepository providerRepository, IProviderUserRepository providerUserRepository, IProviderService providerService, IUserRepository userRepository, - IProviderPlanRepository providerPlanRepository, - IFeatureService featureService) + IProviderPlanRepository providerPlanRepository) { _providerRepository = providerRepository; _providerUserRepository = providerUserRepository; _providerService = providerService; _userRepository = userRepository; _providerPlanRepository = providerPlanRepository; - _featureService = featureService; } public async Task CreateMspAsync(Provider provider, string ownerEmail, int teamsMinimumSeats, int enterpriseMinimumSeats) { var providerId = await CreateProviderAsync(provider, ownerEmail); - var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling); - - if (isConsolidatedBillingEnabled) - { - await CreateProviderPlanAsync(providerId, PlanType.TeamsMonthly, teamsMinimumSeats); - await CreateProviderPlanAsync(providerId, PlanType.EnterpriseMonthly, enterpriseMinimumSeats); - } + await Task.WhenAll( + CreateProviderPlanAsync(providerId, PlanType.TeamsMonthly, teamsMinimumSeats), + CreateProviderPlanAsync(providerId, PlanType.EnterpriseMonthly, enterpriseMinimumSeats)); } public async Task CreateResellerAsync(Provider provider) @@ -61,12 +52,7 @@ public class CreateProviderCommand : ICreateProviderCommand { var providerId = await CreateProviderAsync(provider, ownerEmail); - var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling); - - if (isConsolidatedBillingEnabled) - { - await CreateProviderPlanAsync(providerId, plan, minimumSeats); - } + await CreateProviderPlanAsync(providerId, plan, minimumSeats); } private async Task CreateProviderAsync(Provider provider, string ownerEmail) @@ -77,12 +63,7 @@ public class CreateProviderCommand : ICreateProviderCommand throw new BadRequestException("Invalid owner. Owner must be an existing Bitwarden user."); } - var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling); - - if (isConsolidatedBillingEnabled) - { - provider.Gateway = GatewayType.Stripe; - } + provider.Gateway = GatewayType.Stripe; await ProviderRepositoryCreateAsync(provider, ProviderStatusType.Pending); diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs index 045fd5059..ce0c0c933 100644 --- a/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs +++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs @@ -1,7 +1,5 @@ -using Bit.Core; -using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; -using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Repositories; @@ -102,11 +100,8 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv Provider provider, IEnumerable organizationOwnerEmails) { - var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling); - - if (isConsolidatedBillingEnabled && - provider.Status == ProviderStatusType.Billable && - organization.Status == OrganizationStatusType.Managed && + if (provider.IsBillable() && + organization.IsValidClient() && !string.IsNullOrEmpty(organization.GatewayCustomerId)) { await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, new CustomerUpdateOptions diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs index 48ea903ad..e384d71df 100644 --- a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs +++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs @@ -8,7 +8,6 @@ using Bit.Core.AdminConsole.Models.Business.Tokenables; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Services; using Bit.Core.Context; using Bit.Core.Entities; @@ -101,24 +100,16 @@ public class ProviderService : IProviderService throw new BadRequestException("Invalid owner."); } - if (!_featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling)) + if (taxInfo == null || string.IsNullOrEmpty(taxInfo.BillingAddressCountry) || string.IsNullOrEmpty(taxInfo.BillingAddressPostalCode)) { - provider.Status = ProviderStatusType.Created; - await _providerRepository.UpsertAsync(provider); - } - else - { - if (taxInfo == null || string.IsNullOrEmpty(taxInfo.BillingAddressCountry) || string.IsNullOrEmpty(taxInfo.BillingAddressPostalCode)) - { - throw new BadRequestException("Both address and postal code are required to set up your provider."); - } - var customer = await _providerBillingService.SetupCustomer(provider, taxInfo); - provider.GatewayCustomerId = customer.Id; - var subscription = await _providerBillingService.SetupSubscription(provider); - provider.GatewaySubscriptionId = subscription.Id; - provider.Status = ProviderStatusType.Billable; - await _providerRepository.UpsertAsync(provider); + throw new BadRequestException("Both address and postal code are required to set up your provider."); } + var customer = await _providerBillingService.SetupCustomer(provider, taxInfo); + provider.GatewayCustomerId = customer.Id; + var subscription = await _providerBillingService.SetupSubscription(provider); + provider.GatewaySubscriptionId = subscription.Id; + provider.Status = ProviderStatusType.Billable; + await _providerRepository.UpsertAsync(provider); providerUser.Key = key; await _providerUserRepository.ReplaceAsync(providerUser); @@ -545,13 +536,9 @@ public class ProviderService : IProviderService { var provider = await _providerRepository.GetByIdAsync(providerId); - var consolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling) && provider.IsBillable(); + ThrowOnInvalidPlanType(provider.Type, organizationSignup.Plan); - ThrowOnInvalidPlanType(provider.Type, organizationSignup.Plan, consolidatedBillingEnabled); - - var (organization, _, defaultCollection) = consolidatedBillingEnabled - ? await _organizationService.SignupClientAsync(organizationSignup) - : await _organizationService.SignUpAsync(organizationSignup); + var (organization, _, defaultCollection) = await _organizationService.SignupClientAsync(organizationSignup); var providerOrganization = new ProviderOrganization { @@ -687,27 +674,24 @@ public class ProviderService : IProviderService return confirmedOwnersIds.Except(providerUserIds).Any(); } - private void ThrowOnInvalidPlanType(ProviderType providerType, PlanType requestedType, bool consolidatedBillingEnabled = false) + private void ThrowOnInvalidPlanType(ProviderType providerType, PlanType requestedType) { - if (consolidatedBillingEnabled) + switch (providerType) { - switch (providerType) - { - case ProviderType.Msp: - if (requestedType is not (PlanType.TeamsMonthly or PlanType.EnterpriseMonthly)) - { - throw new BadRequestException($"Managed Service Providers cannot manage organizations with the plan type {requestedType}. Only Teams (Monthly) and Enterprise (Monthly) are allowed."); - } - break; - case ProviderType.MultiOrganizationEnterprise: - if (requestedType is not (PlanType.EnterpriseMonthly or PlanType.EnterpriseAnnually)) - { - throw new BadRequestException($"Multi-organization Enterprise Providers cannot manage organizations with the plan type {requestedType}. Only Enterprise (Monthly) and Enterprise (Annually) are allowed."); - } - break; - default: - throw new BadRequestException($"Unsupported provider type {providerType}."); - } + case ProviderType.Msp: + if (requestedType is not (PlanType.TeamsMonthly or PlanType.EnterpriseMonthly)) + { + throw new BadRequestException($"Managed Service Providers cannot manage organizations with the plan type {requestedType}. Only Teams (Monthly) and Enterprise (Monthly) are allowed."); + } + break; + case ProviderType.MultiOrganizationEnterprise: + if (requestedType is not (PlanType.EnterpriseMonthly or PlanType.EnterpriseAnnually)) + { + throw new BadRequestException($"Multi-organization Enterprise Providers cannot manage organizations with the plan type {requestedType}. Only Enterprise (Monthly) and Enterprise (Annually) are allowed."); + } + break; + default: + throw new BadRequestException($"Unsupported provider type {providerType}."); } if (ProviderDisallowedOrganizationTypes.Contains(requestedType)) diff --git a/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs b/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs index 7b1d33599..a6bf62871 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs @@ -2,17 +2,14 @@ using Bit.Commercial.Core.Billing.Models; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; -using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Entities; using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Repositories; using Bit.Core.Billing.Services; using Bit.Core.Billing.Services.Contracts; -using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Business; @@ -27,7 +24,6 @@ using Stripe; namespace Bit.Commercial.Core.Billing; public class ProviderBillingService( - ICurrentContext currentContext, IGlobalSettings globalSettings, ILogger logger, IOrganizationRepository organizationRepository, @@ -35,38 +31,76 @@ public class ProviderBillingService( IProviderInvoiceItemRepository providerInvoiceItemRepository, IProviderOrganizationRepository providerOrganizationRepository, IProviderPlanRepository providerPlanRepository, - IProviderRepository providerRepository, IStripeAdapter stripeAdapter, ISubscriberService subscriberService) : IProviderBillingService { - public async Task AssignSeatsToClientOrganization( - Provider provider, - Organization organization, - int seats) + public async Task ChangePlan(ChangeProviderPlanCommand command) { - ArgumentNullException.ThrowIfNull(organization); + var plan = await providerPlanRepository.GetByIdAsync(command.ProviderPlanId); - if (seats < 0) + if (plan == null) { - throw new BillingException( - "You cannot assign negative seats to a client.", - "MSP cannot assign negative seats to a client organization"); + throw new BadRequestException("Provider plan not found."); } - if (seats == organization.Seats) + if (plan.PlanType == command.NewPlan) { - logger.LogWarning("Client organization ({ID}) already has {Seats} seats assigned to it", organization.Id, organization.Seats); - return; } - var seatAdjustment = seats - (organization.Seats ?? 0); + var oldPlanConfiguration = StaticStore.GetPlan(plan.PlanType); - await ScaleSeats(provider, organization.PlanType, seatAdjustment); + plan.PlanType = command.NewPlan; + await providerPlanRepository.ReplaceAsync(plan); - organization.Seats = seats; + Subscription subscription; + try + { + subscription = await stripeAdapter.ProviderSubscriptionGetAsync(command.GatewaySubscriptionId, plan.ProviderId); + } + catch (InvalidOperationException) + { + throw new ConflictException("Subscription not found."); + } - await organizationRepository.ReplaceAsync(organization); + var oldSubscriptionItem = subscription.Items.SingleOrDefault(x => + x.Price.Id == oldPlanConfiguration.PasswordManager.StripeProviderPortalSeatPlanId); + + var updateOptions = new SubscriptionUpdateOptions + { + Items = + [ + new SubscriptionItemOptions + { + Price = StaticStore.GetPlan(command.NewPlan).PasswordManager.StripeProviderPortalSeatPlanId, + Quantity = oldSubscriptionItem!.Quantity + }, + new SubscriptionItemOptions + { + Id = oldSubscriptionItem.Id, + Deleted = true + } + ] + }; + + await stripeAdapter.SubscriptionUpdateAsync(command.GatewaySubscriptionId, updateOptions); + + // Refactor later to ?ChangeClientPlanCommand? (ProviderPlanId, ProviderId, OrganizationId) + // 1. Retrieve PlanType and PlanName for ProviderPlan + // 2. Assign PlanType & PlanName to Organization + var providerOrganizations = await providerOrganizationRepository.GetManyDetailsByProviderAsync(plan.ProviderId); + + foreach (var providerOrganization in providerOrganizations) + { + var organization = await organizationRepository.GetByIdAsync(providerOrganization.OrganizationId); + if (organization == null) + { + throw new ConflictException($"Organization '{providerOrganization.Id}' not found."); + } + organization.PlanType = command.NewPlan; + organization.Plan = StaticStore.GetPlan(command.NewPlan).Name; + await organizationRepository.ReplaceAsync(organization); + } } public async Task CreateCustomerForClientOrganization( @@ -171,65 +205,16 @@ public class ProviderBillingService( return memoryStream.ToArray(); } - public async Task GetAssignedSeatTotalForPlanOrThrow( - Guid providerId, - PlanType planType) - { - var provider = await providerRepository.GetByIdAsync(providerId); - - if (provider == null) - { - logger.LogError( - "Could not find provider ({ID}) when retrieving assigned seat total", - providerId); - - throw new BillingException(); - } - - if (provider.Type == ProviderType.Reseller) - { - logger.LogError("Assigned seats cannot be retrieved for reseller-type provider ({ID})", providerId); - - throw new BillingException(); - } - - var providerOrganizations = await providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId); - - var plan = StaticStore.GetPlan(planType); - - return providerOrganizations - .Where(providerOrganization => providerOrganization.Plan == plan.Name && providerOrganization.Status == OrganizationStatusType.Managed) - .Sum(providerOrganization => providerOrganization.Seats ?? 0); - } - public async Task ScaleSeats( Provider provider, PlanType planType, int seatAdjustment) { - ArgumentNullException.ThrowIfNull(provider); + var providerPlan = await GetProviderPlanAsync(provider, planType); - if (!provider.SupportsConsolidatedBilling()) - { - logger.LogError("Provider ({ProviderID}) cannot scale their seats", provider.Id); + var seatMinimum = providerPlan.SeatMinimum ?? 0; - throw new BillingException(); - } - - var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id); - - var providerPlan = providerPlans.FirstOrDefault(providerPlan => providerPlan.PlanType == planType); - - if (providerPlan == null || !providerPlan.IsConfigured()) - { - logger.LogError("Cannot scale provider ({ProviderID}) seats for plan type {PlanType} when their matching provider plan is not configured", provider.Id, planType); - - throw new BillingException(); - } - - var seatMinimum = providerPlan.SeatMinimum.GetValueOrDefault(0); - - var currentlyAssignedSeatTotal = await GetAssignedSeatTotalForPlanOrThrow(provider.Id, planType); + var currentlyAssignedSeatTotal = await GetAssignedSeatTotalAsync(provider, planType); var newlyAssignedSeatTotal = currentlyAssignedSeatTotal + seatAdjustment; @@ -256,13 +241,6 @@ public class ProviderBillingService( else if (currentlyAssignedSeatTotal <= seatMinimum && newlyAssignedSeatTotal > seatMinimum) { - if (!currentContext.ProviderProviderAdmin(provider.Id)) - { - logger.LogError("Service user for provider ({ProviderID}) cannot scale a provider's seat count over the seat minimum", provider.Id); - - throw new BillingException(); - } - await update( seatMinimum, newlyAssignedSeatTotal); @@ -291,6 +269,26 @@ public class ProviderBillingService( } } + public async Task SeatAdjustmentResultsInPurchase( + Provider provider, + PlanType planType, + int seatAdjustment) + { + var providerPlan = await GetProviderPlanAsync(provider, planType); + + var seatMinimum = providerPlan.SeatMinimum; + + var currentlyAssignedSeatTotal = await GetAssignedSeatTotalAsync(provider, planType); + + var newlyAssignedSeatTotal = currentlyAssignedSeatTotal + seatAdjustment; + + return + // Below the limit to above the limit + (currentlyAssignedSeatTotal <= seatMinimum && newlyAssignedSeatTotal > seatMinimum) || + // Above the limit to further above the limit + (currentlyAssignedSeatTotal > seatMinimum && newlyAssignedSeatTotal > seatMinimum && newlyAssignedSeatTotal > currentlyAssignedSeatTotal); + } + public async Task SetupCustomer( Provider provider, TaxInfo taxInfo) @@ -431,75 +429,6 @@ public class ProviderBillingService( } } - public async Task ChangePlan(ChangeProviderPlanCommand command) - { - var plan = await providerPlanRepository.GetByIdAsync(command.ProviderPlanId); - - if (plan == null) - { - throw new BadRequestException("Provider plan not found."); - } - - if (plan.PlanType == command.NewPlan) - { - return; - } - - var oldPlanConfiguration = StaticStore.GetPlan(plan.PlanType); - - plan.PlanType = command.NewPlan; - await providerPlanRepository.ReplaceAsync(plan); - - Subscription subscription; - try - { - subscription = await stripeAdapter.ProviderSubscriptionGetAsync(command.GatewaySubscriptionId, plan.ProviderId); - } - catch (InvalidOperationException) - { - throw new ConflictException("Subscription not found."); - } - - var oldSubscriptionItem = subscription.Items.SingleOrDefault(x => - x.Price.Id == oldPlanConfiguration.PasswordManager.StripeProviderPortalSeatPlanId); - - var updateOptions = new SubscriptionUpdateOptions - { - Items = - [ - new SubscriptionItemOptions - { - Price = StaticStore.GetPlan(command.NewPlan).PasswordManager.StripeProviderPortalSeatPlanId, - Quantity = oldSubscriptionItem!.Quantity - }, - new SubscriptionItemOptions - { - Id = oldSubscriptionItem.Id, - Deleted = true - } - ] - }; - - await stripeAdapter.SubscriptionUpdateAsync(command.GatewaySubscriptionId, updateOptions); - - // Refactor later to ?ChangeClientPlanCommand? (ProviderPlanId, ProviderId, OrganizationId) - // 1. Retrieve PlanType and PlanName for ProviderPlan - // 2. Assign PlanType & PlanName to Organization - var providerOrganizations = await providerOrganizationRepository.GetManyDetailsByProviderAsync(plan.ProviderId); - - foreach (var providerOrganization in providerOrganizations) - { - var organization = await organizationRepository.GetByIdAsync(providerOrganization.OrganizationId); - if (organization == null) - { - throw new ConflictException($"Organization '{providerOrganization.Id}' not found."); - } - organization.PlanType = command.NewPlan; - organization.Plan = StaticStore.GetPlan(command.NewPlan).Name; - await organizationRepository.ReplaceAsync(organization); - } - } - public async Task UpdateSeatMinimums(UpdateProviderSeatMinimumsCommand command) { if (command.Configuration.Any(x => x.SeatsMinimum < 0)) @@ -610,4 +539,32 @@ public class ProviderBillingService( await providerPlanRepository.ReplaceAsync(providerPlan); }; + + // TODO: Replace with SPROC + private async Task GetAssignedSeatTotalAsync(Provider provider, PlanType planType) + { + var providerOrganizations = + await providerOrganizationRepository.GetManyDetailsByProviderAsync(provider.Id); + + var plan = StaticStore.GetPlan(planType); + + return providerOrganizations + .Where(providerOrganization => providerOrganization.Plan == plan.Name && providerOrganization.Status == OrganizationStatusType.Managed) + .Sum(providerOrganization => providerOrganization.Seats ?? 0); + } + + // TODO: Replace with SPROC + private async Task GetProviderPlanAsync(Provider provider, PlanType planType) + { + var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id); + + var providerPlan = providerPlans.FirstOrDefault(x => x.PlanType == planType); + + if (providerPlan == null || !providerPlan.IsConfigured()) + { + throw new BillingException(message: "Provider plan is missing or misconfigured"); + } + + return providerPlan; + } } diff --git a/bitwarden_license/src/Sso/Views/Shared/_Layout.cshtml b/bitwarden_license/src/Sso/Views/Shared/_Layout.cshtml index fc57d7e9b..b5330dfa9 100644 --- a/bitwarden_license/src/Sso/Views/Shared/_Layout.cshtml +++ b/bitwarden_license/src/Sso/Views/Shared/_Layout.cshtml @@ -23,7 +23,7 @@ @RenderBody() -