diff --git a/src/Api/AdminConsole/Controllers/OrganizationsController.cs b/src/Api/AdminConsole/Controllers/OrganizationsController.cs index 07b005bfc..c83fce762 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationsController.cs @@ -14,6 +14,7 @@ using Bit.Core; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationApiKeys.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationCollectionEnhancements.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Repositories; @@ -67,6 +68,7 @@ public class OrganizationsController : Controller private readonly ICancelSubscriptionCommand _cancelSubscriptionCommand; private readonly IGetSubscriptionQuery _getSubscriptionQuery; private readonly IReferenceEventService _referenceEventService; + private readonly IOrganizationEnableCollectionEnhancementsCommand _organizationEnableCollectionEnhancementsCommand; public OrganizationsController( IOrganizationRepository organizationRepository, @@ -92,7 +94,8 @@ public class OrganizationsController : Controller IPushNotificationService pushNotificationService, ICancelSubscriptionCommand cancelSubscriptionCommand, IGetSubscriptionQuery getSubscriptionQuery, - IReferenceEventService referenceEventService) + IReferenceEventService referenceEventService, + IOrganizationEnableCollectionEnhancementsCommand organizationEnableCollectionEnhancementsCommand) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -118,6 +121,7 @@ public class OrganizationsController : Controller _cancelSubscriptionCommand = cancelSubscriptionCommand; _getSubscriptionQuery = getSubscriptionQuery; _referenceEventService = referenceEventService; + _organizationEnableCollectionEnhancementsCommand = organizationEnableCollectionEnhancementsCommand; } [HttpGet("{id}")] @@ -888,15 +892,7 @@ public class OrganizationsController : Controller throw new NotFoundException(); } - if (organization.FlexibleCollections) - { - throw new BadRequestException("Organization has already been migrated to the new collection enhancements"); - } - - await _organizationRepository.EnableCollectionEnhancements(id); - - organization.FlexibleCollections = true; - await _organizationService.ReplaceAndUpdateCacheAsync(organization); + await _organizationEnableCollectionEnhancementsCommand.EnableCollectionEnhancements(organization); // Force a vault sync for all owners and admins of the organization so that changes show immediately // Custom users are intentionally not handled as they are likely to be less impacted and we want to limit simultaneous syncs diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationCollectionEnhancements/Interfaces/IOrganizationEnableCollectionEnhancementsCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationCollectionEnhancements/Interfaces/IOrganizationEnableCollectionEnhancementsCommand.cs new file mode 100644 index 000000000..58a639c74 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationCollectionEnhancements/Interfaces/IOrganizationEnableCollectionEnhancementsCommand.cs @@ -0,0 +1,12 @@ +using Bit.Core.AdminConsole.Entities; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationCollectionEnhancements.Interfaces; + +/// +/// Enable collection enhancements for an organization. +/// This command will be deprecated once all organizations have collection enhancements enabled. +/// +public interface IOrganizationEnableCollectionEnhancementsCommand +{ + Task EnableCollectionEnhancements(Organization organization); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationCollectionEnhancements/OrganizationEnableCollectionEnhancementsCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationCollectionEnhancements/OrganizationEnableCollectionEnhancementsCommand.cs new file mode 100644 index 000000000..da32e9c51 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationCollectionEnhancements/OrganizationEnableCollectionEnhancementsCommand.cs @@ -0,0 +1,112 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationCollectionEnhancements.Interfaces; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationCollectionEnhancements; + +public class OrganizationEnableCollectionEnhancementsCommand : IOrganizationEnableCollectionEnhancementsCommand +{ + private readonly ICollectionRepository _collectionRepository; + private readonly IGroupRepository _groupRepository; + private readonly IOrganizationRepository _organizationRepository; + private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly IOrganizationService _organizationService; + private readonly ILogger _logger; + + public OrganizationEnableCollectionEnhancementsCommand(ICollectionRepository collectionRepository, + IGroupRepository groupRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationService organizationService, + ILogger logger) + { + _collectionRepository = collectionRepository; + _groupRepository = groupRepository; + _organizationRepository = organizationRepository; + _organizationUserRepository = organizationUserRepository; + _organizationService = organizationService; + _logger = logger; + } + + public async Task EnableCollectionEnhancements(Organization organization) + { + if (organization.FlexibleCollections) + { + throw new BadRequestException("Organization has already been migrated to the new collection enhancements"); + } + + // Log the Organization data that will change when the migration is complete + await LogPreMigrationDataAsync(organization.Id); + + // Run the data migration script + await _organizationRepository.EnableCollectionEnhancements(organization.Id); + + organization.FlexibleCollections = true; + await _organizationService.ReplaceAndUpdateCacheAsync(organization); + } + + /// + /// This method logs the data that will be migrated to the new collection enhancements so that it can be restored if needed + /// + /// + private async Task LogPreMigrationDataAsync(Guid organizationId) + { + var groups = await _groupRepository.GetManyByOrganizationIdAsync(organizationId); + // Grab Group Ids that have AccessAll enabled as it will be removed in the data migration + var groupIdsWithAccessAllEnabled = groups + .Where(g => g.AccessAll) + .Select(g => g.Id) + .ToList(); + + var organizationUsers = await _organizationUserRepository.GetManyByOrganizationAsync(organizationId, type: null); + // Grab OrganizationUser Ids that have AccessAll enabled as it will be removed in the data migration + var organizationUserIdsWithAccessAllEnabled = organizationUsers + .Where(ou => ou.AccessAll) + .Select(ou => ou.Id) + .ToList(); + // Grab OrganizationUser Ids of Manager users as that will be downgraded to User in the data migration + var migratedManagers = organizationUsers + .Where(ou => ou.Type == OrganizationUserType.Manager) + .Select(ou => ou.Id) + .ToList(); + + var usersEligibleToManageCollections = organizationUsers + .Where(ou => + ou.Type == OrganizationUserType.Manager || + (ou.Type == OrganizationUserType.Custom && + !string.IsNullOrEmpty(ou.Permissions) && + ou.GetPermissions().EditAssignedCollections) + ) + .Select(ou => ou.Id) + .ToList(); + var collectionUsers = await _collectionRepository.GetManyByOrganizationIdWithAccessAsync(organizationId); + // Grab CollectionUser permissions that will change in the data migration + var collectionUsersData = collectionUsers.SelectMany(tuple => + tuple.Item2.Users.Select(user => + new + { + CollectionId = tuple.Item1.Id, + OrganizationUserId = user.Id, + user.ReadOnly, + user.HidePasswords + })) + .Where(cud => usersEligibleToManageCollections.Any(ou => ou == cud.OrganizationUserId)) + .ToList(); + + var logObject = new + { + OrganizationId = organizationId, + GroupAccessAll = groupIdsWithAccessAllEnabled, + UserAccessAll = organizationUserIdsWithAccessAllEnabled, + MigratedManagers = migratedManagers, + CollectionUsers = collectionUsersData + }; + + _logger.LogWarning("Flexible Collections data migration started. Backup data: {@LogObject}", logObject); + } +} diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index e70738e06..3f303e3a9 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -4,6 +4,8 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Groups; using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationApiKeys; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationApiKeys.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationCollectionEnhancements; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationCollectionEnhancements.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationConnections; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationConnections.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains; @@ -50,6 +52,7 @@ public static class OrganizationServiceCollectionExtensions services.AddOrganizationUserCommands(); services.AddOrganizationUserCommandsQueries(); services.AddBaseOrganizationSubscriptionCommandsQueries(); + services.AddOrganizationCollectionEnhancementsCommands(); } private static void AddOrganizationConnectionCommands(this IServiceCollection services) @@ -144,6 +147,11 @@ public static class OrganizationServiceCollectionExtensions services.AddScoped(); } + private static void AddOrganizationCollectionEnhancementsCommands(this IServiceCollection services) + { + services.AddScoped(); + } + private static void AddTokenizers(this IServiceCollection services) { services.AddSingleton>(serviceProvider => diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs index 45b3d9af3..983470d6f 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs @@ -5,6 +5,7 @@ using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.Models.Request.Organizations; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationApiKeys.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationCollectionEnhancements.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; @@ -57,6 +58,7 @@ public class OrganizationsControllerTests : IDisposable private readonly ICancelSubscriptionCommand _cancelSubscriptionCommand; private readonly IGetSubscriptionQuery _getSubscriptionQuery; private readonly IReferenceEventService _referenceEventService; + private readonly IOrganizationEnableCollectionEnhancementsCommand _organizationEnableCollectionEnhancementsCommand; private readonly OrganizationsController _sut; @@ -86,6 +88,7 @@ public class OrganizationsControllerTests : IDisposable _cancelSubscriptionCommand = Substitute.For(); _getSubscriptionQuery = Substitute.For(); _referenceEventService = Substitute.For(); + _organizationEnableCollectionEnhancementsCommand = Substitute.For(); _sut = new OrganizationsController( _organizationRepository, @@ -111,7 +114,8 @@ public class OrganizationsControllerTests : IDisposable _pushNotificationService, _cancelSubscriptionCommand, _getSubscriptionQuery, - _referenceEventService); + _referenceEventService, + _organizationEnableCollectionEnhancementsCommand); } public void Dispose() @@ -390,11 +394,7 @@ public class OrganizationsControllerTests : IDisposable await _sut.EnableCollectionEnhancements(organization.Id); - await _organizationRepository.Received(1).EnableCollectionEnhancements(organization.Id); - await _organizationService.Received(1).ReplaceAndUpdateCacheAsync( - Arg.Is(o => - o.Id == organization.Id && - o.FlexibleCollections)); + await _organizationEnableCollectionEnhancementsCommand.Received(1).EnableCollectionEnhancements(organization); await _pushNotificationService.Received(1).PushSyncVaultAsync(admin.UserId.Value); await _pushNotificationService.Received(1).PushSyncVaultAsync(owner.UserId.Value); await _pushNotificationService.DidNotReceive().PushSyncVaultAsync(user.UserId.Value); @@ -409,23 +409,7 @@ public class OrganizationsControllerTests : IDisposable await Assert.ThrowsAsync(async () => await _sut.EnableCollectionEnhancements(organization.Id)); - await _organizationRepository.DidNotReceiveWithAnyArgs().EnableCollectionEnhancements(Arg.Any()); - await _organizationService.DidNotReceiveWithAnyArgs().ReplaceAndUpdateCacheAsync(Arg.Any()); - await _pushNotificationService.DidNotReceiveWithAnyArgs().PushSyncVaultAsync(Arg.Any()); - } - - [Theory, AutoData] - public async Task EnableCollectionEnhancements_WhenAlreadyMigrated_Throws(Organization organization) - { - organization.FlexibleCollections = true; - _currentContext.OrganizationOwner(organization.Id).Returns(true); - _organizationRepository.GetByIdAsync(organization.Id).Returns(organization); - - var exception = await Assert.ThrowsAsync(async () => await _sut.EnableCollectionEnhancements(organization.Id)); - Assert.Contains("has already been migrated", exception.Message); - - await _organizationRepository.DidNotReceiveWithAnyArgs().EnableCollectionEnhancements(Arg.Any()); - await _organizationService.DidNotReceiveWithAnyArgs().ReplaceAndUpdateCacheAsync(Arg.Any()); + await _organizationEnableCollectionEnhancementsCommand.DidNotReceiveWithAnyArgs().EnableCollectionEnhancements(Arg.Any()); await _pushNotificationService.DidNotReceiveWithAnyArgs().PushSyncVaultAsync(Arg.Any()); } } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationCollectionEnhancements/OrganizationEnableCollectionEnhancementsCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationCollectionEnhancements/OrganizationEnableCollectionEnhancementsCommandTests.cs new file mode 100644 index 000000000..c63c100ad --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationCollectionEnhancements/OrganizationEnableCollectionEnhancementsCommandTests.cs @@ -0,0 +1,46 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationCollectionEnhancements; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationCollectionEnhancements; + +[SutProviderCustomize] +public class OrganizationEnableCollectionEnhancementsCommandTests +{ + [Theory] + [BitAutoData] + public async Task EnableCollectionEnhancements_Success( + SutProvider sutProvider, + Organization organization) + { + organization.FlexibleCollections = false; + + await sutProvider.Sut.EnableCollectionEnhancements(organization); + + await sutProvider.GetDependency().Received(1).EnableCollectionEnhancements(organization.Id); + await sutProvider.GetDependency().Received(1).ReplaceAndUpdateCacheAsync( + Arg.Is(o => + o.Id == organization.Id && + o.FlexibleCollections)); + } + + [Theory] + [BitAutoData] + public async Task EnableCollectionEnhancements_WhenAlreadyMigrated_Throws( + SutProvider sutProvider, + Organization organization) + { + organization.FlexibleCollections = true; + + var exception = await Assert.ThrowsAsync(async () => await sutProvider.Sut.EnableCollectionEnhancements(organization)); + Assert.Contains("has already been migrated", exception.Message); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().EnableCollectionEnhancements(Arg.Any()); + } +}