From c2d36cb28bbf67a891c732f42e2bbdc727dda83a Mon Sep 17 00:00:00 2001 From: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com> Date: Mon, 18 Dec 2023 19:34:56 -0500 Subject: [PATCH 01/29] PM-5340 - Fix bug where new enterprise orgs without an SSO config couldn't invite new users as I was missing null SSO config handling. (#3593) --- .../Implementations/OrganizationService.cs | 2 +- .../Services/OrganizationServiceTests.cs | 49 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index 4a46c42006..e9eca14a96 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -1118,7 +1118,7 @@ public class OrganizationService : IOrganizationService // Determine if org has SSO enabled and if user is required to login with SSO // Note: we only want to call the DB after checking if the org can use SSO per plan and if they have any policies enabled. - var orgSsoEnabled = organization.UseSso && (await _ssoConfigRepository.GetByOrganizationIdAsync(organization.Id)).Enabled; + var orgSsoEnabled = organization.UseSso && (await _ssoConfigRepository.GetByOrganizationIdAsync(organization.Id))?.Enabled == true; // Even though the require SSO policy can be turned on regardless of SSO being enabled, for this logic, we only // need to check the policy if the org has SSO enabled. var orgSsoLoginRequiredPolicyEnabled = orgSsoEnabled && diff --git a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs index dd78133d28..b6b7ac56ca 100644 --- a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs +++ b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs @@ -432,6 +432,55 @@ public class OrganizationServiceTests } + [Theory] + [OrganizationInviteCustomize, BitAutoData] + public async Task InviteUser_SsoOrgWithNullSsoConfig_Passes(Organization organization, OrganizationUser invitor, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + OrganizationUserInvite invite, SutProvider sutProvider) + { + // Setup FakeDataProtectorTokenFactory for creating new tokens - this must come first in order to avoid resetting mocks + sutProvider.SetDependency(_orgUserInviteTokenDataFactory, "orgUserInviteTokenDataFactory"); + sutProvider.Create(); + + // Org must be able to use SSO to trigger this proper test case as we currently only call to retrieve + // an org's SSO config if the org can use SSO + organization.UseSso = true; + + // Return null for sso config + sutProvider.GetDependency().GetByOrganizationIdAsync(organization.Id).ReturnsNull(); + + sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); + sutProvider.GetDependency().OrganizationOwner(organization.Id).Returns(true); + sutProvider.GetDependency().ManageUsers(organization.Id).Returns(true); + var organizationUserRepository = sutProvider.GetDependency(); + organizationUserRepository.GetManyByOrganizationAsync(organization.Id, OrganizationUserType.Owner) + .Returns(new[] { owner }); + + // Must set guids in order for dictionary of guids to not throw aggregate exceptions + SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository); + + // Mock tokenable factory to return a token that expires in 5 days + sutProvider.GetDependency() + .CreateToken(Arg.Any()) + .Returns( + info => new OrgUserInviteTokenable(info.Arg()) + { + ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5)) + } + ); + + + + await sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, new (OrganizationUserInvite, string)[] { (invite, null) }); + + await sutProvider.GetDependency().Received(1) + .SendOrganizationInviteEmailsAsync(Arg.Is(info => + info.OrgUserTokenPairs.Count() == invite.Emails.Distinct().Count() && + info.IsFreeOrg == (organization.PlanType == PlanType.Free) && + info.OrganizationName == organization.Name)); + + } + [Theory] [OrganizationInviteCustomize( InviteeUserType = OrganizationUserType.Admin, From af7811ba9a55c2e738d8d41af1395ed2de0f0d0d Mon Sep 17 00:00:00 2001 From: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Date: Tue, 19 Dec 2023 11:51:46 +1000 Subject: [PATCH 02/29] [AC-1971] Add SwaggerUI to CORS policy (#3583) * Allow SwaggerUI authorize requests if in development --- src/Identity/Startup.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Identity/Startup.cs b/src/Identity/Startup.cs index 9a7874820d..dda5fb03ee 100644 --- a/src/Identity/Startup.cs +++ b/src/Identity/Startup.cs @@ -213,7 +213,11 @@ public class Startup app.UseRouting(); // Add Cors - app.UseCors(policy => policy.SetIsOriginAllowed(o => CoreHelpers.IsCorsOriginAllowed(o, globalSettings)) + app.UseCors(policy => policy.SetIsOriginAllowed(o => + CoreHelpers.IsCorsOriginAllowed(o, globalSettings) || + + // If development - allow requests from the Swagger UI so it can authorize + (Environment.IsDevelopment() && o == globalSettings.BaseServiceUri.Api)) .AllowAnyMethod().AllowAnyHeader().AllowCredentials()); // Add current context From 1b379485a51b97b1b6f137a5d6b5686f930960d5 Mon Sep 17 00:00:00 2001 From: Oscar Hinton Date: Tue, 19 Dec 2023 17:29:04 +0100 Subject: [PATCH 03/29] Add devops prefix to github actions and docker (#3595) --- .github/renovate.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/renovate.json b/.github/renovate.json index aa0ee6d9ce..22d5b78291 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", From 3f1f6b576ae7f98670c8e29e35120d6bb7d61798 Mon Sep 17 00:00:00 2001 From: Opeyemi Date: Tue, 19 Dec 2023 17:05:02 +0000 Subject: [PATCH 04/29] [DEVOPS-1657] - UPDATE: Adds k8s deploy trigger on main branch (#3597) --- .github/workflows/build.yml | 41 +++++++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7cdc0d8bcf..2c3a76e517 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -525,8 +525,7 @@ jobs: 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 +554,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 +601,7 @@ jobs: - upload - build-mssqlmigratorutility - self-host-build + - trigger-k8s-deploy steps: - name: Check if any job failed if: | @@ -583,6 +617,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 +635,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 From ca750e226f2cb5cb31e8d1cf7e8e40ff8e231671 Mon Sep 17 00:00:00 2001 From: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Date: Wed, 20 Dec 2023 09:27:53 +1000 Subject: [PATCH 05/29] Fix ciphers missing collectionId in sync data (#3594) --- src/Api/Vault/Controllers/SyncController.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Api/Vault/Controllers/SyncController.cs b/src/Api/Vault/Controllers/SyncController.cs index cf7a978d87..5835b9ebed 100644 --- a/src/Api/Vault/Controllers/SyncController.cs +++ b/src/Api/Vault/Controllers/SyncController.cs @@ -96,6 +96,7 @@ public class SyncController : Controller { collections = await _collectionRepository.GetManyByUserIdAsync(user.Id, UseFlexibleCollections); var collectionCiphers = await _collectionCipherRepository.GetManyByUserIdAsync(user.Id, UseFlexibleCollections); + collectionCiphersGroupDict = collectionCiphers.GroupBy(c => c.CipherId).ToDictionary(s => s.Key); } var userTwoFactorEnabled = await _userService.TwoFactorIsEnabledAsync(user); From 72ebb5e66f19641fb6da97dc3227e1249f0dcdb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rui=20Tom=C3=A9?= <108268980+r-tome@users.noreply.github.com> Date: Wed, 20 Dec 2023 16:34:09 +0000 Subject: [PATCH 06/29] [AC-1981] Fix CollectionsController.Get auth check by just checking collections for the requested orgId (#3575) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fixed auth check by just checking collections for the requested orgId * [AC-1139] Refactor collection authorization logic to check for manage permission * [AC-1139] Remove unnecessary authorization check in CollectionsController * [AC-1139] Remove unused test method * [AC-1139] Remove unnecessary code for checking read permissions --- src/Api/Controllers/CollectionsController.cs | 17 +------ .../BulkCollectionAuthorizationHandler.cs | 19 ++++--- .../Controllers/CollectionsControllerTests.cs | 50 +++++++++++++++++-- ...BulkCollectionAuthorizationHandlerTests.cs | 7 ++- 4 files changed, 62 insertions(+), 31 deletions(-) diff --git a/src/Api/Controllers/CollectionsController.cs b/src/Api/Controllers/CollectionsController.cs index 39d3c32262..7ae76ba750 100644 --- a/src/Api/Controllers/CollectionsController.cs +++ b/src/Api/Controllers/CollectionsController.cs @@ -584,11 +584,6 @@ public class CollectionsController : Controller // Filter the assigned collections to only return those where the user has Manage permission var manageableOrgCollections = assignedOrgCollections.Where(c => c.Item1.Manage).ToList(); - var readAssignedAuthorized = await _authorizationService.AuthorizeAsync(User, manageableOrgCollections.Select(c => c.Item1), BulkCollectionOperations.ReadWithAccess); - if (!readAssignedAuthorized.Succeeded) - { - throw new NotFoundException(); - } return new ListResponseModel(manageableOrgCollections.Select(c => new CollectionAccessDetailsResponseModel(c.Item1, c.Item2.Groups, c.Item2.Users) @@ -609,16 +604,8 @@ public class CollectionsController : Controller } else { - var collections = await _collectionRepository.GetManyByUserIdAsync(_currentContext.UserId.Value, FlexibleCollectionsIsEnabled); - var readAuthorized = (await _authorizationService.AuthorizeAsync(User, collections, BulkCollectionOperations.Read)).Succeeded; - if (readAuthorized) - { - orgCollections = collections.Where(c => c.OrganizationId == orgId); - } - else - { - throw new NotFoundException(); - } + var assignedCollections = await _collectionRepository.GetManyByUserIdAsync(_currentContext.UserId.Value, FlexibleCollectionsIsEnabled); + orgCollections = assignedCollections.Where(c => c.OrganizationId == orgId && c.Manage).ToList(); } var responses = orgCollections.Select(c => new CollectionResponseModel(c)); diff --git a/src/Api/Vault/AuthorizationHandlers/Collections/BulkCollectionAuthorizationHandler.cs b/src/Api/Vault/AuthorizationHandlers/Collections/BulkCollectionAuthorizationHandler.cs index db44020b05..76544e3b8d 100644 --- a/src/Api/Vault/AuthorizationHandlers/Collections/BulkCollectionAuthorizationHandler.cs +++ b/src/Api/Vault/AuthorizationHandlers/Collections/BulkCollectionAuthorizationHandler.cs @@ -131,8 +131,8 @@ public class BulkCollectionAuthorizationHandler : BulkAuthorizationHandler IsAssignedToCollectionsAsync( + private async Task CanManageCollectionsAsync( ICollection targetCollections, - CurrentContextOrganization org, - bool requireManagePermission) + CurrentContextOrganization org) { // List of collection Ids the acting user has access to var assignedCollectionIds = (await _collectionRepository.GetManyByUserIdAsync(_currentContext.UserId!.Value, useFlexibleCollections: true)) .Where(c => // Check Collections with Manage permission - (!requireManagePermission || c.Manage) && c.OrganizationId == org.Id) + c.Manage && c.OrganizationId == org.Id) .Select(c => c.Id) .ToHashSet(); diff --git a/test/Api.Test/Controllers/CollectionsControllerTests.cs b/test/Api.Test/Controllers/CollectionsControllerTests.cs index 41d877c20e..f8f3b890bb 100644 --- a/test/Api.Test/Controllers/CollectionsControllerTests.cs +++ b/test/Api.Test/Controllers/CollectionsControllerTests.cs @@ -142,7 +142,7 @@ public class CollectionsControllerTests } [Theory, BitAutoData] - public async Task GetOrganizationCollectionsWithGroups_MissingReadPermissions_ThrowsNotFound(Organization organization, Guid userId, SutProvider sutProvider) + public async Task GetOrganizationCollections_WithReadAllPermissions_GetsAllCollections(Organization organization, ICollection collections, Guid userId, SutProvider sutProvider) { sutProvider.GetDependency().UserId.Returns(userId); @@ -152,7 +152,37 @@ public class CollectionsControllerTests Arg.Any(), Arg.Is>(requirements => requirements.Cast().All(operation => - operation.Name == nameof(CollectionOperations.ReadAllWithAccess) + operation.Name == nameof(CollectionOperations.ReadAll) + && operation.OrganizationId == organization.Id))) + .Returns(AuthorizationResult.Success()); + + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(organization.Id) + .Returns(collections); + + var response = await sutProvider.Sut.Get(organization.Id); + + await sutProvider.GetDependency().Received(1).GetManyByOrganizationIdAsync(organization.Id); + + Assert.Equal(collections.Count, response.Data.Count()); + } + + [Theory, BitAutoData] + public async Task GetOrganizationCollections_MissingReadAllPermissions_GetsManageableCollections(Organization organization, ICollection collections, Guid userId, SutProvider sutProvider) + { + collections.First().OrganizationId = organization.Id; + collections.First().Manage = true; + collections.Skip(1).First().OrganizationId = organization.Id; + + sutProvider.GetDependency().UserId.Returns(userId); + + sutProvider.GetDependency() + .AuthorizeAsync( + Arg.Any(), + Arg.Any(), + Arg.Is>(requirements => + requirements.Cast().All(operation => + operation.Name == nameof(CollectionOperations.ReadAll) && operation.OrganizationId == organization.Id))) .Returns(AuthorizationResult.Failed()); @@ -162,10 +192,20 @@ public class CollectionsControllerTests Arg.Any(), Arg.Is>(requirements => requirements.Cast().All(operation => - operation.Name == nameof(BulkCollectionOperations.ReadWithAccess)))) - .Returns(AuthorizationResult.Failed()); + operation.Name == nameof(BulkCollectionOperations.Read)))) + .Returns(AuthorizationResult.Success()); - _ = await Assert.ThrowsAsync(async () => await sutProvider.Sut.GetManyWithDetails(organization.Id)); + sutProvider.GetDependency() + .GetManyByUserIdAsync(userId, true) + .Returns(collections); + + var result = await sutProvider.Sut.Get(organization.Id); + + await sutProvider.GetDependency().DidNotReceive().GetManyByOrganizationIdAsync(organization.Id); + await sutProvider.GetDependency().Received(1).GetManyByUserIdAsync(userId, true); + + Assert.Single(result.Data); + Assert.All(result.Data, c => Assert.Equal(organization.Id, c.OrganizationId)); } [Theory, BitAutoData] diff --git a/test/Api.Test/Vault/AuthorizationHandlers/BulkCollectionAuthorizationHandlerTests.cs b/test/Api.Test/Vault/AuthorizationHandlers/BulkCollectionAuthorizationHandlerTests.cs index 524533aaf6..14e863799b 100644 --- a/test/Api.Test/Vault/AuthorizationHandlers/BulkCollectionAuthorizationHandlerTests.cs +++ b/test/Api.Test/Vault/AuthorizationHandlers/BulkCollectionAuthorizationHandlerTests.cs @@ -204,13 +204,18 @@ public class BulkCollectionAuthorizationHandlerTests } [Theory, BitAutoData, CollectionCustomization] - public async Task CanReadAsync_WhenUserIsAssignedToCollections_Success( + public async Task CanReadAsync_WhenUserCanManageCollections_Success( SutProvider sutProvider, ICollection collections, CurrentContextOrganization organization) { var actingUserId = Guid.NewGuid(); + foreach (var c in collections) + { + c.Manage = true; + } + organization.Type = OrganizationUserType.User; organization.LimitCollectionCreationDeletion = false; organization.Permissions = new Permissions(); From 5785905103001457a50524283fc0974183e358bf Mon Sep 17 00:00:00 2001 From: Matt Bishop Date: Wed, 20 Dec 2023 14:47:14 -0500 Subject: [PATCH 07/29] Fix some bad test parameter names (#3601) --- .../LaunchDarklyFeatureServiceTests.cs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/test/Core.Test/Services/LaunchDarklyFeatureServiceTests.cs b/test/Core.Test/Services/LaunchDarklyFeatureServiceTests.cs index fadea25512..3eb8c25479 100644 --- a/test/Core.Test/Services/LaunchDarklyFeatureServiceTests.cs +++ b/test/Core.Test/Services/LaunchDarklyFeatureServiceTests.cs @@ -12,8 +12,8 @@ namespace Bit.Core.Test.Services; [SutProviderCustomize] public class LaunchDarklyFeatureServiceTests { - private const string _fakeKey = "somekey"; - private const string _fakeValue = "somevalue"; + private const string _fakeFeatureKey = "somekey"; + private const string _fakeSdkKey = "somesdkkey"; private static SutProvider GetSutProvider(IGlobalSettings globalSettings) { @@ -44,46 +44,46 @@ public class LaunchDarklyFeatureServiceTests var currentContext = Substitute.For(); currentContext.UserId.Returns(Guid.NewGuid()); - Assert.False(sutProvider.Sut.IsEnabled(_fakeKey, currentContext)); + Assert.False(sutProvider.Sut.IsEnabled(_fakeFeatureKey, currentContext)); } [Fact(Skip = "For local development")] public void FeatureValue_Boolean() { - var settings = new Settings.GlobalSettings { LaunchDarkly = { SdkKey = _fakeValue } }; + var settings = new Settings.GlobalSettings { LaunchDarkly = { SdkKey = _fakeSdkKey } }; var sutProvider = GetSutProvider(settings); var currentContext = Substitute.For(); currentContext.UserId.Returns(Guid.NewGuid()); - Assert.False(sutProvider.Sut.IsEnabled(_fakeKey, currentContext)); + Assert.False(sutProvider.Sut.IsEnabled(_fakeFeatureKey, currentContext)); } [Fact(Skip = "For local development")] public void FeatureValue_Int() { - var settings = new Settings.GlobalSettings { LaunchDarkly = { SdkKey = _fakeValue } }; + var settings = new Settings.GlobalSettings { LaunchDarkly = { SdkKey = _fakeSdkKey } }; var sutProvider = GetSutProvider(settings); var currentContext = Substitute.For(); currentContext.UserId.Returns(Guid.NewGuid()); - Assert.Equal(0, sutProvider.Sut.GetIntVariation(_fakeKey, currentContext)); + Assert.Equal(0, sutProvider.Sut.GetIntVariation(_fakeFeatureKey, currentContext)); } [Fact(Skip = "For local development")] public void FeatureValue_String() { - var settings = new Settings.GlobalSettings { LaunchDarkly = { SdkKey = _fakeValue } }; + var settings = new Settings.GlobalSettings { LaunchDarkly = { SdkKey = _fakeSdkKey } }; var sutProvider = GetSutProvider(settings); var currentContext = Substitute.For(); currentContext.UserId.Returns(Guid.NewGuid()); - Assert.Null(sutProvider.Sut.GetStringVariation(_fakeKey, currentContext)); + Assert.Null(sutProvider.Sut.GetStringVariation(_fakeFeatureKey, currentContext)); } [Fact(Skip = "For local development")] From 75cae907e82fa2b2afc0ce7517a18d018ec08daa Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Wed, 20 Dec 2023 22:54:45 +0100 Subject: [PATCH 08/29] [AC-1753] Automatically assign provider's pricing to new organizations (#3513) * Initial commit * resolve pr comment * adding some unit test * Resolve pr comments * Adding some unit test * Resolve pr comment * changes to find the bug * revert back changes on admin * Fix the failing Test * fix the bug --- .../AdminConsole/Services/ProviderService.cs | 106 +++++++++++++++++- .../Services/ProviderServiceTests.cs | 95 ++++++++++++++++ .../Providers/ProviderResponseModel.cs | 2 + .../Implementations/OrganizationService.cs | 11 +- src/Core/Constants.cs | 6 + 5 files changed, 206 insertions(+), 14 deletions(-) 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/Api/AdminConsole/Models/Response/Providers/ProviderResponseModel.cs b/src/Api/AdminConsole/Models/Response/Providers/ProviderResponseModel.cs index bc55093a03..a7280fd495 100644 --- a/src/Api/AdminConsole/Models/Response/Providers/ProviderResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/Providers/ProviderResponseModel.cs @@ -21,6 +21,7 @@ public class ProviderResponseModel : ResponseModel BusinessCountry = provider.BusinessCountry; BusinessTaxNumber = provider.BusinessTaxNumber; BillingEmail = provider.BillingEmail; + CreationDate = provider.CreationDate; } public Guid Id { get; set; } @@ -32,4 +33,5 @@ public class ProviderResponseModel : ResponseModel public string BusinessCountry { get; set; } public string BusinessTaxNumber { get; set; } public string BillingEmail { get; set; } + public DateTime CreationDate { get; set; } } diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index e9eca14a96..6961ce71b7 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -1890,11 +1890,6 @@ public class OrganizationService : IOrganizationService public void ValidatePasswordManagerPlan(Models.StaticStore.Plan plan, OrganizationUpgrade upgrade) { - if (plan is not { LegacyYear: null }) - { - throw new BadRequestException("Invalid Password Manager plan selected."); - } - ValidatePlan(plan, upgrade.AdditionalSeats, "Password Manager"); if (plan.PasswordManager.BaseSeats + upgrade.AdditionalSeats <= 0) @@ -2409,12 +2404,8 @@ public class OrganizationService : IOrganizationService public async Task CreatePendingOrganization(Organization organization, string ownerEmail, ClaimsPrincipal user, IUserService userService, bool salesAssistedTrialStarted) { var plan = StaticStore.Plans.FirstOrDefault(p => p.Type == organization.PlanType); - if (plan is not { LegacyYear: null }) - { - throw new BadRequestException("Invalid plan selected."); - } - if (plan.Disabled) + if (plan!.Disabled) { throw new BadRequestException("Plan not found."); } diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 706d6858a6..a71032ea23 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -29,6 +29,12 @@ public static class Constants /// Used by IdentityServer to identify our own provider. /// public const string IdentityProvider = "bitwarden"; + + /// + /// Date identifier used in ProviderService to determine if a provider was created before Nov 6, 2023. + /// If true, the organization plan assigned to that provider is updated to a 2020 plan. + /// + public static readonly DateTime ProviderCreatedPriorNov62023 = new DateTime(2023, 11, 6); } public static class AuthConstants From 73a793bf106a040ea23dc96a63e3c0255a7ac0f1 Mon Sep 17 00:00:00 2001 From: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Date: Thu, 21 Dec 2023 13:53:53 +1000 Subject: [PATCH 09/29] AC Team code ownership moves: AssociationWithPermissions public api model (#3584) --- .../Public/Models}/AssociationWithPermissionsBaseModel.cs | 2 +- .../Models}/Request/AssociationWithPermissionsRequestModel.cs | 2 +- .../Public/Models/Request/GroupCreateUpdateRequestModel.cs | 3 +-- .../Public/Models/Request/MemberUpdateRequestModel.cs | 3 +-- .../Response/AssociationWithPermissionsResponseModel.cs | 2 +- .../AdminConsole/Public/Models/Response/GroupResponseModel.cs | 1 - .../AdminConsole/Public/Models/Response/MemberResponseModel.cs | 1 - src/Api/Models/Public/Request/CollectionUpdateRequestModel.cs | 2 +- src/Api/Models/Public/Response/CollectionResponseModel.cs | 2 +- 9 files changed, 7 insertions(+), 11 deletions(-) rename src/Api/{Auth/Models/Public => AdminConsole/Public/Models}/AssociationWithPermissionsBaseModel.cs (91%) rename src/Api/{Auth/Models/Public => AdminConsole/Public/Models}/Request/AssociationWithPermissionsRequestModel.cs (85%) rename src/Api/{Auth/Models/Public => AdminConsole/Public/Models}/Response/AssociationWithPermissionsResponseModel.cs (88%) diff --git a/src/Api/Auth/Models/Public/AssociationWithPermissionsBaseModel.cs b/src/Api/AdminConsole/Public/Models/AssociationWithPermissionsBaseModel.cs similarity index 91% rename from src/Api/Auth/Models/Public/AssociationWithPermissionsBaseModel.cs rename to src/Api/AdminConsole/Public/Models/AssociationWithPermissionsBaseModel.cs index 18f12fb6f9..9ccc17915d 100644 --- a/src/Api/Auth/Models/Public/AssociationWithPermissionsBaseModel.cs +++ b/src/Api/AdminConsole/Public/Models/AssociationWithPermissionsBaseModel.cs @@ -1,6 +1,6 @@ using System.ComponentModel.DataAnnotations; -namespace Bit.Api.Auth.Models.Public; +namespace Bit.Api.AdminConsole.Public.Models; public abstract class AssociationWithPermissionsBaseModel { diff --git a/src/Api/Auth/Models/Public/Request/AssociationWithPermissionsRequestModel.cs b/src/Api/AdminConsole/Public/Models/Request/AssociationWithPermissionsRequestModel.cs similarity index 85% rename from src/Api/Auth/Models/Public/Request/AssociationWithPermissionsRequestModel.cs rename to src/Api/AdminConsole/Public/Models/Request/AssociationWithPermissionsRequestModel.cs index cac9894b29..a367e8d4e6 100644 --- a/src/Api/Auth/Models/Public/Request/AssociationWithPermissionsRequestModel.cs +++ b/src/Api/AdminConsole/Public/Models/Request/AssociationWithPermissionsRequestModel.cs @@ -1,6 +1,6 @@ using Bit.Core.Models.Data; -namespace Bit.Api.Auth.Models.Public.Request; +namespace Bit.Api.AdminConsole.Public.Models.Request; public class AssociationWithPermissionsRequestModel : AssociationWithPermissionsBaseModel { diff --git a/src/Api/AdminConsole/Public/Models/Request/GroupCreateUpdateRequestModel.cs b/src/Api/AdminConsole/Public/Models/Request/GroupCreateUpdateRequestModel.cs index 01850003d7..c1c7945d10 100644 --- a/src/Api/AdminConsole/Public/Models/Request/GroupCreateUpdateRequestModel.cs +++ b/src/Api/AdminConsole/Public/Models/Request/GroupCreateUpdateRequestModel.cs @@ -1,5 +1,4 @@ -using Bit.Api.Auth.Models.Public.Request; -using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities; namespace Bit.Api.AdminConsole.Public.Models.Request; diff --git a/src/Api/AdminConsole/Public/Models/Request/MemberUpdateRequestModel.cs b/src/Api/AdminConsole/Public/Models/Request/MemberUpdateRequestModel.cs index 18aced4371..0d584af2f2 100644 --- a/src/Api/AdminConsole/Public/Models/Request/MemberUpdateRequestModel.cs +++ b/src/Api/AdminConsole/Public/Models/Request/MemberUpdateRequestModel.cs @@ -1,5 +1,4 @@ -using Bit.Api.Auth.Models.Public.Request; -using Bit.Core.Entities; +using Bit.Core.Entities; namespace Bit.Api.AdminConsole.Public.Models.Request; diff --git a/src/Api/Auth/Models/Public/Response/AssociationWithPermissionsResponseModel.cs b/src/Api/AdminConsole/Public/Models/Response/AssociationWithPermissionsResponseModel.cs similarity index 88% rename from src/Api/Auth/Models/Public/Response/AssociationWithPermissionsResponseModel.cs rename to src/Api/AdminConsole/Public/Models/Response/AssociationWithPermissionsResponseModel.cs index 757eb7ab18..08d9e36d45 100644 --- a/src/Api/Auth/Models/Public/Response/AssociationWithPermissionsResponseModel.cs +++ b/src/Api/AdminConsole/Public/Models/Response/AssociationWithPermissionsResponseModel.cs @@ -1,6 +1,6 @@ using Bit.Core.Models.Data; -namespace Bit.Api.Auth.Models.Public.Response; +namespace Bit.Api.AdminConsole.Public.Models.Response; public class AssociationWithPermissionsResponseModel : AssociationWithPermissionsBaseModel { diff --git a/src/Api/AdminConsole/Public/Models/Response/GroupResponseModel.cs b/src/Api/AdminConsole/Public/Models/Response/GroupResponseModel.cs index b1100ef02c..a2f6899e5e 100644 --- a/src/Api/AdminConsole/Public/Models/Response/GroupResponseModel.cs +++ b/src/Api/AdminConsole/Public/Models/Response/GroupResponseModel.cs @@ -1,5 +1,4 @@ using System.ComponentModel.DataAnnotations; -using Bit.Api.Auth.Models.Public.Response; using Bit.Api.Models.Public.Response; using Bit.Core.AdminConsole.Entities; using Bit.Core.Models.Data; diff --git a/src/Api/AdminConsole/Public/Models/Response/MemberResponseModel.cs b/src/Api/AdminConsole/Public/Models/Response/MemberResponseModel.cs index 4f2ff1c178..7035b64295 100644 --- a/src/Api/AdminConsole/Public/Models/Response/MemberResponseModel.cs +++ b/src/Api/AdminConsole/Public/Models/Response/MemberResponseModel.cs @@ -1,5 +1,4 @@ using System.ComponentModel.DataAnnotations; -using Bit.Api.Auth.Models.Public.Response; using Bit.Api.Models.Public.Response; using Bit.Core.Entities; using Bit.Core.Enums; diff --git a/src/Api/Models/Public/Request/CollectionUpdateRequestModel.cs b/src/Api/Models/Public/Request/CollectionUpdateRequestModel.cs index 8e62c61568..0adc6afa77 100644 --- a/src/Api/Models/Public/Request/CollectionUpdateRequestModel.cs +++ b/src/Api/Models/Public/Request/CollectionUpdateRequestModel.cs @@ -1,4 +1,4 @@ -using Bit.Api.Auth.Models.Public.Request; +using Bit.Api.AdminConsole.Public.Models.Request; using Bit.Core.Entities; namespace Bit.Api.Models.Public.Request; diff --git a/src/Api/Models/Public/Response/CollectionResponseModel.cs b/src/Api/Models/Public/Response/CollectionResponseModel.cs index 4ea747eac0..58968d4be7 100644 --- a/src/Api/Models/Public/Response/CollectionResponseModel.cs +++ b/src/Api/Models/Public/Response/CollectionResponseModel.cs @@ -1,5 +1,5 @@ using System.ComponentModel.DataAnnotations; -using Bit.Api.Auth.Models.Public.Response; +using Bit.Api.AdminConsole.Public.Models.Response; using Bit.Core.Entities; using Bit.Core.Models.Data; From 1cacecefdff150a2ed37b89dab4ed8b56302d587 Mon Sep 17 00:00:00 2001 From: SmithThe4th Date: Thu, 21 Dec 2023 10:13:10 -0500 Subject: [PATCH 10/29] Added user creation date to profile response to be user on onboarding web (#3602) --- src/Api/Models/Response/ProfileResponseModel.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Api/Models/Response/ProfileResponseModel.cs b/src/Api/Models/Response/ProfileResponseModel.cs index b5a32fa326..dc5c9e0a68 100644 --- a/src/Api/Models/Response/ProfileResponseModel.cs +++ b/src/Api/Models/Response/ProfileResponseModel.cs @@ -36,6 +36,7 @@ public class ProfileResponseModel : ResponseModel ForcePasswordReset = user.ForcePasswordReset; UsesKeyConnector = user.UsesKeyConnector; AvatarColor = user.AvatarColor; + CreationDate = user.CreationDate; Organizations = organizationsUserDetails?.Select(o => new ProfileOrganizationResponseModel(o)); Providers = providerUserDetails?.Select(p => new ProfileProviderResponseModel(p)); ProviderOrganizations = @@ -61,6 +62,7 @@ public class ProfileResponseModel : ResponseModel public bool ForcePasswordReset { get; set; } public bool UsesKeyConnector { get; set; } public string AvatarColor { get; set; } + public DateTime CreationDate { get; set; } public IEnumerable Organizations { get; set; } public IEnumerable Providers { get; set; } public IEnumerable ProviderOrganizations { get; set; } From 3bffd0947246f3eefcdeebc6258090f31ff7b977 Mon Sep 17 00:00:00 2001 From: SmithThe4th Date: Thu, 21 Dec 2023 16:03:47 -0500 Subject: [PATCH 11/29] [AC-1741] Include owners/admins can manage all collections setting in license file (#3458) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [AC-1117] Add manage permission (#3126) * Update sql files to add Manage permission * Add migration script * Rename collection manage migration file to remove duplicate migration date * Migrations * Add manage to models * Add manage to repository * Add constraint to Manage columns * Migration lint fixes * Add manage to OrganizationUserUserDetails_ReadWithCollectionsById * Add missing manage fields * Add 'Manage' to UserCollectionDetails * Use CREATE OR ALTER where possible * [AC-1374] Limit collection creation/deletion to Owner/Admin (#3145) * feat: update org table with new column, write migration, refs AC-1374 * feat: update views with new column, refs AC-1374 * feat: Alter sprocs (org create/update) to include new column, refs AC-1374 * feat: update entity/data/request/response models to handle new column, refs AC-1374 * feat: update necessary Provider related views during migration, refs AC-1374 * fix: update org create to default new column to false, refs AC-1374 * feat: added new API/request model for collection management and removed property from update request model, refs AC-1374 * fix: renamed migration script to be after secrets manage beta column changes, refs AC-1374 * fix: dotnet format, refs AC-1374 * feat: add ef migrations to reflect mssql changes, refs AC-1374 * fix: dotnet format, refs AC-1374 * feat: update API signature to accept Guid and explain Cd verbiage, refs AC-1374 * fix: merge conflict resolution * [AC-1174] CollectionUser and CollectionGroup authorization handlers (#3194) * [AC-1174] Introduce BulkAuthorizationHandler.cs * [AC-1174] Introduce CollectionUserAuthorizationHandler * [AC-1174] Add CreateForNewCollection CollectionUser requirement * [AC-1174] Add some more details to CollectionCustomization * [AC-1174] Formatting * [AC-1174] Add CollectionGroupOperation.cs * [AC-1174] Introduce CollectionGroupAuthorizationHandler.cs * [AC-1174] Cleanup CollectionFixture customization Implement and use re-usable extension method to support seeded Guids * [AC-1174] Introduce WithValueFromList AutoFixtureExtensions Modify CollectionCustomization to use multiple organization Ids for auto generated test data * [AC-1174] Simplify CollectionUserAuthorizationHandler.cs Modify the authorization handler to only perform authorization logic. Validation logic will need to be handled by any calling commands/controllers instead. * [AC-1174] Introduce shared CollectionAccessAuthorizationHandlerBase A shared base authorization handler was created for both CollectionUser and CollectionGroup resources, as they share the same underlying management authorization logic. * [AC-1174] Update CollectionUserAuthorizationHandler and CollectionGroupAuthorizationHandler to use the new CollectionAccessAuthorizationHandlerBase class * [AC-1174] Formatting * [AC-1174] Cleanup typo and redundant ToList() call * [AC-1174] Add check for provider users * [AC-1174] Reduce nested loops * [AC-1174] Introduce ICollectionAccess.cs * [AC-1174] Remove individual CollectionGroup and CollectionUser auth handlers and use base class instead * [AC-1174] Tweak unit test to fail minimally * [AC-1174] Reorganize authorization handlers in Core project * [AC-1174] Introduce new AddCoreAuthorizationHandlers() extension method * [AC-1174] Move CollectionAccessAuthorizationHandler into Api project * [AC-1174] Move CollectionFixture to Vault folder * [AC-1174] Rename operation to CreateUpdateDelete * [AC-1174] Require single organization for collection access authorization handler - Add requirement that all target collections must belong to the same organization - Simplify logic related to multiple organizations - Update tests and helpers - Use ToHashSet to improve lookup time * [AC-1174] Fix null reference exception * [AC-1174] Throw bad request exception when collections belong to different organizations * [AC-1174] Switch to CollectionAuthorizationHandler instead of CollectionAccessAuthorizationHandler to reduce complexity * Fix improper merge conflict resolution * fix: add permission check for collection management api, refs AC-1647 (#3252) * [AC-1125] Enforce org setting for creating/deleting collections (#3241) * [AC-1117] Add manage permission (#3126) * Update sql files to add Manage permission * Add migration script * Rename collection manage migration file to remove duplicate migration date * Migrations * Add manage to models * Add manage to repository * Add constraint to Manage columns * Migration lint fixes * Add manage to OrganizationUserUserDetails_ReadWithCollectionsById * Add missing manage fields * Add 'Manage' to UserCollectionDetails * Use CREATE OR ALTER where possible * [AC-1374] Limit collection creation/deletion to Owner/Admin (#3145) * feat: update org table with new column, write migration, refs AC-1374 * feat: update views with new column, refs AC-1374 * feat: Alter sprocs (org create/update) to include new column, refs AC-1374 * feat: update entity/data/request/response models to handle new column, refs AC-1374 * feat: update necessary Provider related views during migration, refs AC-1374 * fix: update org create to default new column to false, refs AC-1374 * feat: added new API/request model for collection management and removed property from update request model, refs AC-1374 * fix: renamed migration script to be after secrets manage beta column changes, refs AC-1374 * fix: dotnet format, refs AC-1374 * feat: add ef migrations to reflect mssql changes, refs AC-1374 * fix: dotnet format, refs AC-1374 * feat: update API signature to accept Guid and explain Cd verbiage, refs AC-1374 * feat: created collection auth handler/operations, added LimitCollectionCdOwnerAdmin to CurrentContentOrganization, refs AC-1125 * feat: create vault service collection extensions and register with base services, refs AC-1125 * feat: deprecated CurrentContext.CreateNewCollections, refs AC-1125 * feat: deprecate DeleteAnyCollection for single resource usages, refs AC-1125 * feat: move service registration to api, update references, refs AC-1125 * feat: add bulk delete authorization handler, refs AC-1125 * feat: always assign user and give manage access on create, refs AC-1125 * fix: updated CurrentContextOrganization type, refs AC-1125 * feat: combined existing collection authorization handlers/operations, refs AC-1125 * fix: OrganizationServiceTests -> CurrentContentOrganization typo, refs AC-1125 * fix: format, refs AC-1125 * fix: update collection controller tests, refs AC-1125 * fix: dotnet format, refs AC-1125 * feat: removed extra BulkAuthorizationHandler, refs AC-1125 * fix: dotnet format, refs AC-1125 * fix: change string to guid for org id, update bulk delete request model, refs AC-1125 * fix: remove delete many collection check, refs AC-1125 * fix: clean up collection auth handler, refs AC-1125 * fix: format fix for CollectionOperations, refs AC-1125 * fix: removed unnecessary owner check, add org null check to custom permission validation, refs AC-1125 * fix: remove unused methods in CurrentContext, refs AC-1125 * fix: removed obsolete test, fixed failling delete many test, refs AC-1125 * fix: CollectionAuthorizationHandlerTests fixes, refs AC-1125 * fix: OrganizationServiceTests fix broken test by mocking GetOrganization, refs AC-1125 * fix: CollectionAuthorizationHandler - remove unused repository, refs AC-1125 * feat: moved UserId null check to common method, refs AC-1125 * fix: updated auth handler tests to remove dependency on requirement for common code checks, refs AC-1125 * feat: updated conditionals/comments for create/delete methods within colleciton auth handler, refs AC-1125 * feat: added create/delete collection auth handler success methods, refs AC-1125 * fix: new up permissions to prevent excessive null checks, refs AC-1125 * fix: remove old reference to CreateNewCollections, refs AC-1125 * fix: typo within ViewAssignedCollections method, refs AC-1125 --------- Co-authored-by: Robyn MacCallum * refactor: remove organizationId from CollectionBulkDeleteRequestModel, refs AC-1649 (#3282) * [AC-1174] Bulk Collection Management (#3229) * [AC-1174] Update SelectionReadOnlyRequestModel to use Guid for Id property * [AC-1174] Introduce initial bulk-access collection endpoint * [AC-1174] Introduce BulkAddCollectionAccessCommand and validation logic/tests * [AC-1174] Add CreateOrUpdateAccessMany method to CollectionRepository * [AC-1174] Add event logs for bulk add collection access command * [AC-1174] Add User_BumpAccountRevisionDateByCollectionIds and database migration script * [AC-1174] Implement EF repository method * [AC-1174] Improve null checks * [AC-1174] Remove unnecessary BulkCollectionAccessRequestModel helpers * [AC-1174] Add unit tests for new controller endpoint * [AC-1174] Fix formatting * [AC-1174] Remove comment * [AC-1174] Remove redundant organizationId parameter * [AC-1174] Ensure user and group Ids are distinct * [AC-1174] Cleanup tests based on PR feedback * [AC-1174] Formatting * [AC-1174] Update CollectionGroup alias in the sproc * [AC-1174] Add some additional comments to SQL sproc * [AC-1174] Add comment explaining additional SaveChangesAsync call --------- Co-authored-by: Thomas Rittson * [AC-1646] Rename LimitCollectionCdOwnerAdmin column (#3300) * Rename LimitCollectionCdOwnerAdmin -> LimitCollectionCreationDeletion * Rename and bump migration script * [AC-1666] Removed EditAnyCollection from Create/Delete permission checks (#3301) * fix: remove EditAnyCollection from Create/Delete permission check, refs AC-1666 * fix: updated comment, refs AC-1666 * [AC-1669] Bug - Remove obsolete assignUserId from CollectionService.SaveAsync(...) (#3312) * fix: remove AssignUserId from CollectionService.SaveAsync, refs AC-1669 * fix: add manage access conditional before creating collection, refs AC-1669 * fix: move access logic for create/update, fix all tests, refs AC-1669 * fix: add CollectionAccessSelection fixture, update tests, update bad reqeuest message, refs AC-1669 * fix: format, refs AC-1669 * fix: update null params with specific arg.is null checks, refs Ac-1669 * fix: update attribute class name, refs AC-1669 * [AC-1713] [Flexible collections] Add feature flags to server (#3334) * Add feature flags for FlexibleCollections and BulkCollectionAccess * Flag new routes and behaviour --------- Co-authored-by: Rui Tomé <108268980+r-tome@users.noreply.github.com> * Add joint codeownership for auth handlers (#3346) * [AC-1717] Update default values for LimitCollectionCreationDeletion (#3365) * Change default value in organization create sproc to 1 * Drop old column name still present in some QA instances * Set LimitCollectionCreationDeletion value in code based on feature flag * Fix: add missing namespace after merging in master * Fix: add missing namespace after merging in master * [AC-1683] Fix DB migrations for new Manage permission (#3307) * [AC-1683] Update migration script and introduce V2 procedures and types * [AC-1683] Update repository calls to use new V2 procedures / types * [AC-1684] Update bulk add collection migration script to use new V2 type * [AC-1683] Undo Manage changes to more original procedures * [AC-1683] Restore whitespace changes * [AC-1683] Clarify comments regarding explicit column lists * [AC-1683] Update migration script dates * [AC-1683] Split the migration script for readability * [AC-1683] Re-name SelectReadOnlyArray_V2 to CollectionAccessSelectionType * [AC-1648] [Flexible Collections] Bump migration scripts before feature branch merge (#3371) * Bump dates on sql migration scripts * Bump date on ef migrations * [AC-1727] Add AllowAdminAccessToAllCollectionItems column to Organization table * [AC-1720] Update stored procedures and views that query the organization table and new column * [AC-1727] Add EF migrations for new DB column * [AC-1729] Update API request/response models * [AC-1122] Add new setting to CurrentContextOrganization.cs * [AC-1122] Ensure new setting is disabled for new orgs when the feature flag is enabled * [AC-1122] Use V1 feature flag for new setting * added property to organization license, incremented version number * added property to organization license, incremented version number * Added property to the SignUpAsync * Updated UpdateFromLicense to update proprty on the org * Updated endpoint to allow only cloud access * removed file added mistakenly, and increased licence version * updated test fixture * updated test fixture * linter fix * updated json string with correct hash * added the v1 feature flag check --------- Co-authored-by: Robyn MacCallum Co-authored-by: Vincent Salucci <26154748+vincentsalucci@users.noreply.github.com> Co-authored-by: Vincent Salucci Co-authored-by: Shane Melton Co-authored-by: Thomas Rittson Co-authored-by: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Co-authored-by: Rui Tomé <108268980+r-tome@users.noreply.github.com> --- src/Core/AdminConsole/Entities/Organization.cs | 8 ++++++-- .../Services/Implementations/OrganizationService.cs | 7 +++++-- src/Core/Models/Business/OrganizationLicense.cs | 12 ++++++++++-- .../UpdateOrganizationLicenseCommand.cs | 5 +++-- .../Business/OrganizationLicenseFileFixtures.cs | 8 ++++++-- 5 files changed, 30 insertions(+), 10 deletions(-) diff --git a/src/Core/AdminConsole/Entities/Organization.cs b/src/Core/AdminConsole/Entities/Organization.cs index 14f403d2b7..1e51dbaaaf 100644 --- a/src/Core/AdminConsole/Entities/Organization.cs +++ b/src/Core/AdminConsole/Entities/Organization.cs @@ -236,7 +236,10 @@ public class Organization : ITableObject, ISubscriber, IStorable, IStorabl return providers[provider]; } - public void UpdateFromLicense(OrganizationLicense license, bool flexibleCollectionsIsEnabled) + public void UpdateFromLicense( + OrganizationLicense license, + bool flexibleCollectionsMvpIsEnabled, + bool flexibleCollectionsV1IsEnabled) { Name = license.Name; BusinessName = license.BusinessName; @@ -267,6 +270,7 @@ public class Organization : ITableObject, ISubscriber, IStorable, IStorabl UseSecretsManager = license.UseSecretsManager; SmSeats = license.SmSeats; SmServiceAccounts = license.SmServiceAccounts; - LimitCollectionCreationDeletion = !flexibleCollectionsIsEnabled || license.LimitCollectionCreationDeletion; + LimitCollectionCreationDeletion = !flexibleCollectionsMvpIsEnabled || license.LimitCollectionCreationDeletion; + AllowAdminAccessToAllCollectionItems = !flexibleCollectionsV1IsEnabled || license.AllowAdminAccessToAllCollectionItems; } } diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index 6961ce71b7..a7f4f057d8 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -558,8 +558,10 @@ public class OrganizationService : IOrganizationService await ValidateSignUpPoliciesAsync(owner.Id); - var flexibleCollectionsIsEnabled = + var flexibleCollectionsMvpIsEnabled = _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections, _currentContext); + var flexibleCollectionsV1IsEnabled = + _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1, _currentContext); var organization = new Organization { @@ -601,7 +603,8 @@ public class OrganizationService : IOrganizationService UseSecretsManager = license.UseSecretsManager, SmSeats = license.SmSeats, SmServiceAccounts = license.SmServiceAccounts, - LimitCollectionCreationDeletion = !flexibleCollectionsIsEnabled || license.LimitCollectionCreationDeletion + LimitCollectionCreationDeletion = !flexibleCollectionsMvpIsEnabled || license.LimitCollectionCreationDeletion, + AllowAdminAccessToAllCollectionItems = !flexibleCollectionsV1IsEnabled || license.AllowAdminAccessToAllCollectionItems }; var result = await SignUpAsync(organization, owner.Id, ownerKey, collectionName, false); diff --git a/src/Core/Models/Business/OrganizationLicense.cs b/src/Core/Models/Business/OrganizationLicense.cs index d00b43d399..764cb31aa2 100644 --- a/src/Core/Models/Business/OrganizationLicense.cs +++ b/src/Core/Models/Business/OrganizationLicense.cs @@ -53,6 +53,7 @@ public class OrganizationLicense : ILicense SmSeats = org.SmSeats; SmServiceAccounts = org.SmServiceAccounts; LimitCollectionCreationDeletion = org.LimitCollectionCreationDeletion; + AllowAdminAccessToAllCollectionItems = org.AllowAdminAccessToAllCollectionItems; if (subscriptionInfo?.Subscription == null) { @@ -137,6 +138,7 @@ public class OrganizationLicense : ILicense public int? SmSeats { get; set; } public int? SmServiceAccounts { get; set; } public bool LimitCollectionCreationDeletion { get; set; } = true; + public bool AllowAdminAccessToAllCollectionItems { get; set; } = true; public bool Trial { get; set; } public LicenseType? LicenseType { get; set; } public string Hash { get; set; } @@ -148,10 +150,10 @@ public class OrganizationLicense : ILicense /// /// Intentionally set one version behind to allow self hosted users some time to update before /// getting out of date license errors - public const int CurrentLicenseFileVersion = 13; + public const int CurrentLicenseFileVersion = 14; private bool ValidLicenseVersion { - get => Version is >= 1 and <= 14; + get => Version is >= 1 and <= 15; } public byte[] GetDataBytes(bool forHash = false) @@ -194,6 +196,8 @@ public class OrganizationLicense : ILicense (Version >= 13 || !p.Name.Equals(nameof(SmServiceAccounts))) && // LimitCollectionCreationDeletion was added in Version 14 (Version >= 14 || !p.Name.Equals(nameof(LimitCollectionCreationDeletion))) && + // AllowAdminAccessToAllCollectionItems was added in Version 15 + (Version >= 15 || !p.Name.Equals(nameof(AllowAdminAccessToAllCollectionItems))) && ( !forHash || ( @@ -347,6 +351,10 @@ public class OrganizationLicense : ILicense // { // valid = organization.LimitCollectionCreationDeletion == LimitCollectionCreationDeletion; // } + // if (valid && Version >= 15) + // { + // valid = organization.AllowAdminAccessToAllCollectionItems == AllowAdminAccessToAllCollectionItems; + // } return valid; } diff --git a/src/Core/OrganizationFeatures/OrganizationLicenses/UpdateOrganizationLicenseCommand.cs b/src/Core/OrganizationFeatures/OrganizationLicenses/UpdateOrganizationLicenseCommand.cs index 8979324ccb..ac2e1b1012 100644 --- a/src/Core/OrganizationFeatures/OrganizationLicenses/UpdateOrganizationLicenseCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationLicenses/UpdateOrganizationLicenseCommand.cs @@ -65,9 +65,10 @@ public class UpdateOrganizationLicenseCommand : IUpdateOrganizationLicenseComman private async Task UpdateOrganizationAsync(SelfHostedOrganizationDetails selfHostedOrganizationDetails, OrganizationLicense license) { - var flexibleCollectionsIsEnabled = _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections, _currentContext); + var flexibleCollectionsMvpIsEnabled = _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections, _currentContext); + var flexibleCollectionsV1IsEnabled = _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1, _currentContext); var organization = selfHostedOrganizationDetails.ToOrganization(); - organization.UpdateFromLicense(license, flexibleCollectionsIsEnabled); + organization.UpdateFromLicense(license, flexibleCollectionsMvpIsEnabled, flexibleCollectionsV1IsEnabled); await _organizationService.ReplaceAndUpdateCacheAsync(organization); } diff --git a/test/Core.Test/Models/Business/OrganizationLicenseFileFixtures.cs b/test/Core.Test/Models/Business/OrganizationLicenseFileFixtures.cs index b00d0b377a..2e058ab335 100644 --- a/test/Core.Test/Models/Business/OrganizationLicenseFileFixtures.cs +++ b/test/Core.Test/Models/Business/OrganizationLicenseFileFixtures.cs @@ -24,7 +24,10 @@ public static class OrganizationLicenseFileFixtures private const string Version14 = "{\n 'LicenseKey': 'myLicenseKey',\n 'InstallationId': '78900000-0000-0000-0000-000000000123',\n 'Id': '12300000-0000-0000-0000-000000000456',\n 'Name': 'myOrg',\n 'BillingEmail': 'myBillingEmail',\n 'BusinessName': 'myBusinessName',\n 'Enabled': true,\n 'Plan': 'myPlan',\n 'PlanType': 11,\n 'Seats': 10,\n 'MaxCollections': 2,\n 'UsePolicies': true,\n 'UseSso': true,\n 'UseKeyConnector': true,\n 'UseScim': true,\n 'UseGroups': true,\n 'UseEvents': true,\n 'UseDirectory': true,\n 'UseTotp': true,\n 'Use2fa': true,\n 'UseApi': true,\n 'UseResetPassword': true,\n 'MaxStorageGb': 100,\n 'SelfHost': true,\n 'UsersGetPremium': true,\n 'UseCustomPermissions': true,\n 'Version': 13,\n 'Issued': '2023-11-29T22:42:33.970597Z',\n 'Refresh': '2023-12-06T22:42:33.970597Z',\n 'Expires': '2023-12-06T22:42:33.970597Z',\n 'ExpirationWithoutGracePeriod': null,\n 'UsePasswordManager': true,\n 'UseSecretsManager': true,\n 'SmSeats': 5,\n 'SmServiceAccounts': 8,\n 'LimitCollectionCreationDeletion': true,\n 'Trial': true,\n 'LicenseType': 1,\n 'Hash': '4G2u\\u002BWKO9EOiVnDVNr7uPxxRkv7TtaOmDl7kAYH05Fw=',\n 'Signature': ''\n}"; - private static readonly Dictionary LicenseVersions = new() { { 12, Version12 }, { 13, Version13 }, { 14, Version14 } }; + private const string Version15 = + "{\n 'LicenseKey': 'myLicenseKey',\n 'InstallationId': '78900000-0000-0000-0000-000000000123',\n 'Id': '12300000-0000-0000-0000-000000000456',\n 'Name': 'myOrg',\n 'BillingEmail': 'myBillingEmail',\n 'BusinessName': 'myBusinessName',\n 'Enabled': true,\n 'Plan': 'myPlan',\n 'PlanType': 11,\n 'Seats': 10,\n 'MaxCollections': 2,\n 'UsePolicies': true,\n 'UseSso': true,\n 'UseKeyConnector': true,\n 'UseScim': true,\n 'UseGroups': true,\n 'UseEvents': true,\n 'UseDirectory': true,\n 'UseTotp': true,\n 'Use2fa': true,\n 'UseApi': true,\n 'UseResetPassword': true,\n 'MaxStorageGb': 100,\n 'SelfHost': true,\n 'UsersGetPremium': true,\n 'UseCustomPermissions': true,\n 'Version': 14,\n 'Issued': '2023-12-14T02:03:33.374297Z',\n 'Refresh': '2023-12-07T22:42:33.970597Z',\n 'Expires': '2023-12-21T02:03:33.374297Z',\n 'ExpirationWithoutGracePeriod': null,\n 'UsePasswordManager': true,\n 'UseSecretsManager': true,\n 'SmSeats': 5,\n 'SmServiceAccounts': 8,\n 'LimitCollectionCreationDeletion': true,\n 'AllowAdminAccessToAllCollectionItems': true,\n 'Trial': true,\n 'LicenseType': 1,\n 'Hash': 'EZl4IvJaa1E5mPmlfp4p5twAtlmaxlF1yoZzVYP4vog=',\n 'Signature': ''\n}"; + + private static readonly Dictionary LicenseVersions = new() { { 12, Version12 }, { 13, Version13 }, { 14, Version14 }, { 15, Version15 } }; public static OrganizationLicense GetVersion(int licenseVersion) { @@ -108,6 +111,7 @@ public static class OrganizationLicenseFileFixtures MaxAutoscaleSmSeats = 101, MaxAutoscaleSmServiceAccounts = 102, SecretsManagerBeta = true, - LimitCollectionCreationDeletion = true + LimitCollectionCreationDeletion = true, + AllowAdminAccessToAllCollectionItems = true, }; } From cedbea4a6075fab11f140f717f1a2ced6ba283b4 Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Thu, 21 Dec 2023 22:10:14 +0100 Subject: [PATCH 12/29] [AC-85] Set Max Seats Autoscale and Current Seats via Public API (#3389) * Add new public models and controllers * Resolve pr comments * Fix the failing test * Change the controller name * resolve pr comments * add the IValidatableObject * resolve pr comment * resolve pr comments * resolve pr comments * resolve * removing the whitespaces * code refactoring --- .../Controllers/OrganizationController.cs | 81 ++++++++++ ...anizationSubscriptionUpdateRequestModel.cs | 138 ++++++++++++++++++ 2 files changed, 219 insertions(+) create mode 100644 src/Api/Billing/Public/Controllers/OrganizationController.cs create mode 100644 src/Api/Billing/Public/Models/OrganizationSubscriptionUpdateRequestModel.cs diff --git a/src/Api/Billing/Public/Controllers/OrganizationController.cs b/src/Api/Billing/Public/Controllers/OrganizationController.cs new file mode 100644 index 0000000000..294165a7e8 --- /dev/null +++ b/src/Api/Billing/Public/Controllers/OrganizationController.cs @@ -0,0 +1,81 @@ +using System.Net; +using Bit.Api.Models.Public.Response; +using Bit.Core.Context; +using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Utilities; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using OrganizationSubscriptionUpdateRequestModel = Bit.Api.Billing.Public.Models.OrganizationSubscriptionUpdateRequestModel; + +namespace Bit.Api.Billing.Public.Controllers; + +[Route("public/organization")] +[Authorize("Organization")] +public class OrganizationController : Controller +{ + private readonly IOrganizationService _organizationService; + private readonly ICurrentContext _currentContext; + private readonly IOrganizationRepository _organizationRepository; + private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand; + + public OrganizationController( + IOrganizationService organizationService, + ICurrentContext currentContext, + IOrganizationRepository organizationRepository, + IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand) + { + _organizationService = organizationService; + _currentContext = currentContext; + _organizationRepository = organizationRepository; + _updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand; + } + + /// + /// Update the organization's current subscription for Password Manager and/or Secrets Manager. + /// + /// The request model containing the updated subscription information. + [HttpPut("subscription")] + [SelfHosted(NotSelfHostedOnly = true)] + [ProducesResponseType((int)HttpStatusCode.OK)] + [ProducesResponseType(typeof(ErrorResponseModel), (int)HttpStatusCode.BadRequest)] + [ProducesResponseType((int)HttpStatusCode.NotFound)] + public async Task PostSubscriptionAsync([FromBody] OrganizationSubscriptionUpdateRequestModel model) + { + + await UpdatePasswordManagerAsync(model, _currentContext.OrganizationId.Value); + + await UpdateSecretsManagerAsync(model, _currentContext.OrganizationId.Value); + + return new OkResult(); + } + + private async Task UpdatePasswordManagerAsync(OrganizationSubscriptionUpdateRequestModel model, Guid organizationId) + { + if (model.PasswordManager != null) + { + var organization = await _organizationRepository.GetByIdAsync(organizationId); + + model.PasswordManager.ToPasswordManagerSubscriptionUpdate(organization); + await _organizationService.UpdateSubscription(organization.Id, (int)model.PasswordManager.Seats, + model.PasswordManager.MaxAutoScaleSeats); + if (model.PasswordManager.Storage.HasValue) + { + await _organizationService.AdjustStorageAsync(organization.Id, (short)model.PasswordManager.Storage); + } + } + } + + private async Task UpdateSecretsManagerAsync(OrganizationSubscriptionUpdateRequestModel model, Guid organizationId) + { + if (model.SecretsManager != null) + { + var organization = + await _organizationRepository.GetByIdAsync(organizationId); + + var organizationUpdate = model.SecretsManager.ToSecretsManagerSubscriptionUpdate(organization); + await _updateSecretsManagerSubscriptionCommand.UpdateSubscriptionAsync(organizationUpdate); + } + } +} diff --git a/src/Api/Billing/Public/Models/OrganizationSubscriptionUpdateRequestModel.cs b/src/Api/Billing/Public/Models/OrganizationSubscriptionUpdateRequestModel.cs new file mode 100644 index 0000000000..781ad3ca53 --- /dev/null +++ b/src/Api/Billing/Public/Models/OrganizationSubscriptionUpdateRequestModel.cs @@ -0,0 +1,138 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Models.Business; + +namespace Bit.Api.Billing.Public.Models; + +public class OrganizationSubscriptionUpdateRequestModel : IValidatableObject +{ + public PasswordManagerSubscriptionUpdateModel PasswordManager { get; set; } + public SecretsManagerSubscriptionUpdateModel SecretsManager { get; set; } + + public IEnumerable Validate(ValidationContext validationContext) + { + if (PasswordManager == null && SecretsManager == null) + { + yield return new ValidationResult("At least one of PasswordManager or SecretsManager must be provided."); + } + + yield return ValidationResult.Success; + } +} + +public class PasswordManagerSubscriptionUpdateModel +{ + public int? Seats { get; set; } + public int? Storage { get; set; } + private int? _maxAutoScaleSeats; + public int? MaxAutoScaleSeats + { + get { return _maxAutoScaleSeats; } + set { _maxAutoScaleSeats = value < 0 ? null : value; } + } + + public virtual void ToPasswordManagerSubscriptionUpdate(Organization organization) + { + UpdateMaxAutoScaleSeats(organization); + + UpdateSeats(organization); + + UpdateStorage(organization); + } + + private void UpdateMaxAutoScaleSeats(Organization organization) + { + MaxAutoScaleSeats ??= organization.MaxAutoscaleSeats; + } + + private void UpdateSeats(Organization organization) + { + if (Seats is > 0) + { + if (organization.Seats.HasValue) + { + Seats = Seats.Value - organization.Seats.Value; + } + } + else + { + Seats = 0; + } + } + + private void UpdateStorage(Organization organization) + { + if (Storage is > 0) + { + if (organization.MaxStorageGb.HasValue) + { + Storage = (short?)(Storage - organization.MaxStorageGb.Value); + } + } + else + { + Storage = null; + } + } +} + +public class SecretsManagerSubscriptionUpdateModel +{ + public int? Seats { get; set; } + private int? _maxAutoScaleSeats; + public int? MaxAutoScaleSeats + { + get { return _maxAutoScaleSeats; } + set { _maxAutoScaleSeats = value < 0 ? null : value; } + } + public int? ServiceAccounts { get; set; } + private int? _maxAutoScaleServiceAccounts; + public int? MaxAutoScaleServiceAccounts + { + get { return _maxAutoScaleServiceAccounts; } + set { _maxAutoScaleServiceAccounts = value < 0 ? null : value; } + } + + public virtual SecretsManagerSubscriptionUpdate ToSecretsManagerSubscriptionUpdate(Organization organization) + { + var update = UpdateUpdateMaxAutoScale(organization); + UpdateSeats(organization, update); + UpdateServiceAccounts(organization, update); + return update; + } + + private SecretsManagerSubscriptionUpdate UpdateUpdateMaxAutoScale(Organization organization) + { + var update = new SecretsManagerSubscriptionUpdate(organization, false) + { + MaxAutoscaleSmSeats = MaxAutoScaleSeats ?? organization.MaxAutoscaleSmSeats, + MaxAutoscaleSmServiceAccounts = MaxAutoScaleServiceAccounts ?? organization.MaxAutoscaleSmServiceAccounts + }; + return update; + } + + private void UpdateSeats(Organization organization, SecretsManagerSubscriptionUpdate update) + { + if (Seats is > 0) + { + if (organization.SmSeats.HasValue) + { + Seats = Seats.Value - organization.SmSeats.Value; + + } + update.AdjustSeats(Seats.Value); + } + } + + private void UpdateServiceAccounts(Organization organization, SecretsManagerSubscriptionUpdate update) + { + if (ServiceAccounts is > 0) + { + if (organization.SmServiceAccounts.HasValue) + { + ServiceAccounts = ServiceAccounts.Value - organization.SmServiceAccounts.Value; + } + update.AdjustServiceAccounts(ServiceAccounts.Value); + } + } +} From 506d0aa318f9dfa46e410d2cb55952b476af1afd Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Fri, 22 Dec 2023 20:28:07 +0100 Subject: [PATCH 13/29] [AC-2000] Get 400 response code when a secrets manager is not enabled for Organisation while password Manager is Updated (#3612) * fix the bug * resolve qa comments --- .../Controllers/OrganizationController.cs | 39 ++++++++++++++----- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/src/Api/Billing/Public/Controllers/OrganizationController.cs b/src/Api/Billing/Public/Controllers/OrganizationController.cs index 294165a7e8..22d5627643 100644 --- a/src/Api/Billing/Public/Controllers/OrganizationController.cs +++ b/src/Api/Billing/Public/Controllers/OrganizationController.cs @@ -43,12 +43,23 @@ public class OrganizationController : Controller [ProducesResponseType((int)HttpStatusCode.NotFound)] public async Task PostSubscriptionAsync([FromBody] OrganizationSubscriptionUpdateRequestModel model) { + try + { + await UpdatePasswordManagerAsync(model, _currentContext.OrganizationId.Value); - await UpdatePasswordManagerAsync(model, _currentContext.OrganizationId.Value); + var secretsManagerResult = await UpdateSecretsManagerAsync(model, _currentContext.OrganizationId.Value); - await UpdateSecretsManagerAsync(model, _currentContext.OrganizationId.Value); + if (!string.IsNullOrEmpty(secretsManagerResult)) + { + return Ok(new { Message = secretsManagerResult }); + } - return new OkResult(); + return Ok(new { Message = "Subscription updated successfully." }); + } + catch (Exception ex) + { + return StatusCode(500, new { Message = "An error occurred while updating the subscription." }); + } } private async Task UpdatePasswordManagerAsync(OrganizationSubscriptionUpdateRequestModel model, Guid organizationId) @@ -67,15 +78,23 @@ public class OrganizationController : Controller } } - private async Task UpdateSecretsManagerAsync(OrganizationSubscriptionUpdateRequestModel model, Guid organizationId) + private async Task UpdateSecretsManagerAsync(OrganizationSubscriptionUpdateRequestModel model, Guid organizationId) { - if (model.SecretsManager != null) + if (model.SecretsManager == null) { - var organization = - await _organizationRepository.GetByIdAsync(organizationId); - - var organizationUpdate = model.SecretsManager.ToSecretsManagerSubscriptionUpdate(organization); - await _updateSecretsManagerSubscriptionCommand.UpdateSubscriptionAsync(organizationUpdate); + return string.Empty; } + + var organization = await _organizationRepository.GetByIdAsync(organizationId); + + if (!organization.UseSecretsManager) + { + return "Organization has no access to Secrets Manager."; + } + + var secretsManagerUpdate = model.SecretsManager.ToSecretsManagerSubscriptionUpdate(organization); + await _updateSecretsManagerSubscriptionCommand.UpdateSubscriptionAsync(secretsManagerUpdate); + + return string.Empty; } } From cf4d8a4f9243e969fe13a360e6331887d3d04aac Mon Sep 17 00:00:00 2001 From: Todd Martin <106564991+trmartin4@users.noreply.github.com> Date: Fri, 22 Dec 2023 15:12:27 -0500 Subject: [PATCH 14/29] [PM-2740] Add null check on base64-encoded values on knowndevice query (#3586) * Added null check on header-based knowndevice call to match query-string implementation. * Updated to use model binding instead of individual inputs. * Linting. --- src/Api/Controllers/DevicesController.cs | 9 ++++----- .../Models/Request/KnownDeviceRequestModel.cs | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 5 deletions(-) create mode 100644 src/Api/Models/Request/KnownDeviceRequestModel.cs diff --git a/src/Api/Controllers/DevicesController.cs b/src/Api/Controllers/DevicesController.cs index b462e51df2..6787fe515c 100644 --- a/src/Api/Controllers/DevicesController.cs +++ b/src/Api/Controllers/DevicesController.cs @@ -1,4 +1,5 @@ -using Bit.Api.Auth.Models.Request; +using Api.Models.Request; +using Bit.Api.Auth.Models.Request; using Bit.Api.Auth.Models.Request.Accounts; using Bit.Api.Models.Request; using Bit.Api.Models.Response; @@ -206,10 +207,8 @@ public class DevicesController : Controller [AllowAnonymous] [HttpGet("knowndevice")] - public async Task GetByIdentifierQuery( - [FromHeader(Name = "X-Request-Email")] string email, - [FromHeader(Name = "X-Device-Identifier")] string deviceIdentifier) - => await GetByIdentifier(CoreHelpers.Base64UrlDecodeString(email), deviceIdentifier); + public async Task GetByIdentifierQuery([FromHeader] KnownDeviceRequestModel request) + => await GetByIdentifier(CoreHelpers.Base64UrlDecodeString(request.Email), request.DeviceIdentifier); [Obsolete("Path is deprecated due to encoding issues, use /knowndevice instead.")] [AllowAnonymous] diff --git a/src/Api/Models/Request/KnownDeviceRequestModel.cs b/src/Api/Models/Request/KnownDeviceRequestModel.cs new file mode 100644 index 0000000000..8232f596af --- /dev/null +++ b/src/Api/Models/Request/KnownDeviceRequestModel.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Mvc; + +namespace Api.Models.Request; + +public class KnownDeviceRequestModel +{ + [Required] + [FromHeader(Name = "X-Request-Email")] + public string Email { get; set; } + + [Required] + [FromHeader(Name = "X-Device-Identifier")] + public string DeviceIdentifier { get; set; } + +} From c60f260c0f8e2da0cf0f38de1c54f3e0381ba69a Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Wed, 27 Dec 2023 09:30:23 -0500 Subject: [PATCH 15/29] [AC-1754] Provide upgrade flow for paid organizations (#3468) * wip * Add CompleteSubscriptionUpdate * Add AdjustSubscription to PaymentService * Use PaymentService.AdjustSubscription in UpgradeOrganizationPlanCommand * Add CompleteSubscriptionUpdateTests * Remove unused changes * Update UpgradeOrganizationPlanCommandTests * Fixing missing usings after master merge * Defects: AC-1958, AC-1959 * Allow user to unsubscribe from Secrets Manager and Storage during upgrade * Handled null exception when upgrading away from a plan that doesn't allow secrets manager * Resolved issue where Teams Starter couldn't increase storage --------- Co-authored-by: Conner Turnbull Co-authored-by: Conner Turnbull <133619638+cturnbull-bitwarden@users.noreply.github.com> --- .../Business/CompleteSubscriptionUpdate.cs | 329 +++++++++++ .../Models/Business/SubscriptionUpdate.cs | 2 +- .../StaticStore/Plans/Enterprise2019Plan.cs | 4 +- .../StaticStore/Plans/Enterprise2020Plan.cs | 4 +- .../StaticStore/Plans/EnterprisePlan.cs | 4 +- .../Models/StaticStore/Plans/Teams2019Plan.cs | 4 +- .../Models/StaticStore/Plans/Teams2020Plan.cs | 4 +- .../Models/StaticStore/Plans/TeamsPlan.cs | 4 +- .../StaticStore/Plans/TeamsStarterPlan.cs | 1 + .../UpgradeOrganizationPlanCommand.cs | 17 +- src/Core/Services/IPaymentService.cs | 9 + .../Implementations/StripePaymentService.cs | 25 +- .../AutoFixture/OrganizationFixtures.cs | 44 ++ .../CompleteSubscriptionUpdateTests.cs | 530 ++++++++++++++++++ .../UpgradeOrganizationPlanCommandTests.cs | 59 +- 15 files changed, 995 insertions(+), 45 deletions(-) create mode 100644 src/Core/Models/Business/CompleteSubscriptionUpdate.cs create mode 100644 test/Core.Test/Models/Business/CompleteSubscriptionUpdateTests.cs diff --git a/src/Core/Models/Business/CompleteSubscriptionUpdate.cs b/src/Core/Models/Business/CompleteSubscriptionUpdate.cs new file mode 100644 index 0000000000..a1146cd2a0 --- /dev/null +++ b/src/Core/Models/Business/CompleteSubscriptionUpdate.cs @@ -0,0 +1,329 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Stripe; + +namespace Bit.Core.Models.Business; + +/// +/// A model representing the data required to upgrade from one subscription to another using a . +/// +public class SubscriptionData +{ + public StaticStore.Plan Plan { get; init; } + public int PurchasedPasswordManagerSeats { get; init; } + public bool SubscribedToSecretsManager { get; set; } + public int? PurchasedSecretsManagerSeats { get; init; } + public int? PurchasedAdditionalSecretsManagerServiceAccounts { get; init; } + public int PurchasedAdditionalStorage { get; init; } +} + +public class CompleteSubscriptionUpdate : SubscriptionUpdate +{ + private readonly SubscriptionData _currentSubscription; + private readonly SubscriptionData _updatedSubscription; + + private readonly Dictionary _subscriptionUpdateMap = new(); + + private enum SubscriptionUpdateType + { + PasswordManagerSeats, + SecretsManagerSeats, + SecretsManagerServiceAccounts, + Storage + } + + /// + /// A model used to generate the Stripe + /// necessary to both upgrade an organization's subscription and revert that upgrade + /// in the case of an error. + /// + /// The to upgrade. + /// The updates you want to apply to the organization's subscription. + public CompleteSubscriptionUpdate( + Organization organization, + SubscriptionData updatedSubscription) + { + _currentSubscription = GetSubscriptionDataFor(organization); + _updatedSubscription = updatedSubscription; + } + + protected override List PlanIds => new() + { + GetPasswordManagerPlanId(_updatedSubscription.Plan), + _updatedSubscription.Plan.SecretsManager.StripeSeatPlanId, + _updatedSubscription.Plan.SecretsManager.StripeServiceAccountPlanId, + _updatedSubscription.Plan.PasswordManager.StripeStoragePlanId + }; + + /// + /// Generates the necessary to revert an 's + /// upgrade in the case of an error. + /// + /// The organization's . + public override List RevertItemsOptions(Subscription subscription) + { + var subscriptionItemOptions = new List + { + GetPasswordManagerOptions(subscription, _updatedSubscription, _currentSubscription) + }; + + if (_updatedSubscription.SubscribedToSecretsManager || _currentSubscription.SubscribedToSecretsManager) + { + subscriptionItemOptions.Add(GetSecretsManagerOptions(subscription, _updatedSubscription, _currentSubscription)); + + if (_updatedSubscription.PurchasedAdditionalSecretsManagerServiceAccounts != 0 || + _currentSubscription.PurchasedAdditionalSecretsManagerServiceAccounts != 0) + { + subscriptionItemOptions.Add(GetServiceAccountsOptions(subscription, _updatedSubscription, _currentSubscription)); + } + } + + if (_updatedSubscription.PurchasedAdditionalStorage != 0 || _currentSubscription.PurchasedAdditionalStorage != 0) + { + subscriptionItemOptions.Add(GetStorageOptions(subscription, _updatedSubscription, _currentSubscription)); + } + + return subscriptionItemOptions; + } + + /* + * This is almost certainly overkill. If we trust the data in the Vault DB, we should just be able to + * compare the _currentSubscription against the _updatedSubscription to see if there are any differences. + * However, for the sake of ensuring we're checking against the Stripe subscription itself, I'll leave this + * included for now. + */ + /// + /// Checks whether the updates provided in the 's constructor + /// are actually different than the organization's current . + /// + /// The organization's . + public override bool UpdateNeeded(Subscription subscription) + { + var upgradeItemsOptions = UpgradeItemsOptions(subscription); + + foreach (var subscriptionItemOptions in upgradeItemsOptions) + { + var success = _subscriptionUpdateMap.TryGetValue(subscriptionItemOptions.Price, out var updateType); + + if (!success) + { + return false; + } + + var updateNeeded = updateType switch + { + SubscriptionUpdateType.PasswordManagerSeats => ContainsUpdatesBetween( + GetPasswordManagerPlanId(_currentSubscription.Plan), + subscriptionItemOptions), + SubscriptionUpdateType.SecretsManagerSeats => ContainsUpdatesBetween( + _currentSubscription.Plan.SecretsManager.StripeSeatPlanId, + subscriptionItemOptions), + SubscriptionUpdateType.SecretsManagerServiceAccounts => ContainsUpdatesBetween( + _currentSubscription.Plan.SecretsManager.StripeServiceAccountPlanId, + subscriptionItemOptions), + SubscriptionUpdateType.Storage => ContainsUpdatesBetween( + _currentSubscription.Plan.PasswordManager.StripeStoragePlanId, + subscriptionItemOptions), + _ => false + }; + + if (updateNeeded) + { + return true; + } + } + + return false; + + bool ContainsUpdatesBetween(string currentPlanId, SubscriptionItemOptions options) + { + var subscriptionItem = FindSubscriptionItem(subscription, currentPlanId); + + return (subscriptionItem.Plan.Id != options.Plan && subscriptionItem.Price.Id != options.Plan) || + subscriptionItem.Quantity != options.Quantity || + subscriptionItem.Deleted != options.Deleted; + } + } + + /// + /// Generates the necessary to upgrade an 's + /// . + /// + /// The organization's . + public override List UpgradeItemsOptions(Subscription subscription) + { + var subscriptionItemOptions = new List + { + GetPasswordManagerOptions(subscription, _currentSubscription, _updatedSubscription) + }; + + if (_currentSubscription.SubscribedToSecretsManager || _updatedSubscription.SubscribedToSecretsManager) + { + subscriptionItemOptions.Add(GetSecretsManagerOptions(subscription, _currentSubscription, _updatedSubscription)); + + if (_currentSubscription.PurchasedAdditionalSecretsManagerServiceAccounts != 0 || + _updatedSubscription.PurchasedAdditionalSecretsManagerServiceAccounts != 0) + { + subscriptionItemOptions.Add(GetServiceAccountsOptions(subscription, _currentSubscription, _updatedSubscription)); + } + } + + if (_currentSubscription.PurchasedAdditionalStorage != 0 || _updatedSubscription.PurchasedAdditionalStorage != 0) + { + subscriptionItemOptions.Add(GetStorageOptions(subscription, _currentSubscription, _updatedSubscription)); + } + + return subscriptionItemOptions; + } + + private SubscriptionItemOptions GetPasswordManagerOptions( + Subscription subscription, + SubscriptionData from, + SubscriptionData to) + { + var currentPlanId = GetPasswordManagerPlanId(from.Plan); + + var subscriptionItem = FindSubscriptionItem(subscription, currentPlanId); + + if (subscriptionItem == null) + { + throw new GatewayException("Could not find Password Manager subscription"); + } + + var updatedPlanId = GetPasswordManagerPlanId(to.Plan); + + _subscriptionUpdateMap[updatedPlanId] = SubscriptionUpdateType.PasswordManagerSeats; + + return new SubscriptionItemOptions + { + Id = subscriptionItem.Id, + Price = updatedPlanId, + Quantity = IsNonSeatBasedPlan(to.Plan) ? 1 : to.PurchasedPasswordManagerSeats + }; + } + + private SubscriptionItemOptions GetSecretsManagerOptions( + Subscription subscription, + SubscriptionData from, + SubscriptionData to) + { + var currentPlanId = from.Plan?.SecretsManager?.StripeSeatPlanId; + + var subscriptionItem = !string.IsNullOrEmpty(currentPlanId) + ? FindSubscriptionItem(subscription, currentPlanId) + : null; + + var updatedPlanId = to.Plan.SecretsManager.StripeSeatPlanId; + + _subscriptionUpdateMap[updatedPlanId] = SubscriptionUpdateType.SecretsManagerSeats; + + return new SubscriptionItemOptions + { + Id = subscriptionItem?.Id, + Price = updatedPlanId, + Quantity = to.PurchasedSecretsManagerSeats, + Deleted = subscriptionItem?.Id != null && to.PurchasedSecretsManagerSeats == 0 + ? true + : null + }; + } + + private SubscriptionItemOptions GetServiceAccountsOptions( + Subscription subscription, + SubscriptionData from, + SubscriptionData to) + { + var currentPlanId = from.Plan?.SecretsManager?.StripeServiceAccountPlanId; + + var subscriptionItem = !string.IsNullOrEmpty(currentPlanId) + ? FindSubscriptionItem(subscription, currentPlanId) + : null; + + var updatedPlanId = to.Plan.SecretsManager.StripeServiceAccountPlanId; + + _subscriptionUpdateMap[updatedPlanId] = SubscriptionUpdateType.SecretsManagerServiceAccounts; + + return new SubscriptionItemOptions + { + Id = subscriptionItem?.Id, + Price = updatedPlanId, + Quantity = to.PurchasedAdditionalSecretsManagerServiceAccounts, + Deleted = subscriptionItem?.Id != null && to.PurchasedAdditionalSecretsManagerServiceAccounts == 0 + ? true + : null + }; + } + + private SubscriptionItemOptions GetStorageOptions( + Subscription subscription, + SubscriptionData from, + SubscriptionData to) + { + var currentPlanId = from.Plan.PasswordManager.StripeStoragePlanId; + + var subscriptionItem = FindSubscriptionItem(subscription, currentPlanId); + + var updatedPlanId = to.Plan.PasswordManager.StripeStoragePlanId; + + _subscriptionUpdateMap[updatedPlanId] = SubscriptionUpdateType.Storage; + + return new SubscriptionItemOptions + { + Id = subscriptionItem?.Id, + Price = updatedPlanId, + Quantity = to.PurchasedAdditionalStorage, + Deleted = subscriptionItem?.Id != null && to.PurchasedAdditionalStorage == 0 + ? true + : null + }; + } + + private static SubscriptionItem FindSubscriptionItem(Subscription subscription, string planId) + { + if (string.IsNullOrEmpty(planId)) + { + return null; + } + + var data = subscription.Items.Data; + + var subscriptionItem = data.FirstOrDefault(item => item.Plan?.Id == planId) ?? data.FirstOrDefault(item => item.Price?.Id == planId); + + return subscriptionItem; + } + + private static string GetPasswordManagerPlanId(StaticStore.Plan plan) + => IsNonSeatBasedPlan(plan) + ? plan.PasswordManager.StripePlanId + : plan.PasswordManager.StripeSeatPlanId; + + private static SubscriptionData GetSubscriptionDataFor(Organization organization) + { + var plan = Utilities.StaticStore.GetPlan(organization.PlanType); + + return new SubscriptionData + { + Plan = plan, + PurchasedPasswordManagerSeats = organization.Seats.HasValue + ? organization.Seats.Value - plan.PasswordManager.BaseSeats + : 0, + SubscribedToSecretsManager = organization.UseSecretsManager, + PurchasedSecretsManagerSeats = plan.SecretsManager is not null + ? organization.SmSeats - plan.SecretsManager.BaseSeats + : 0, + PurchasedAdditionalSecretsManagerServiceAccounts = plan.SecretsManager is not null + ? organization.SmServiceAccounts - plan.SecretsManager.BaseServiceAccount + : 0, + PurchasedAdditionalStorage = organization.MaxStorageGb.HasValue + ? organization.MaxStorageGb.Value - (plan.PasswordManager.BaseStorageGb ?? 0) : + 0 + }; + } + + private static bool IsNonSeatBasedPlan(StaticStore.Plan plan) + => plan.Type is + >= PlanType.FamiliesAnnually2019 and <= PlanType.EnterpriseAnnually2019 + or PlanType.FamiliesAnnually + or PlanType.TeamsStarter; +} diff --git a/src/Core/Models/Business/SubscriptionUpdate.cs b/src/Core/Models/Business/SubscriptionUpdate.cs index 497a455d6c..70106a10ea 100644 --- a/src/Core/Models/Business/SubscriptionUpdate.cs +++ b/src/Core/Models/Business/SubscriptionUpdate.cs @@ -9,7 +9,7 @@ public abstract class SubscriptionUpdate public abstract List RevertItemsOptions(Subscription subscription); public abstract List UpgradeItemsOptions(Subscription subscription); - public bool UpdateNeeded(Subscription subscription) + public virtual bool UpdateNeeded(Subscription subscription) { var upgradeItemsOptions = UpgradeItemsOptions(subscription); foreach (var upgradeItemOptions in upgradeItemsOptions) diff --git a/src/Core/Models/StaticStore/Plans/Enterprise2019Plan.cs b/src/Core/Models/StaticStore/Plans/Enterprise2019Plan.cs index 7684b0897c..802326deff 100644 --- a/src/Core/Models/StaticStore/Plans/Enterprise2019Plan.cs +++ b/src/Core/Models/StaticStore/Plans/Enterprise2019Plan.cs @@ -31,8 +31,8 @@ public record Enterprise2019Plan : Models.StaticStore.Plan UsersGetPremium = true; HasCustomPermissions = true; - UpgradeSortOrder = 3; - DisplaySortOrder = 3; + UpgradeSortOrder = 4; + DisplaySortOrder = 4; LegacyYear = 2020; SecretsManager = new Enterprise2019SecretsManagerFeatures(isAnnual); diff --git a/src/Core/Models/StaticStore/Plans/Enterprise2020Plan.cs b/src/Core/Models/StaticStore/Plans/Enterprise2020Plan.cs index 4fa7eee972..d984320801 100644 --- a/src/Core/Models/StaticStore/Plans/Enterprise2020Plan.cs +++ b/src/Core/Models/StaticStore/Plans/Enterprise2020Plan.cs @@ -31,8 +31,8 @@ public record Enterprise2020Plan : Models.StaticStore.Plan UsersGetPremium = true; HasCustomPermissions = true; - UpgradeSortOrder = 3; - DisplaySortOrder = 3; + UpgradeSortOrder = 4; + DisplaySortOrder = 4; LegacyYear = 2023; PasswordManager = new Enterprise2020PasswordManagerFeatures(isAnnual); diff --git a/src/Core/Models/StaticStore/Plans/EnterprisePlan.cs b/src/Core/Models/StaticStore/Plans/EnterprisePlan.cs index 61eabc6436..30242f49cf 100644 --- a/src/Core/Models/StaticStore/Plans/EnterprisePlan.cs +++ b/src/Core/Models/StaticStore/Plans/EnterprisePlan.cs @@ -31,8 +31,8 @@ public record EnterprisePlan : Models.StaticStore.Plan UsersGetPremium = true; HasCustomPermissions = true; - UpgradeSortOrder = 3; - DisplaySortOrder = 3; + UpgradeSortOrder = 4; + DisplaySortOrder = 4; PasswordManager = new EnterprisePasswordManagerFeatures(isAnnual); SecretsManager = new EnterpriseSecretsManagerFeatures(isAnnual); diff --git a/src/Core/Models/StaticStore/Plans/Teams2019Plan.cs b/src/Core/Models/StaticStore/Plans/Teams2019Plan.cs index d81a015de8..ce53354f27 100644 --- a/src/Core/Models/StaticStore/Plans/Teams2019Plan.cs +++ b/src/Core/Models/StaticStore/Plans/Teams2019Plan.cs @@ -24,8 +24,8 @@ public record Teams2019Plan : Models.StaticStore.Plan HasApi = true; UsersGetPremium = true; - UpgradeSortOrder = 2; - DisplaySortOrder = 2; + UpgradeSortOrder = 3; + DisplaySortOrder = 3; LegacyYear = 2020; SecretsManager = new Teams2019SecretsManagerFeatures(isAnnual); diff --git a/src/Core/Models/StaticStore/Plans/Teams2020Plan.cs b/src/Core/Models/StaticStore/Plans/Teams2020Plan.cs index 680a5deece..e040edc88a 100644 --- a/src/Core/Models/StaticStore/Plans/Teams2020Plan.cs +++ b/src/Core/Models/StaticStore/Plans/Teams2020Plan.cs @@ -24,8 +24,8 @@ public record Teams2020Plan : Models.StaticStore.Plan HasApi = true; UsersGetPremium = true; - UpgradeSortOrder = 2; - DisplaySortOrder = 2; + UpgradeSortOrder = 3; + DisplaySortOrder = 3; LegacyYear = 2023; PasswordManager = new Teams2020PasswordManagerFeatures(isAnnual); diff --git a/src/Core/Models/StaticStore/Plans/TeamsPlan.cs b/src/Core/Models/StaticStore/Plans/TeamsPlan.cs index 77482d10fb..d181f62747 100644 --- a/src/Core/Models/StaticStore/Plans/TeamsPlan.cs +++ b/src/Core/Models/StaticStore/Plans/TeamsPlan.cs @@ -24,8 +24,8 @@ public record TeamsPlan : Models.StaticStore.Plan HasApi = true; UsersGetPremium = true; - UpgradeSortOrder = 2; - DisplaySortOrder = 2; + UpgradeSortOrder = 3; + DisplaySortOrder = 3; PasswordManager = new TeamsPasswordManagerFeatures(isAnnual); SecretsManager = new TeamsSecretsManagerFeatures(isAnnual); diff --git a/src/Core/Models/StaticStore/Plans/TeamsStarterPlan.cs b/src/Core/Models/StaticStore/Plans/TeamsStarterPlan.cs index 1b9b1b876a..d00fec8f83 100644 --- a/src/Core/Models/StaticStore/Plans/TeamsStarterPlan.cs +++ b/src/Core/Models/StaticStore/Plans/TeamsStarterPlan.cs @@ -64,6 +64,7 @@ public record TeamsStarterPlan : Plan HasAdditionalStorageOption = true; StripePlanId = "teams-org-starter"; + StripeStoragePlanId = "storage-gb-monthly"; AdditionalStoragePricePerGb = 0.5M; } } diff --git a/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs b/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs index 08b33e6664..0023484bf9 100644 --- a/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs @@ -97,11 +97,6 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand throw new BadRequestException("You cannot upgrade to this plan."); } - if (existingPlan.Type != PlanType.Free) - { - throw new BadRequestException("You can only upgrade from the free plan. Contact support."); - } - _organizationService.ValidatePasswordManagerPlan(newPlan, upgrade); if (upgrade.UseSecretsManager) @@ -226,8 +221,16 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand } else { - // TODO: Update existing sub - throw new BadRequestException("You can only upgrade from the free plan. Contact support."); + paymentIntentClientSecret = await _paymentService.AdjustSubscription( + organization, + newPlan, + upgrade.AdditionalSeats, + upgrade.UseSecretsManager, + upgrade.AdditionalSmSeats, + upgrade.AdditionalServiceAccounts, + upgrade.AdditionalStorageGb); + + success = string.IsNullOrEmpty(paymentIntentClientSecret); } organization.BusinessName = upgrade.BusinessName; diff --git a/src/Core/Services/IPaymentService.cs b/src/Core/Services/IPaymentService.cs index 2385155d3f..04526268f7 100644 --- a/src/Core/Services/IPaymentService.cs +++ b/src/Core/Services/IPaymentService.cs @@ -18,6 +18,15 @@ public interface IPaymentService Task UpgradeFreeOrganizationAsync(Organization org, Plan plan, OrganizationUpgrade upgrade); Task PurchasePremiumAsync(User user, PaymentMethodType paymentMethodType, string paymentToken, short additionalStorageGb, TaxInfo taxInfo); + Task AdjustSubscription( + Organization organization, + Plan updatedPlan, + int newlyPurchasedPasswordManagerSeats, + bool subscribedToSecretsManager, + int? newlyPurchasedSecretsManagerSeats, + int? newlyPurchasedAdditionalSecretsManagerServiceAccounts, + int newlyPurchasedAdditionalStorage, + DateTime? prorationDate = null); Task AdjustSeatsAsync(Organization organization, Plan plan, int additionalSeats, DateTime? prorationDate = null); Task AdjustSmSeatsAsync(Organization organization, Plan plan, int additionalSeats, DateTime? prorationDate = null); Task AdjustStorageAsync(IStorableSubscriber storableSubscriber, int additionalStorage, string storagePlanId, DateTime? prorationDate = null); diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index 4de12c6afd..78b54b7a1f 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -741,7 +741,6 @@ public class StripePaymentService : IPaymentService SubscriptionUpdate subscriptionUpdate, DateTime? prorationDate) { // remember, when in doubt, throw - var sub = await _stripeAdapter.SubscriptionGetAsync(storableSubscriber.GatewaySubscriptionId); if (sub == null) { @@ -860,6 +859,30 @@ public class StripePaymentService : IPaymentService return paymentIntentClientSecret; } + public Task AdjustSubscription( + Organization organization, + StaticStore.Plan updatedPlan, + int newlyPurchasedPasswordManagerSeats, + bool subscribedToSecretsManager, + int? newlyPurchasedSecretsManagerSeats, + int? newlyPurchasedAdditionalSecretsManagerServiceAccounts, + int newlyPurchasedAdditionalStorage, + DateTime? prorationDate = null) + => FinalizeSubscriptionChangeAsync( + organization, + new CompleteSubscriptionUpdate( + organization, + new SubscriptionData + { + Plan = updatedPlan, + PurchasedPasswordManagerSeats = newlyPurchasedPasswordManagerSeats, + SubscribedToSecretsManager = subscribedToSecretsManager, + PurchasedSecretsManagerSeats = newlyPurchasedSecretsManagerSeats, + PurchasedAdditionalSecretsManagerServiceAccounts = newlyPurchasedAdditionalSecretsManagerServiceAccounts, + PurchasedAdditionalStorage = newlyPurchasedAdditionalStorage + }), + prorationDate); + public Task AdjustSeatsAsync(Organization organization, StaticStore.Plan plan, int additionalSeats, DateTime? prorationDate = null) { return FinalizeSubscriptionChangeAsync(organization, new SeatSubscriptionUpdate(organization, plan, additionalSeats), prorationDate); diff --git a/test/Core.Test/AdminConsole/AutoFixture/OrganizationFixtures.cs b/test/Core.Test/AdminConsole/AutoFixture/OrganizationFixtures.cs index 297dbe8933..ef3889c6d2 100644 --- a/test/Core.Test/AdminConsole/AutoFixture/OrganizationFixtures.cs +++ b/test/Core.Test/AdminConsole/AutoFixture/OrganizationFixtures.cs @@ -147,6 +147,40 @@ public class SecretsManagerOrganizationCustomization : ICustomization } } +internal class TeamsStarterOrganizationCustomization : ICustomization +{ + public void Customize(IFixture fixture) + { + var organizationId = Guid.NewGuid(); + const PlanType planType = PlanType.TeamsStarter; + + fixture.Customize(composer => + composer + .With(organization => organization.Id, organizationId) + .With(organization => organization.PlanType, planType) + .With(organization => organization.Seats, 10) + .Without(organization => organization.MaxStorageGb)); + } +} + +internal class TeamsMonthlyWithAddOnsOrganizationCustomization : ICustomization +{ + public void Customize(IFixture fixture) + { + var organizationId = Guid.NewGuid(); + const PlanType planType = PlanType.TeamsMonthly; + + fixture.Customize(composer => + composer + .With(organization => organization.Id, organizationId) + .With(organization => organization.PlanType, planType) + .With(organization => organization.Seats, 20) + .With(organization => organization.UseSecretsManager, true) + .With(organization => organization.SmSeats, 5) + .With(organization => organization.SmServiceAccounts, 53)); + } +} + internal class OrganizationCustomizeAttribute : BitCustomizeAttribute { public bool UseGroups { get; set; } @@ -189,6 +223,16 @@ internal class SecretsManagerOrganizationCustomizeAttribute : BitCustomizeAttrib new SecretsManagerOrganizationCustomization(); } +internal class TeamsStarterOrganizationCustomizeAttribute : BitCustomizeAttribute +{ + public override ICustomization GetCustomization() => new TeamsStarterOrganizationCustomization(); +} + +internal class TeamsMonthlyWithAddOnsOrganizationCustomizeAttribute : BitCustomizeAttribute +{ + public override ICustomization GetCustomization() => new TeamsMonthlyWithAddOnsOrganizationCustomization(); +} + internal class EphemeralDataProtectionCustomization : ICustomization { public void Customize(IFixture fixture) diff --git a/test/Core.Test/Models/Business/CompleteSubscriptionUpdateTests.cs b/test/Core.Test/Models/Business/CompleteSubscriptionUpdateTests.cs new file mode 100644 index 0000000000..03d8d83825 --- /dev/null +++ b/test/Core.Test/Models/Business/CompleteSubscriptionUpdateTests.cs @@ -0,0 +1,530 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Enums; +using Bit.Core.Models.Business; +using Bit.Core.Test.AutoFixture.OrganizationFixtures; +using Bit.Core.Utilities; +using Bit.Test.Common.AutoFixture.Attributes; +using Stripe; +using Xunit; + +namespace Bit.Core.Test.Models.Business; + +public class CompleteSubscriptionUpdateTests +{ + [Theory] + [BitAutoData] + [TeamsStarterOrganizationCustomize] + public void UpgradeItemOptions_TeamsStarterToTeams_ReturnsCorrectOptions( + Organization organization) + { + var teamsStarterPlan = StaticStore.GetPlan(PlanType.TeamsStarter); + + var subscription = new Subscription + { + Items = new StripeList + { + Data = new List + { + new () + { + Id = "subscription_item", + Price = new Price { Id = teamsStarterPlan.PasswordManager.StripePlanId }, + Quantity = 1 + } + } + } + }; + + var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); + + var updatedSubscriptionData = new SubscriptionData + { + Plan = teamsMonthlyPlan, + PurchasedPasswordManagerSeats = 20 + }; + + var subscriptionUpdate = new CompleteSubscriptionUpdate(organization, updatedSubscriptionData); + + var upgradeItemOptions = subscriptionUpdate.UpgradeItemsOptions(subscription); + + Assert.Single(upgradeItemOptions); + + var passwordManagerOptions = upgradeItemOptions.First(); + + Assert.Equal(subscription.Items.Data.FirstOrDefault()?.Id, passwordManagerOptions.Id); + Assert.Equal(teamsMonthlyPlan.PasswordManager.StripeSeatPlanId, passwordManagerOptions.Price); + Assert.Equal(updatedSubscriptionData.PurchasedPasswordManagerSeats, passwordManagerOptions.Quantity); + Assert.Null(passwordManagerOptions.Deleted); + } + + [Theory] + [BitAutoData] + [TeamsMonthlyWithAddOnsOrganizationCustomize] + public void UpgradeItemOptions_TeamsWithSMToEnterpriseWithSM_ReturnsCorrectOptions( + Organization organization) + { + // 5 purchased, 1 base + organization.MaxStorageGb = 6; + + var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); + + var subscription = new Subscription + { + Items = new StripeList + { + Data = new List + { + new () + { + Id = "password_manager_subscription_item", + Price = new Price { Id = teamsMonthlyPlan.PasswordManager.StripeSeatPlanId }, + Quantity = organization.Seats!.Value + }, + new () + { + Id = "secrets_manager_subscription_item", + Price = new Price { Id = teamsMonthlyPlan.SecretsManager.StripeSeatPlanId }, + Quantity = organization.SmSeats!.Value + }, + new () + { + Id = "secrets_manager_service_accounts_subscription_item", + Price = new Price { Id = teamsMonthlyPlan.SecretsManager.StripeServiceAccountPlanId }, + Quantity = organization.SmServiceAccounts!.Value + }, + new () + { + Id = "password_manager_storage_subscription_item", + Price = new Price { Id = teamsMonthlyPlan.PasswordManager.StripeStoragePlanId }, + Quantity = organization.Storage!.Value + } + } + } + }; + + var enterpriseMonthlyPlan = StaticStore.GetPlan(PlanType.EnterpriseMonthly); + + var updatedSubscriptionData = new SubscriptionData + { + Plan = enterpriseMonthlyPlan, + PurchasedPasswordManagerSeats = 50, + SubscribedToSecretsManager = true, + PurchasedSecretsManagerSeats = 30, + PurchasedAdditionalSecretsManagerServiceAccounts = 10, + PurchasedAdditionalStorage = 10 + }; + + var subscriptionUpdate = new CompleteSubscriptionUpdate(organization, updatedSubscriptionData); + + var upgradeItemOptions = subscriptionUpdate.UpgradeItemsOptions(subscription); + + Assert.Equal(4, upgradeItemOptions.Count); + + var passwordManagerOptions = upgradeItemOptions.FirstOrDefault(options => + options.Price == enterpriseMonthlyPlan.PasswordManager.StripeSeatPlanId); + + var passwordManagerSubscriptionItem = + subscription.Items.Data.FirstOrDefault(item => item.Id == "password_manager_subscription_item"); + + Assert.Equal(passwordManagerSubscriptionItem?.Id, passwordManagerOptions!.Id); + Assert.Equal(enterpriseMonthlyPlan.PasswordManager.StripeSeatPlanId, passwordManagerOptions.Price); + Assert.Equal(updatedSubscriptionData.PurchasedPasswordManagerSeats, passwordManagerOptions.Quantity); + Assert.Null(passwordManagerOptions.Deleted); + + var secretsManagerOptions = upgradeItemOptions.FirstOrDefault(options => + options.Price == enterpriseMonthlyPlan.SecretsManager.StripeSeatPlanId); + + var secretsManagerSubscriptionItem = + subscription.Items.Data.FirstOrDefault(item => item.Id == "secrets_manager_subscription_item"); + + Assert.Equal(secretsManagerSubscriptionItem?.Id, secretsManagerOptions!.Id); + Assert.Equal(enterpriseMonthlyPlan.SecretsManager.StripeSeatPlanId, secretsManagerOptions.Price); + Assert.Equal(updatedSubscriptionData.PurchasedSecretsManagerSeats, secretsManagerOptions.Quantity); + Assert.Null(secretsManagerOptions.Deleted); + + var serviceAccountsOptions = upgradeItemOptions.FirstOrDefault(options => + options.Price == enterpriseMonthlyPlan.SecretsManager.StripeServiceAccountPlanId); + + var serviceAccountsSubscriptionItem = subscription.Items.Data.FirstOrDefault(item => + item.Id == "secrets_manager_service_accounts_subscription_item"); + + Assert.Equal(serviceAccountsSubscriptionItem?.Id, serviceAccountsOptions!.Id); + Assert.Equal(enterpriseMonthlyPlan.SecretsManager.StripeServiceAccountPlanId, serviceAccountsOptions.Price); + Assert.Equal(updatedSubscriptionData.PurchasedAdditionalSecretsManagerServiceAccounts, serviceAccountsOptions.Quantity); + Assert.Null(serviceAccountsOptions.Deleted); + + var storageOptions = upgradeItemOptions.FirstOrDefault(options => + options.Price == enterpriseMonthlyPlan.PasswordManager.StripeStoragePlanId); + + var storageSubscriptionItem = subscription.Items.Data.FirstOrDefault(item => item.Id == "password_manager_storage_subscription_item"); + + Assert.Equal(storageSubscriptionItem?.Id, storageOptions!.Id); + Assert.Equal(enterpriseMonthlyPlan.PasswordManager.StripeStoragePlanId, storageOptions.Price); + Assert.Equal(updatedSubscriptionData.PurchasedAdditionalStorage, storageOptions.Quantity); + Assert.Null(storageOptions.Deleted); + } + + [Theory] + [BitAutoData] + [TeamsMonthlyWithAddOnsOrganizationCustomize] + public void UpgradeItemOptions_TeamsWithSMToEnterpriseWithoutSM_ReturnsCorrectOptions( + Organization organization) + { + // 5 purchased, 1 base + organization.MaxStorageGb = 6; + + var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); + + var subscription = new Subscription + { + Items = new StripeList + { + Data = new List + { + new () + { + Id = "password_manager_subscription_item", + Price = new Price { Id = teamsMonthlyPlan.PasswordManager.StripeSeatPlanId }, + Quantity = organization.Seats!.Value + }, + new () + { + Id = "secrets_manager_subscription_item", + Price = new Price { Id = teamsMonthlyPlan.SecretsManager.StripeSeatPlanId }, + Quantity = organization.SmSeats!.Value + }, + new () + { + Id = "secrets_manager_service_accounts_subscription_item", + Price = new Price { Id = teamsMonthlyPlan.SecretsManager.StripeServiceAccountPlanId }, + Quantity = organization.SmServiceAccounts!.Value + }, + new () + { + Id = "password_manager_storage_subscription_item", + Price = new Price { Id = teamsMonthlyPlan.PasswordManager.StripeStoragePlanId }, + Quantity = organization.Storage!.Value + } + } + } + }; + + var enterpriseMonthlyPlan = StaticStore.GetPlan(PlanType.EnterpriseMonthly); + + var updatedSubscriptionData = new SubscriptionData + { + Plan = enterpriseMonthlyPlan, + PurchasedPasswordManagerSeats = 50, + SubscribedToSecretsManager = false, + PurchasedSecretsManagerSeats = 0, + PurchasedAdditionalSecretsManagerServiceAccounts = 0, + PurchasedAdditionalStorage = 10 + }; + + var subscriptionUpdate = new CompleteSubscriptionUpdate(organization, updatedSubscriptionData); + + var upgradeItemOptions = subscriptionUpdate.UpgradeItemsOptions(subscription); + + Assert.Equal(4, upgradeItemOptions.Count); + + var passwordManagerOptions = upgradeItemOptions.FirstOrDefault(options => + options.Price == enterpriseMonthlyPlan.PasswordManager.StripeSeatPlanId); + + var passwordManagerSubscriptionItem = + subscription.Items.Data.FirstOrDefault(item => item.Id == "password_manager_subscription_item"); + + Assert.Equal(passwordManagerSubscriptionItem?.Id, passwordManagerOptions!.Id); + Assert.Equal(enterpriseMonthlyPlan.PasswordManager.StripeSeatPlanId, passwordManagerOptions.Price); + Assert.Equal(updatedSubscriptionData.PurchasedPasswordManagerSeats, passwordManagerOptions.Quantity); + Assert.Null(passwordManagerOptions.Deleted); + + var secretsManagerOptions = upgradeItemOptions.FirstOrDefault(options => + options.Price == enterpriseMonthlyPlan.SecretsManager.StripeSeatPlanId); + + var secretsManagerSubscriptionItem = + subscription.Items.Data.FirstOrDefault(item => item.Id == "secrets_manager_subscription_item"); + + Assert.Equal(secretsManagerSubscriptionItem?.Id, secretsManagerOptions!.Id); + Assert.Equal(enterpriseMonthlyPlan.SecretsManager.StripeSeatPlanId, secretsManagerOptions.Price); + Assert.Equal(updatedSubscriptionData.PurchasedSecretsManagerSeats, secretsManagerOptions.Quantity); + Assert.True(secretsManagerOptions.Deleted); + + var serviceAccountsOptions = upgradeItemOptions.FirstOrDefault(options => + options.Price == enterpriseMonthlyPlan.SecretsManager.StripeServiceAccountPlanId); + + var serviceAccountsSubscriptionItem = subscription.Items.Data.FirstOrDefault(item => + item.Id == "secrets_manager_service_accounts_subscription_item"); + + Assert.Equal(serviceAccountsSubscriptionItem?.Id, serviceAccountsOptions!.Id); + Assert.Equal(enterpriseMonthlyPlan.SecretsManager.StripeServiceAccountPlanId, serviceAccountsOptions.Price); + Assert.Equal(updatedSubscriptionData.PurchasedAdditionalSecretsManagerServiceAccounts, serviceAccountsOptions.Quantity); + Assert.True(serviceAccountsOptions.Deleted); + + var storageOptions = upgradeItemOptions.FirstOrDefault(options => + options.Price == enterpriseMonthlyPlan.PasswordManager.StripeStoragePlanId); + + var storageSubscriptionItem = subscription.Items.Data.FirstOrDefault(item => item.Id == "password_manager_storage_subscription_item"); + + Assert.Equal(storageSubscriptionItem?.Id, storageOptions!.Id); + Assert.Equal(enterpriseMonthlyPlan.PasswordManager.StripeStoragePlanId, storageOptions.Price); + Assert.Equal(updatedSubscriptionData.PurchasedAdditionalStorage, storageOptions.Quantity); + Assert.Null(storageOptions.Deleted); + } + + [Theory] + [BitAutoData] + [TeamsStarterOrganizationCustomize] + public void RevertItemOptions_TeamsStarterToTeams_ReturnsCorrectOptions( + Organization organization) + { + var teamsStarterPlan = StaticStore.GetPlan(PlanType.TeamsStarter); + var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); + + var subscription = new Subscription + { + Items = new StripeList + { + Data = new List + { + new () + { + Id = "subscription_item", + Price = new Price { Id = teamsMonthlyPlan.PasswordManager.StripeSeatPlanId }, + Quantity = 20 + } + } + } + }; + + var updatedSubscriptionData = new SubscriptionData + { + Plan = teamsMonthlyPlan, + PurchasedPasswordManagerSeats = 20 + }; + + var subscriptionUpdate = new CompleteSubscriptionUpdate(organization, updatedSubscriptionData); + + var revertItemOptions = subscriptionUpdate.RevertItemsOptions(subscription); + + Assert.Single(revertItemOptions); + + var passwordManagerOptions = revertItemOptions.First(); + + Assert.Equal(subscription.Items.Data.FirstOrDefault()?.Id, passwordManagerOptions.Id); + Assert.Equal(teamsStarterPlan.PasswordManager.StripePlanId, passwordManagerOptions.Price); + Assert.Equal(1, passwordManagerOptions.Quantity); + Assert.Null(passwordManagerOptions.Deleted); + } + + [Theory] + [BitAutoData] + [TeamsMonthlyWithAddOnsOrganizationCustomize] + public void RevertItemOptions_TeamsWithSMToEnterpriseWithSM_ReturnsCorrectOptions( + Organization organization) + { + // 5 purchased, 1 base + organization.MaxStorageGb = 6; + + var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); + var enterpriseMonthlyPlan = StaticStore.GetPlan(PlanType.EnterpriseMonthly); + + var subscription = new Subscription + { + Items = new StripeList + { + Data = new List + { + new () + { + Id = "password_manager_subscription_item", + Price = new Price { Id = enterpriseMonthlyPlan.PasswordManager.StripeSeatPlanId }, + Quantity = organization.Seats!.Value + }, + new () + { + Id = "secrets_manager_subscription_item", + Price = new Price { Id = enterpriseMonthlyPlan.SecretsManager.StripeSeatPlanId }, + Quantity = organization.SmSeats!.Value + }, + new () + { + Id = "secrets_manager_service_accounts_subscription_item", + Price = new Price { Id = enterpriseMonthlyPlan.SecretsManager.StripeServiceAccountPlanId }, + Quantity = organization.SmServiceAccounts!.Value + }, + new () + { + Id = "password_manager_storage_subscription_item", + Price = new Price { Id = enterpriseMonthlyPlan.PasswordManager.StripeStoragePlanId }, + Quantity = organization.Storage!.Value + } + } + } + }; + + var updatedSubscriptionData = new SubscriptionData + { + Plan = enterpriseMonthlyPlan, + PurchasedPasswordManagerSeats = 50, + SubscribedToSecretsManager = true, + PurchasedSecretsManagerSeats = 30, + PurchasedAdditionalSecretsManagerServiceAccounts = 10, + PurchasedAdditionalStorage = 10 + }; + + var subscriptionUpdate = new CompleteSubscriptionUpdate(organization, updatedSubscriptionData); + + var revertItemOptions = subscriptionUpdate.RevertItemsOptions(subscription); + + Assert.Equal(4, revertItemOptions.Count); + + var passwordManagerOptions = revertItemOptions.FirstOrDefault(options => + options.Price == teamsMonthlyPlan.PasswordManager.StripeSeatPlanId); + + var passwordManagerSubscriptionItem = + subscription.Items.Data.FirstOrDefault(item => item.Id == "password_manager_subscription_item"); + + Assert.Equal(passwordManagerSubscriptionItem?.Id, passwordManagerOptions!.Id); + Assert.Equal(teamsMonthlyPlan.PasswordManager.StripeSeatPlanId, passwordManagerOptions.Price); + Assert.Equal(organization.Seats - teamsMonthlyPlan.PasswordManager.BaseSeats, passwordManagerOptions.Quantity); + Assert.Null(passwordManagerOptions.Deleted); + + var secretsManagerOptions = revertItemOptions.FirstOrDefault(options => + options.Price == teamsMonthlyPlan.SecretsManager.StripeSeatPlanId); + + var secretsManagerSubscriptionItem = + subscription.Items.Data.FirstOrDefault(item => item.Id == "secrets_manager_subscription_item"); + + Assert.Equal(secretsManagerSubscriptionItem?.Id, secretsManagerOptions!.Id); + Assert.Equal(teamsMonthlyPlan.SecretsManager.StripeSeatPlanId, secretsManagerOptions.Price); + Assert.Equal(organization.SmSeats - teamsMonthlyPlan.SecretsManager.BaseSeats, secretsManagerOptions.Quantity); + Assert.Null(secretsManagerOptions.Deleted); + + var serviceAccountsOptions = revertItemOptions.FirstOrDefault(options => + options.Price == teamsMonthlyPlan.SecretsManager.StripeServiceAccountPlanId); + + var serviceAccountsSubscriptionItem = subscription.Items.Data.FirstOrDefault(item => + item.Id == "secrets_manager_service_accounts_subscription_item"); + + Assert.Equal(serviceAccountsSubscriptionItem?.Id, serviceAccountsOptions!.Id); + Assert.Equal(teamsMonthlyPlan.SecretsManager.StripeServiceAccountPlanId, serviceAccountsOptions.Price); + Assert.Equal(organization.SmServiceAccounts - teamsMonthlyPlan.SecretsManager.BaseServiceAccount, serviceAccountsOptions.Quantity); + Assert.Null(serviceAccountsOptions.Deleted); + + var storageOptions = revertItemOptions.FirstOrDefault(options => + options.Price == teamsMonthlyPlan.PasswordManager.StripeStoragePlanId); + + var storageSubscriptionItem = subscription.Items.Data.FirstOrDefault(item => item.Id == "password_manager_storage_subscription_item"); + + Assert.Equal(storageSubscriptionItem?.Id, storageOptions!.Id); + Assert.Equal(teamsMonthlyPlan.PasswordManager.StripeStoragePlanId, storageOptions.Price); + Assert.Equal(organization.MaxStorageGb - teamsMonthlyPlan.PasswordManager.BaseStorageGb, storageOptions.Quantity); + Assert.Null(storageOptions.Deleted); + } + + [Theory] + [BitAutoData] + [TeamsMonthlyWithAddOnsOrganizationCustomize] + public void RevertItemOptions_TeamsWithSMToEnterpriseWithoutSM_ReturnsCorrectOptions( + Organization organization) + { + // 5 purchased, 1 base + organization.MaxStorageGb = 6; + + var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); + var enterpriseMonthlyPlan = StaticStore.GetPlan(PlanType.EnterpriseMonthly); + + var subscription = new Subscription + { + Items = new StripeList + { + Data = new List + { + new () + { + Id = "password_manager_subscription_item", + Price = new Price { Id = enterpriseMonthlyPlan.PasswordManager.StripeSeatPlanId }, + Quantity = organization.Seats!.Value + }, + new () + { + Id = "secrets_manager_subscription_item", + Price = new Price { Id = enterpriseMonthlyPlan.SecretsManager.StripeSeatPlanId }, + Quantity = organization.SmSeats!.Value + }, + new () + { + Id = "secrets_manager_service_accounts_subscription_item", + Price = new Price { Id = enterpriseMonthlyPlan.SecretsManager.StripeServiceAccountPlanId }, + Quantity = organization.SmServiceAccounts!.Value + }, + new () + { + Id = "password_manager_storage_subscription_item", + Price = new Price { Id = enterpriseMonthlyPlan.PasswordManager.StripeStoragePlanId }, + Quantity = organization.Storage!.Value + } + } + } + }; + + var updatedSubscriptionData = new SubscriptionData + { + Plan = enterpriseMonthlyPlan, + PurchasedPasswordManagerSeats = 50, + SubscribedToSecretsManager = false, + PurchasedSecretsManagerSeats = 0, + PurchasedAdditionalSecretsManagerServiceAccounts = 0, + PurchasedAdditionalStorage = 10 + }; + + var subscriptionUpdate = new CompleteSubscriptionUpdate(organization, updatedSubscriptionData); + + var revertItemOptions = subscriptionUpdate.RevertItemsOptions(subscription); + + Assert.Equal(4, revertItemOptions.Count); + + var passwordManagerOptions = revertItemOptions.FirstOrDefault(options => + options.Price == teamsMonthlyPlan.PasswordManager.StripeSeatPlanId); + + var passwordManagerSubscriptionItem = + subscription.Items.Data.FirstOrDefault(item => item.Id == "password_manager_subscription_item"); + + Assert.Equal(passwordManagerSubscriptionItem?.Id, passwordManagerOptions!.Id); + Assert.Equal(teamsMonthlyPlan.PasswordManager.StripeSeatPlanId, passwordManagerOptions.Price); + Assert.Equal(organization.Seats - teamsMonthlyPlan.PasswordManager.BaseSeats, passwordManagerOptions.Quantity); + Assert.Null(passwordManagerOptions.Deleted); + + var secretsManagerOptions = revertItemOptions.FirstOrDefault(options => + options.Price == teamsMonthlyPlan.SecretsManager.StripeSeatPlanId); + + var secretsManagerSubscriptionItem = + subscription.Items.Data.FirstOrDefault(item => item.Id == "secrets_manager_subscription_item"); + + Assert.Equal(secretsManagerSubscriptionItem?.Id, secretsManagerOptions!.Id); + Assert.Equal(teamsMonthlyPlan.SecretsManager.StripeSeatPlanId, secretsManagerOptions.Price); + Assert.Equal(organization.SmSeats - teamsMonthlyPlan.SecretsManager.BaseSeats, secretsManagerOptions.Quantity); + Assert.Null(secretsManagerOptions.Deleted); + + var serviceAccountsOptions = revertItemOptions.FirstOrDefault(options => + options.Price == teamsMonthlyPlan.SecretsManager.StripeServiceAccountPlanId); + + var serviceAccountsSubscriptionItem = subscription.Items.Data.FirstOrDefault(item => + item.Id == "secrets_manager_service_accounts_subscription_item"); + + Assert.Equal(serviceAccountsSubscriptionItem?.Id, serviceAccountsOptions!.Id); + Assert.Equal(teamsMonthlyPlan.SecretsManager.StripeServiceAccountPlanId, serviceAccountsOptions.Price); + Assert.Equal(organization.SmServiceAccounts - teamsMonthlyPlan.SecretsManager.BaseServiceAccount, serviceAccountsOptions.Quantity); + Assert.Null(serviceAccountsOptions.Deleted); + + var storageOptions = revertItemOptions.FirstOrDefault(options => + options.Price == teamsMonthlyPlan.PasswordManager.StripeStoragePlanId); + + var storageSubscriptionItem = subscription.Items.Data.FirstOrDefault(item => item.Id == "password_manager_storage_subscription_item"); + + Assert.Equal(storageSubscriptionItem?.Id, storageOptions!.Id); + Assert.Equal(teamsMonthlyPlan.PasswordManager.StripeStoragePlanId, storageOptions.Price); + Assert.Equal(organization.MaxStorageGb - teamsMonthlyPlan.PasswordManager.BaseStorageGb, storageOptions.Quantity); + Assert.Null(storageOptions.Deleted); + } +} diff --git a/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpgradeOrganizationPlanCommandTests.cs b/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpgradeOrganizationPlanCommandTests.cs index 44f07a7c94..d0d11acf76 100644 --- a/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpgradeOrganizationPlanCommandTests.cs +++ b/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpgradeOrganizationPlanCommandTests.cs @@ -63,29 +63,6 @@ public class UpgradeOrganizationPlanCommandTests Assert.Contains("already on this plan", exception.Message); } - [Theory, PaidOrganizationCustomize(CheckedPlanType = PlanType.Free), BitAutoData] - public async Task UpgradePlan_UpgradeFromPaidPlan_Throws(Organization organization, OrganizationUpgrade upgrade, - SutProvider sutProvider) - { - sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); - var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.UpgradePlanAsync(organization.Id, upgrade)); - Assert.Contains("can only upgrade", exception.Message); - } - - [Theory, PaidOrganizationCustomize(CheckedPlanType = PlanType.Free), BitAutoData] - public async Task UpgradePlan_SM_UpgradeFromPaidPlan_Throws(Organization organization, OrganizationUpgrade upgrade, - SutProvider sutProvider) - { - upgrade.UseSecretsManager = true; - upgrade.AdditionalSmSeats = 10; - upgrade.AdditionalServiceAccounts = 10; - sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); - var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.UpgradePlanAsync(organization.Id, upgrade)); - Assert.Contains("can only upgrade", exception.Message); - } - [Theory] [FreeOrganizationUpgradeCustomize, BitAutoData] public async Task UpgradePlan_Passes(Organization organization, OrganizationUpgrade upgrade, @@ -99,6 +76,41 @@ public class UpgradeOrganizationPlanCommandTests await sutProvider.GetDependency().Received(1).ReplaceAndUpdateCacheAsync(organization); } + [Theory] + [BitAutoData(PlanType.TeamsStarter)] + [BitAutoData(PlanType.TeamsMonthly)] + [BitAutoData(PlanType.TeamsAnnually)] + [BitAutoData(PlanType.EnterpriseMonthly)] + [BitAutoData(PlanType.EnterpriseAnnually)] + public async Task UpgradePlan_FromFamilies_Passes( + PlanType planType, + Organization organization, + OrganizationUpgrade organizationUpgrade, + SutProvider sutProvider) + { + sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); + + organization.PlanType = PlanType.FamiliesAnnually; + + organizationUpgrade.AdditionalSeats = 30; + organizationUpgrade.UseSecretsManager = true; + organizationUpgrade.AdditionalSmSeats = 20; + organizationUpgrade.AdditionalServiceAccounts = 5; + organizationUpgrade.AdditionalStorageGb = 3; + organizationUpgrade.Plan = planType; + + await sutProvider.Sut.UpgradePlanAsync(organization.Id, organizationUpgrade); + await sutProvider.GetDependency().Received(1).AdjustSubscription( + organization, + StaticStore.GetPlan(planType), + organizationUpgrade.AdditionalSeats, + organizationUpgrade.UseSecretsManager, + organizationUpgrade.AdditionalSmSeats, + 5, + 3); + await sutProvider.GetDependency().Received(1).ReplaceAndUpdateCacheAsync(organization); + } + [Theory, FreeOrganizationUpgradeCustomize] [BitAutoData(PlanType.EnterpriseMonthly)] [BitAutoData(PlanType.EnterpriseAnnually)] @@ -130,7 +142,6 @@ public class UpgradeOrganizationPlanCommandTests Assert.NotNull(result.Item2); } - [Theory, FreeOrganizationUpgradeCustomize] [BitAutoData(PlanType.EnterpriseMonthly)] [BitAutoData(PlanType.EnterpriseAnnually)] From 9b50cf89b73fdef1bd8d6e522ad450ff2b398b57 Mon Sep 17 00:00:00 2001 From: Ike <137194738+ike-kottlowski@users.noreply.github.com> Date: Wed, 27 Dec 2023 07:08:49 -0800 Subject: [PATCH 16/29] [PM-3505][PM-4587] Update Delete Organization and User SPROCs and EF methods (#3604) * update Organization_DeleteById SPROC * Add migration for user delete * Updated delete methods for EF support * added WITH RECOMPILE * updating sprocs in sql project * Add recompile --- .../Repositories/OrganizationRepository.cs | 2 + .../Repositories/UserRepository.cs | 1 + .../Organization_DeleteById.sql | 7 + .../dbo/Stored Procedures/User_DeleteById.sql | 7 + ...moveAuthRequest_OrganizationDeleteById.sql | 136 +++++++++++++++++ ...12-20_00_RemovePassKeys_UserDeleteById.sql | 137 ++++++++++++++++++ 6 files changed, 290 insertions(+) create mode 100644 util/Migrator/DbScripts/2023-12-18_00_RemoveAuthRequest_OrganizationDeleteById.sql create mode 100644 util/Migrator/DbScripts/2023-12-20_00_RemovePassKeys_UserDeleteById.sql diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs index 000d3d0659..8cdfe3a424 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs @@ -157,6 +157,8 @@ public class OrganizationRepository : Repository ar.OrganizationId == organization.Id) + .ExecuteDeleteAsync(); await dbContext.SsoUsers.Where(su => su.OrganizationId == organization.Id) .ExecuteDeleteAsync(); await dbContext.SsoConfigs.Where(sc => sc.OrganizationId == organization.Id) diff --git a/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs b/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs index b256985989..4458e6044e 100644 --- a/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs @@ -210,6 +210,7 @@ public class UserRepository : Repository, IUserR var transaction = await dbContext.Database.BeginTransactionAsync(); + dbContext.WebAuthnCredentials.RemoveRange(dbContext.WebAuthnCredentials.Where(w => w.UserId == user.Id)); dbContext.Ciphers.RemoveRange(dbContext.Ciphers.Where(c => c.UserId == user.Id)); dbContext.Folders.RemoveRange(dbContext.Folders.Where(f => f.UserId == user.Id)); dbContext.AuthRequests.RemoveRange(dbContext.AuthRequests.Where(s => s.UserId == user.Id)); diff --git a/src/Sql/dbo/Stored Procedures/Organization_DeleteById.sql b/src/Sql/dbo/Stored Procedures/Organization_DeleteById.sql index a78e5633ed..e359475670 100644 --- a/src/Sql/dbo/Stored Procedures/Organization_DeleteById.sql +++ b/src/Sql/dbo/Stored Procedures/Organization_DeleteById.sql @@ -1,5 +1,6 @@ CREATE PROCEDURE [dbo].[Organization_DeleteById] @Id UNIQUEIDENTIFIER +WITH RECOMPILE AS BEGIN SET NOCOUNT ON @@ -25,6 +26,12 @@ BEGIN BEGIN TRANSACTION Organization_DeleteById + DELETE + FROM + [dbo].[AuthRequest] + WHERE + [OrganizationId] = @Id + DELETE FROM [dbo].[SsoUser] diff --git a/src/Sql/dbo/Stored Procedures/User_DeleteById.sql b/src/Sql/dbo/Stored Procedures/User_DeleteById.sql index 1f16c15aaa..266d5e0bc3 100644 --- a/src/Sql/dbo/Stored Procedures/User_DeleteById.sql +++ b/src/Sql/dbo/Stored Procedures/User_DeleteById.sql @@ -24,6 +24,13 @@ BEGIN BEGIN TRANSACTION User_DeleteById + -- Delete WebAuthnCredentials + DELETE + FROM + [dbo].[WebAuthnCredential] + WHERE + [UserId] = @Id + -- Delete folders DELETE FROM diff --git a/util/Migrator/DbScripts/2023-12-18_00_RemoveAuthRequest_OrganizationDeleteById.sql b/util/Migrator/DbScripts/2023-12-18_00_RemoveAuthRequest_OrganizationDeleteById.sql new file mode 100644 index 0000000000..524a26353e --- /dev/null +++ b/util/Migrator/DbScripts/2023-12-18_00_RemoveAuthRequest_OrganizationDeleteById.sql @@ -0,0 +1,136 @@ +IF OBJECT_ID('[dbo].[Organization_DeleteById]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[Organization_DeleteById] +END +GO + +CREATE PROCEDURE [dbo].[Organization_DeleteById] + @Id UNIQUEIDENTIFIER +WITH + RECOMPILE +AS +BEGIN + SET NOCOUNT ON + + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @Id + + DECLARE @BatchSize INT = 100 + WHILE @BatchSize > 0 + BEGIN + BEGIN TRANSACTION Organization_DeleteById_Ciphers + + DELETE TOP(@BatchSize) + FROM + [dbo].[Cipher] + WHERE + [UserId] IS NULL + AND [OrganizationId] = @Id + + SET @BatchSize = @@ROWCOUNT + + COMMIT TRANSACTION Organization_DeleteById_Ciphers + END + + BEGIN TRANSACTION Organization_DeleteById + + DELETE + FROM + [dbo].[AuthRequest] + WHERE + [OrganizationId] = @Id + + DELETE + FROM + [dbo].[SsoUser] + WHERE + [OrganizationId] = @Id + + DELETE + FROM + [dbo].[SsoConfig] + WHERE + [OrganizationId] = @Id + + DELETE CU + FROM + [dbo].[CollectionUser] CU + INNER JOIN + [dbo].[OrganizationUser] OU ON [CU].[OrganizationUserId] = [OU].[Id] + WHERE + [OU].[OrganizationId] = @Id + + DELETE AP + FROM + [dbo].[AccessPolicy] AP + INNER JOIN + [dbo].[OrganizationUser] OU ON [AP].[OrganizationUserId] = [OU].[Id] + WHERE + [OU].[OrganizationId] = @Id + + DELETE GU + FROM + [dbo].[GroupUser] GU + INNER JOIN + [dbo].[OrganizationUser] OU ON [GU].[OrganizationUserId] = [OU].[Id] + WHERE + [OU].[OrganizationId] = @Id + + DELETE + FROM + [dbo].[OrganizationUser] + WHERE + [OrganizationId] = @Id + + DELETE + FROM + [dbo].[ProviderOrganization] + WHERE + [OrganizationId] = @Id + + EXEC [dbo].[OrganizationApiKey_OrganizationDeleted] @Id + EXEC [dbo].[OrganizationConnection_OrganizationDeleted] @Id + EXEC [dbo].[OrganizationSponsorship_OrganizationDeleted] @Id + EXEC [dbo].[OrganizationDomain_OrganizationDeleted] @Id + + DELETE + FROM + [dbo].[Project] + WHERE + [OrganizationId] = @Id + + DELETE + FROM + [dbo].[Secret] + WHERE + [OrganizationId] = @Id + + DELETE AK + FROM + [dbo].[ApiKey] AK + INNER JOIN + [dbo].[ServiceAccount] SA ON [AK].[ServiceAccountId] = [SA].[Id] + WHERE + [SA].[OrganizationId] = @Id + + DELETE AP + FROM + [dbo].[AccessPolicy] AP + INNER JOIN + [dbo].[ServiceAccount] SA ON [AP].[GrantedServiceAccountId] = [SA].[Id] + WHERE + [SA].[OrganizationId] = @Id + + DELETE + FROM + [dbo].[ServiceAccount] + WHERE + [OrganizationId] = @Id + + DELETE + FROM + [dbo].[Organization] + WHERE + [Id] = @Id + + COMMIT TRANSACTION Organization_DeleteById +END diff --git a/util/Migrator/DbScripts/2023-12-20_00_RemovePassKeys_UserDeleteById.sql b/util/Migrator/DbScripts/2023-12-20_00_RemovePassKeys_UserDeleteById.sql new file mode 100644 index 0000000000..c4dbfbb271 --- /dev/null +++ b/util/Migrator/DbScripts/2023-12-20_00_RemovePassKeys_UserDeleteById.sql @@ -0,0 +1,137 @@ +IF OBJECT_ID('[dbo].[User_DeleteById]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[User_DeleteById] +END +GO + +CREATE PROCEDURE [dbo].[User_DeleteById] + @Id UNIQUEIDENTIFIER +WITH + RECOMPILE +AS +BEGIN + SET NOCOUNT ON + DECLARE @BatchSize INT = 100 + + -- Delete ciphers + WHILE @BatchSize > 0 + BEGIN + BEGIN TRANSACTION User_DeleteById_Ciphers + + DELETE TOP(@BatchSize) + FROM + [dbo].[Cipher] + WHERE + [UserId] = @Id + + SET @BatchSize = @@ROWCOUNT + + COMMIT TRANSACTION User_DeleteById_Ciphers + END + + BEGIN TRANSACTION User_DeleteById + + -- Delete WebAuthnCredentials + DELETE + FROM + [dbo].[WebAuthnCredential] + WHERE + [UserId] = @Id + + -- Delete folders + DELETE + FROM + [dbo].[Folder] + WHERE + [UserId] = @Id + + -- Delete AuthRequest, must be before Device + DELETE + FROM + [dbo].[AuthRequest] + WHERE + [UserId] = @Id + + -- Delete devices + DELETE + FROM + [dbo].[Device] + WHERE + [UserId] = @Id + + -- Delete collection users + DELETE + CU + FROM + [dbo].[CollectionUser] CU + INNER JOIN + [dbo].[OrganizationUser] OU ON OU.[Id] = CU.[OrganizationUserId] + WHERE + OU.[UserId] = @Id + + -- Delete group users + DELETE + GU + FROM + [dbo].[GroupUser] GU + INNER JOIN + [dbo].[OrganizationUser] OU ON OU.[Id] = GU.[OrganizationUserId] + WHERE + OU.[UserId] = @Id + + -- Delete AccessPolicy + DELETE + AP + FROM + [dbo].[AccessPolicy] AP + INNER JOIN + [dbo].[OrganizationUser] OU ON OU.[Id] = AP.[OrganizationUserId] + WHERE + [UserId] = @Id + + -- Delete organization users + DELETE + FROM + [dbo].[OrganizationUser] + WHERE + [UserId] = @Id + + -- Delete provider users + DELETE + FROM + [dbo].[ProviderUser] + WHERE + [UserId] = @Id + + -- Delete SSO Users + DELETE + FROM + [dbo].[SsoUser] + WHERE + [UserId] = @Id + + -- Delete Emergency Accesses + DELETE + FROM + [dbo].[EmergencyAccess] + WHERE + [GrantorId] = @Id + OR + [GranteeId] = @Id + + -- Delete Sends + DELETE + FROM + [dbo].[Send] + WHERE + [UserId] = @Id + + -- Finally, delete the user + DELETE + FROM + [dbo].[User] + WHERE + [Id] = @Id + + COMMIT TRANSACTION User_DeleteById +END From 1f8e2385db78a9a6b38e39709a77cee668ae32b4 Mon Sep 17 00:00:00 2001 From: Matt Bishop Date: Wed, 27 Dec 2023 10:36:20 -0500 Subject: [PATCH 17/29] Wire up code coverage (#3618) --- .config/dotnet-tools.json | 22 +++------------ .github/workflows/build.yml | 46 +++++++------------------------- test/coverage.ps1 | 31 ---------------------- test/coverage.sh | 53 ------------------------------------- 4 files changed, 12 insertions(+), 140 deletions(-) delete mode 100644 test/coverage.ps1 delete mode 100755 test/coverage.sh 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/workflows/build.yml b/.github/workflows/build.yml index 2c3a76e517..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,7 +494,6 @@ 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 diff --git a/test/coverage.ps1 b/test/coverage.ps1 deleted file mode 100644 index bf8780c493..0000000000 --- a/test/coverage.ps1 +++ /dev/null @@ -1,31 +0,0 @@ -param( - [string][Alias('c')]$Configuration = "Release", - [string][Alias('o')]$Output = "CoverageOutput", - [string][Alias('rt')]$ReportType = "lcov" -) - -function Install-Tools { - dotnet tool restore -} - -function Print-Environment { - dotnet --version -} - -function Prepare-Output { - if (Test-Path -Path $Output) { - Remove-Item $Output -Recurse - } -} - -function Run-Tests { - dotnet test $PSScriptRoot/bitwarden.tests.sln /p:CoverletOutputFormatter="cobertura" --collect:"XPlat Code Coverage" --results-directory:"$Output" -c $Configuration - - dotnet tool run reportgenerator -reports:$Output/**/*.cobertura.xml -targetdir:$Output -reporttypes:"$ReportType" -} - -Write-Host "Collecting Code Coverage" -Install-Tools -Print-Environment -Prepare-Output -Run-Tests diff --git a/test/coverage.sh b/test/coverage.sh deleted file mode 100755 index b061a34c61..0000000000 --- a/test/coverage.sh +++ /dev/null @@ -1,53 +0,0 @@ -#!/bin/bash - -# Set defaults if no values supplied -CONFIGURATION="Release" -OUTPUT="CoverageOutput" -REPORT_TYPE="lcov" - - -# Read in arguments -while [[ $# -gt 0 ]]; do - key="$1" - - case $key in - -c|--configuration) - - CONFIGURATION="$2" - shift - shift - ;; - -o|--output) - OUTPUT="$2" - shift - shift - ;; - -rt|--reportType) - REPORT_TYPE="$2" - shift - shift - ;; - *) - shift - ;; - esac -done - -echo "CONFIGURATION = ${CONFIGURATION}" -echo "OUTPUT = ${OUTPUT}" -echo "REPORT_TYPE = ${REPORT_TYPE}" - -echo "Collectiong Code Coverage" -# Install tools -dotnet tool restore -# Print Environment -dotnet --version - -if [[ -d $OUTPUT ]]; then - echo "Cleaning output location" - rm -rf $OUTPUT -fi - -dotnet test "./bitwarden.tests.sln" /p:CoverletOutputFormatter="cobertura" --collect:"XPlat Code Coverage" --results-directory:"$OUTPUT" -c $CONFIGURATION - -dotnet tool run reportgenerator -reports:$OUTPUT/**/*.cobertura.xml -targetdir:$OUTPUT -reporttype:"$REPORT_TYPE" From fbc25f3317643cd5e3c4c6d7cfc0a9c25984a5ec Mon Sep 17 00:00:00 2001 From: Chukwuma Akunyili <56761791+aguluman@users.noreply.github.com> Date: Wed, 27 Dec 2023 16:39:33 +0100 Subject: [PATCH 18/29] amend: i changed all var keywords to let, i removed asp-for duplicates and i introduced `i0,i1,12` variables to store the current value of the loop counter variable `i` at different points within the loop (#3100) Co-authored-by: Chukwuma Akunyili <56761791+ChukwumaA@users.noreply.github.com> Co-authored-by: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> --- .../Views/Tools/StripeSubscriptions.cshtml | 412 +++++++++--------- 1 file changed, 210 insertions(+), 202 deletions(-) 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 {