diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 87e0fb56f0..3a7def9a18 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -4,27 +4,11 @@ "tools": { "swashbuckle.aspnetcore.cli": { "version": "6.5.0", - "commands": [ - "swagger" - ] - }, - "coverlet.console": { - "version": "3.1.2", - "commands": [ - "coverlet" - ] - }, - "dotnet-reportgenerator-globaltool": { - "version": "5.1.6", - "commands": [ - "reportgenerator" - ] + "commands": ["swagger"] }, "dotnet-ef": { "version": "7.0.14", - "commands": [ - "dotnet-ef" - ] + "commands": ["dotnet-ef"] } } -} \ No newline at end of file +} diff --git a/.github/renovate.json b/.github/renovate.json index aa0ee6d9ce..714baad8e1 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -37,6 +37,10 @@ "matchManagers": ["github-actions"], "matchUpdateTypes": ["minor", "patch"] }, + { + "matchManagers": ["github-actions", "dockerfile", "docker-compose"], + "commitMessagePrefix": "[deps] DevOps:" + }, { "matchPackageNames": ["DnsClient", "Quartz"], "description": "Admin Console owned dependencies", @@ -59,8 +63,7 @@ "Azure.Storage.Blobs", "Azure.Storage.Queues", "Fido2.AspNet", - "IdentityServer4", - "IdentityServer4.AccessTokenValidation", + "Duende.IdentityServer", "Microsoft.Azure.Cosmos", "Microsoft.Azure.Cosmos.Table", "Microsoft.Extensions.Caching.StackExchangeRedis", diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7cdc0d8bcf..96061b128e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -61,28 +61,14 @@ jobs: echo "GitHub ref: $GITHUB_REF" echo "GitHub event: $GITHUB_EVENT" - - name: Restore - run: dotnet restore --locked-mode - shell: pwsh - - name: Remove SQL proj run: dotnet sln bitwarden-server.sln remove src/Sql/Sql.sqlproj - - name: Build OSS solution - run: dotnet build bitwarden-server.sln -p:Configuration=Debug -p:DefineConstants="OSS" --verbosity minimal - shell: pwsh - - - name: Build solution - run: dotnet build bitwarden-server.sln -p:Configuration=Debug --verbosity minimal - shell: pwsh - - name: Test OSS solution - run: dotnet test ./test --configuration Debug --no-build --logger "trx;LogFileName=oss-test-results.trx" - shell: pwsh + run: dotnet test ./test --configuration Release --logger "trx;LogFileName=oss-test-results.trx" /p:CoverletOutputFormatter="cobertura" --collect:"XPlat Code Coverage" - name: Test Bitwarden solution - run: dotnet test ./bitwarden_license/test --configuration Debug --no-build --logger "trx;LogFileName=bw-test-results.trx" - shell: pwsh + run: dotnet test ./bitwarden_license/test --configuration Release --logger "trx;LogFileName=bw-test-results.trx" /p:CoverletOutputFormatter="cobertura" --collect:"XPlat Code Coverage" - name: Report test results uses: dorny/test-reporter@c9b3d0e2bd2a4e96aaf424dbaa31c46b42318226 # v1.6.0 @@ -93,6 +79,11 @@ jobs: reporter: dotnet-trx fail-on-error: true + - name: Upload to codecov.io + uses: codecov/codecov-action@eaaf4bedf32dbdc6b720b63067d99c4d77d6047d # v3.1.4 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + build-artifacts: name: Build artifacts runs-on: ubuntu-22.04 @@ -156,14 +147,6 @@ jobs: echo "GitHub ref: $GITHUB_REF" echo "GitHub event: $GITHUB_EVENT" - - name: Restore/Clean project - working-directory: ${{ matrix.base_path }}/${{ matrix.project_name }} - run: | - echo "Restore" - dotnet restore - echo "Clean" - dotnet clean -c "Release" -o obj/build-output/publish - - name: Build node if: ${{ matrix.node }} working-directory: ${{ matrix.base_path }}/${{ matrix.project_name }} @@ -357,9 +340,6 @@ jobs: - name: Login to PROD ACR run: az acr login -n $_AZ_REGISTRY --only-show-errors - - name: Restore - run: dotnet tool restore - - name: Make Docker stubs if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || @@ -443,10 +423,8 @@ jobs: - name: Build Swagger run: | cd ./src/Api - echo "Restore" - dotnet restore - echo "Clean" - dotnet clean -c "Release" -o obj/build-output/publish + echo "Restore tools" + dotnet tool restore echo "Publish" dotnet publish -c "Release" -o obj/build-output/publish @@ -495,11 +473,6 @@ jobs: echo "GitHub ref: $GITHUB_REF" echo "GitHub event: $GITHUB_EVENT" - - name: Restore project - run: | - echo "Restore" - dotnet restore - - name: Publish project run: | dotnet publish -c "Release" -o obj/build-output/publish -r ${{ matrix.target }} -p:PublishSingleFile=true \ @@ -521,12 +494,10 @@ jobs: path: util/MsSqlMigratorUtility/obj/build-output/publish/MsSqlMigratorUtility if-no-files-found: error - self-host-build: name: Trigger self-host build runs-on: ubuntu-22.04 - needs: - - build-docker + needs: build-docker steps: - name: Login to Azure - CI Subscription uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7 @@ -555,6 +526,40 @@ jobs: } }) + trigger-k8s-deploy: + name: Trigger k8s deploy + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-22.04 + needs: build-docker + steps: + - name: Login to Azure - CI Subscription + uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7 + with: + creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} + + - name: Retrieve github PAT secrets + id: retrieve-secret-pat + uses: bitwarden/gh-actions/get-keyvault-secrets@main + with: + keyvault: "bitwarden-ci" + secrets: "github-pat-bitwarden-devops-bot-repo-scope" + + - name: Trigger k8s deploy + uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6.4.1 + with: + github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }} + script: | + await github.rest.actions.createWorkflowDispatch({ + owner: 'bitwarden', + repo: 'devops', + workflow_id: 'deploy-k8s.yml', + ref: 'main', + inputs: { + environment: 'US-DEV Cloud', + tag: 'main' + } + }) + check-failures: name: Check for failures if: always() @@ -568,6 +573,7 @@ jobs: - upload - build-mssqlmigratorutility - self-host-build + - trigger-k8s-deploy steps: - name: Check if any job failed if: | @@ -583,6 +589,7 @@ jobs: UPLOAD_STATUS: ${{ needs.upload.result }} BUILD_MSSQLMIGRATORUTILITY_STATUS: ${{ needs.build-mssqlmigratorutility.result }} TRIGGER_SELF_HOST_BUILD_STATUS: ${{ needs.self-host-build.result }} + TRIGGER_K8S_DEPLOY_STATUS: ${{ needs.trigger-k8s-deploy.result }} run: | if [ "$CLOC_STATUS" = "failure" ]; then exit 1 @@ -600,6 +607,8 @@ jobs: exit 1 elif [ "$TRIGGER_SELF_HOST_BUILD_STATUS" = "failure" ]; then exit 1 + elif [ "$TRIGGER_K8S_DEPLOY_STATUS" = "failure" ]; then + exit 1 fi - name: Login to Azure - CI subscription diff --git a/Directory.Build.props b/Directory.Build.props index c5ca0651e9..98d030daa7 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -2,7 +2,7 @@ net6.0 - 2023.12.1 + 2024.1.0 Bit.$(MSBuildProjectName) enable false diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs index a03e92c3f0..c8b64da19a 100644 --- a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs +++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities.Provider; +using System.ComponentModel.DataAnnotations; +using Bit.Core; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Models.Business.Provider; using Bit.Core.AdminConsole.Repositories; @@ -33,13 +36,14 @@ public class ProviderService : IProviderService private readonly IUserService _userService; private readonly IOrganizationService _organizationService; private readonly ICurrentContext _currentContext; + private readonly IStripeAdapter _stripeAdapter; public ProviderService(IProviderRepository providerRepository, IProviderUserRepository providerUserRepository, IProviderOrganizationRepository providerOrganizationRepository, IUserRepository userRepository, IUserService userService, IOrganizationService organizationService, IMailService mailService, IDataProtectionProvider dataProtectionProvider, IEventService eventService, IOrganizationRepository organizationRepository, GlobalSettings globalSettings, - ICurrentContext currentContext) + ICurrentContext currentContext, IStripeAdapter stripeAdapter) { _providerRepository = providerRepository; _providerUserRepository = providerUserRepository; @@ -53,6 +57,7 @@ public class ProviderService : IProviderService _globalSettings = globalSettings; _dataProtector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector"); _currentContext = currentContext; + _stripeAdapter = stripeAdapter; } public async Task CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key) @@ -369,6 +374,7 @@ public class ProviderService : IProviderService Key = key, }; + await ApplyProviderPriceRateAsync(organizationId, providerId); await _providerOrganizationRepository.CreateAsync(providerOrganization); await _eventService.LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_Added); } @@ -381,18 +387,110 @@ public class ProviderService : IProviderService throw new BadRequestException("Provider must be of type Reseller in order to assign Organizations to it."); } - var existingProviderOrganizationsCount = await _providerOrganizationRepository.GetCountByOrganizationIdsAsync(organizationIds); + var orgIdsList = organizationIds.ToList(); + var existingProviderOrganizationsCount = await _providerOrganizationRepository.GetCountByOrganizationIdsAsync(orgIdsList); if (existingProviderOrganizationsCount > 0) { throw new BadRequestException("Organizations must not be assigned to any Provider."); } - var providerOrganizationsToInsert = organizationIds.Select(orgId => new ProviderOrganization { ProviderId = providerId, OrganizationId = orgId }); + var providerOrganizationsToInsert = orgIdsList.Select(orgId => new ProviderOrganization { ProviderId = providerId, OrganizationId = orgId }); var insertedProviderOrganizations = await _providerOrganizationRepository.CreateManyAsync(providerOrganizationsToInsert); await _eventService.LogProviderOrganizationEventsAsync(insertedProviderOrganizations.Select(ipo => (ipo, EventType.ProviderOrganization_Added, (DateTime?)null))); } + private async Task ApplyProviderPriceRateAsync(Guid organizationId, Guid providerId) + { + var provider = await _providerRepository.GetByIdAsync(providerId); + // if a provider was created before Nov 6, 2023.If true, the organization plan assigned to that provider is updated to a 2020 plan. + if (provider.CreationDate >= Constants.ProviderCreatedPriorNov62023) + { + return; + } + + var organization = await _organizationRepository.GetByIdAsync(organizationId); + var subscriptionItem = await GetSubscriptionItemAsync(organization.GatewaySubscriptionId, GetStripeSeatPlanId(organization.PlanType)); + var extractedPlanType = PlanTypeMappings(organization); + if (subscriptionItem != null) + { + await UpdateSubscriptionAsync(subscriptionItem, GetStripeSeatPlanId(extractedPlanType), organization); + } + + await _organizationRepository.UpsertAsync(organization); + } + + private async Task GetSubscriptionItemAsync(string subscriptionId, string oldPlanId) + { + var subscriptionDetails = await _stripeAdapter.SubscriptionGetAsync(subscriptionId); + return subscriptionDetails.Items.Data.FirstOrDefault(item => item.Price.Id == oldPlanId); + } + + private static string GetStripeSeatPlanId(PlanType planType) + { + return StaticStore.GetPlan(planType).PasswordManager.StripeSeatPlanId; + } + + private async Task UpdateSubscriptionAsync(Stripe.SubscriptionItem subscriptionItem, string extractedPlanType, Organization organization) + { + try + { + if (subscriptionItem.Price.Id != extractedPlanType) + { + await _stripeAdapter.SubscriptionUpdateAsync(subscriptionItem.Subscription, + new Stripe.SubscriptionUpdateOptions + { + Items = new List + { + new() + { + Id = subscriptionItem.Id, + Price = extractedPlanType, + Quantity = organization.Seats.Value, + }, + } + }); + } + } + catch (Exception) + { + throw new Exception("Unable to update existing plan on stripe"); + } + + } + + private static PlanType PlanTypeMappings(Organization organization) + { + var planTypeMappings = new Dictionary + { + { PlanType.EnterpriseAnnually2020, GetEnumDisplayName(PlanType.EnterpriseAnnually2020) }, + { PlanType.EnterpriseMonthly2020, GetEnumDisplayName(PlanType.EnterpriseMonthly2020) }, + { PlanType.TeamsMonthly2020, GetEnumDisplayName(PlanType.TeamsMonthly2020) }, + { PlanType.TeamsAnnually2020, GetEnumDisplayName(PlanType.TeamsAnnually2020) } + }; + + foreach (var mapping in planTypeMappings) + { + if (mapping.Value.IndexOf(organization.Plan, StringComparison.Ordinal) != -1) + { + organization.PlanType = mapping.Key; + organization.Plan = mapping.Value; + return organization.PlanType; + } + } + + throw new ArgumentException("Invalid PlanType selected"); + } + + private static string GetEnumDisplayName(Enum value) + { + var fieldInfo = value.GetType().GetField(value.ToString()); + + var displayAttribute = (DisplayAttribute)Attribute.GetCustomAttribute(fieldInfo!, typeof(DisplayAttribute)); + + return displayAttribute?.Name ?? value.ToString(); + } + public async Task CreateOrganizationAsync(Guid providerId, OrganizationSignup organizationSignup, string clientOwnerEmail, User user) { 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 b7ee76da1a..24167e7141 100644 --- a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs @@ -18,6 +18,7 @@ using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.AspNetCore.DataProtection; using NSubstitute; using NSubstitute.ReturnsExtensions; +using Stripe; using Xunit; using Provider = Bit.Core.AdminConsole.Entities.Provider.Provider; using ProviderUser = Bit.Core.AdminConsole.Entities.Provider.ProviderUser; @@ -598,4 +599,98 @@ public class ProviderServiceTests await sutProvider.GetDependency().Received() .LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_Removed); } + + [Theory, BitAutoData] + public async Task AddOrganization_CreateAfterNov162023_PlanTypeDoesNotUpdated(Provider provider, Organization organization, string key, + SutProvider sutProvider) + { + provider.Type = ProviderType.Msp; + + sutProvider.GetDependency().GetByIdAsync(provider.Id).Returns(provider); + + var providerOrganizationRepository = sutProvider.GetDependency(); + var expectedPlanType = PlanType.EnterpriseAnnually; + organization.PlanType = PlanType.EnterpriseAnnually; + sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); + + await sutProvider.Sut.AddOrganization(provider.Id, organization.Id, key); + + await providerOrganizationRepository.ReceivedWithAnyArgs().CreateAsync(default); + await sutProvider.GetDependency() + .Received().LogProviderOrganizationEventAsync(Arg.Any(), + EventType.ProviderOrganization_Added); + Assert.Equal(organization.PlanType, expectedPlanType); + } + + [Theory, BitAutoData] + public async Task AddOrganization_CreateBeforeNov162023_PlanTypeUpdated(Provider provider, Organization organization, string key, + SutProvider sutProvider) + { + var newCreationDate = DateTime.UtcNow.AddMonths(-3); + BackdateProviderCreationDate(provider, newCreationDate); + provider.Type = ProviderType.Msp; + + organization.PlanType = PlanType.EnterpriseAnnually; + organization.Plan = "Enterprise (Annually)"; + + var expectedPlanType = PlanType.EnterpriseAnnually2020; + + var expectedPlanId = "2020-enterprise-org-seat-annually"; + + sutProvider.GetDependency().GetByIdAsync(provider.Id).Returns(provider); + var providerOrganizationRepository = sutProvider.GetDependency(); + providerOrganizationRepository.GetByOrganizationId(organization.Id).ReturnsNull(); + sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); + + sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); + var subscriptionItem = GetSubscription(organization.GatewaySubscriptionId); + sutProvider.GetDependency().SubscriptionGetAsync(organization.GatewaySubscriptionId) + .Returns(GetSubscription(organization.GatewaySubscriptionId)); + await sutProvider.GetDependency().SubscriptionUpdateAsync( + organization.GatewaySubscriptionId, SubscriptionUpdateRequest(expectedPlanId, subscriptionItem)); + + await sutProvider.Sut.AddOrganization(provider.Id, organization.Id, key); + + await providerOrganizationRepository.ReceivedWithAnyArgs().CreateAsync(default); + await sutProvider.GetDependency() + .Received().LogProviderOrganizationEventAsync(Arg.Any(), + EventType.ProviderOrganization_Added); + + Assert.Equal(organization.PlanType, expectedPlanType); + } + + private static SubscriptionUpdateOptions SubscriptionUpdateRequest(string expectedPlanId, Subscription subscriptionItem) => + new() + { + Items = new List + { + new() { Id = subscriptionItem.Id, Price = expectedPlanId }, + } + }; + + private static Subscription GetSubscription(string subscriptionId) => + new() + { + Id = subscriptionId, + Items = new StripeList + { + Data = new List + { + new() + { + Id = "sub_item_123", + Price = new Price() + { + Id = "2023-enterprise-org-seat-annually" + } + } + } + } + }; + + private static void BackdateProviderCreationDate(Provider provider, DateTime newCreationDate) + { + // Set the CreationDate to the desired value + provider.GetType().GetProperty("CreationDate")?.SetValue(provider, newCreationDate, null); + } } diff --git a/src/Admin/Views/Tools/StripeSubscriptions.cshtml b/src/Admin/Views/Tools/StripeSubscriptions.cshtml index 8ba50072ea..c8900125d2 100644 --- a/src/Admin/Views/Tools/StripeSubscriptions.cshtml +++ b/src/Admin/Views/Tools/StripeSubscriptions.cshtml @@ -6,12 +6,12 @@ @section Scripts {