From 6a0f6e1dac85f1940226735bf7c5907189c2ef9b Mon Sep 17 00:00:00 2001 From: Shane Melton Date: Fri, 22 Mar 2024 13:16:34 -0700 Subject: [PATCH] [PM-2383] Bulk collection assignment (#3919) * [PM-2383] Add bulk add/remove collection cipher repository methods * [PM-2383] Add additional authorization helpers for CiphersControlle * [PM-2383] Add /bulk-collections endpoint to CiphersController.cs * [PM-2383] Add EF implementation for new CollectionCipherRepository methods * [PM-2383] Ensure V1 logic only applies when the flag is enabled for new bulk functionality --- .../Vault/Controllers/CiphersController.cs | 162 +++++++++++++++++- ...CipherBulkUpdateCollectionsRequestModel.cs | 15 ++ .../ICollectionCipherRepository.cs | 18 ++ src/Core/Vault/Models/Data/CipherDetails.cs | 20 +++ .../CollectionCipherRepository.cs | 24 +++ .../CollectionCipherRepository.cs | 43 +++++ ...ionCipher_AddCollectionsForManyCiphers.sql | 56 ++++++ ...ipher_RemoveCollectionsFromManyCiphers.sql | 26 +++ ...3-20_00_BulkCipherCollectionAssignment.sql | 85 +++++++++ 9 files changed, 448 insertions(+), 1 deletion(-) create mode 100644 src/Api/Vault/Models/Request/CipherBulkUpdateCollectionsRequestModel.cs create mode 100644 src/Sql/Vault/dbo/Stored Procedures/CollectionCipher/CollectionCipher_AddCollectionsForManyCiphers.sql create mode 100644 src/Sql/Vault/dbo/Stored Procedures/CollectionCipher/CollectionCipher_RemoveCollectionsFromManyCiphers.sql create mode 100644 util/Migrator/DbScripts/2024-03-20_00_BulkCipherCollectionAssignment.sql diff --git a/src/Api/Vault/Controllers/CiphersController.cs b/src/Api/Vault/Controllers/CiphersController.cs index 90e7d2ed1..80a453dfc 100644 --- a/src/Api/Vault/Controllers/CiphersController.cs +++ b/src/Api/Vault/Controllers/CiphersController.cs @@ -45,6 +45,7 @@ public class CiphersController : Controller private readonly IFeatureService _featureService; private readonly IOrganizationCiphersQuery _organizationCiphersQuery; private readonly IApplicationCacheService _applicationCacheService; + private readonly ICollectionRepository _collectionRepository; private bool UseFlexibleCollections => _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections); @@ -61,7 +62,8 @@ public class CiphersController : Controller GlobalSettings globalSettings, IFeatureService featureService, IOrganizationCiphersQuery organizationCiphersQuery, - IApplicationCacheService applicationCacheService) + IApplicationCacheService applicationCacheService, + ICollectionRepository collectionRepository) { _cipherRepository = cipherRepository; _collectionCipherRepository = collectionCipherRepository; @@ -75,6 +77,7 @@ public class CiphersController : Controller _featureService = featureService; _organizationCiphersQuery = organizationCiphersQuery; _applicationCacheService = applicationCacheService; + _collectionRepository = collectionRepository; } [HttpGet("{id}")] @@ -342,6 +345,45 @@ public class CiphersController : Controller return false; } + /// + /// TODO: Move this to its own authorization handler or equivalent service - AC-2062 + /// + private async Task CanEditAllCiphersAsync(Guid organizationId) + { + var org = _currentContext.GetOrganization(organizationId); + + // If not using V1, owners, admins, and users with EditAnyCollection permissions, and providers can always edit all ciphers + if (!await UseFlexibleCollectionsV1Async(organizationId)) + { + return org is { Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or + { Permissions.EditAnyCollection: true } || + await _currentContext.ProviderUserForOrgAsync(organizationId); + } + + // Custom users with EditAnyCollection permissions can always edit all ciphers + if (org is { Type: OrganizationUserType.Custom, Permissions.EditAnyCollection: true }) + { + return true; + } + + var orgAbility = await _applicationCacheService.GetOrganizationAbilityAsync(organizationId); + + // Owners/Admins can only edit all ciphers if the organization has the setting enabled + if (orgAbility is { AllowAdminAccessToAllCollectionItems: true } && org is + { Type: OrganizationUserType.Admin or OrganizationUserType.Owner }) + { + return true; + } + + // Provider users can edit all ciphers in V1 (to change later) + if (await _currentContext.ProviderUserForOrgAsync(organizationId)) + { + return true; + } + + return false; + } + /// /// TODO: Move this to its own authorization handler or equivalent service - AC-2062 /// @@ -388,6 +430,97 @@ public class CiphersController : Controller return false; } + /// + /// TODO: Move this to its own authorization handler or equivalent service - AC-2062 + /// + private async Task CanEditCiphersAsync(Guid organizationId, IEnumerable cipherIds) + { + // If the user can edit all ciphers for the organization, just check they all belong to the org + if (await CanEditAllCiphersAsync(organizationId)) + { + // TODO: This can likely be optimized to only query the requested ciphers and then checking they belong to the org + var orgCiphers = (await _cipherRepository.GetManyByOrganizationIdAsync(organizationId)).ToDictionary(c => c.Id); + + // Ensure all requested ciphers are in orgCiphers + if (cipherIds.Any(c => !orgCiphers.ContainsKey(c))) + { + return false; + } + + return true; + } + + // The user cannot access any ciphers for the organization, we're done + if (!await CanAccessOrganizationCiphersAsync(organizationId)) + { + return false; + } + + var userId = _userService.GetProperUserId(User).Value; + // Select all editable ciphers for this user belonging to the organization + var editableOrgCipherList = (await _cipherRepository.GetManyByUserIdAsync(userId, true)) + .Where(c => c.OrganizationId == organizationId && c.UserId == null && c.Edit).ToList(); + + // Special case for unassigned ciphers + if (await CanAccessUnassignedCiphersAsync(organizationId)) + { + var unassignedCiphers = + (await _cipherRepository.GetManyUnassignedOrganizationDetailsByOrganizationIdAsync( + organizationId)); + + // Users that can access unassigned ciphers can also edit them + editableOrgCipherList.AddRange(unassignedCiphers.Select(c => new CipherDetails(c) { Edit = true })); + } + + var editableOrgCiphers = editableOrgCipherList + .ToDictionary(c => c.Id); + + if (cipherIds.Any(c => !editableOrgCiphers.ContainsKey(c))) + { + return false; + } + + return true; + } + + /// + /// TODO: Move this to its own authorization handler or equivalent service - AC-2062 + /// This likely belongs to the BulkCollectionAuthorizationHandler + /// + private async Task CanEditItemsInCollections(Guid organizationId, IEnumerable collectionIds) + { + if (await CanEditAllCiphersAsync(organizationId)) + { + // TODO: This can likely be optimized to only query the requested ciphers and then checking they belong to the org + var orgCollections = (await _collectionRepository.GetManyByOrganizationIdAsync(organizationId)).ToDictionary(c => c.Id); + + // Ensure all requested collections are in orgCollections + if (collectionIds.Any(c => !orgCollections.ContainsKey(c))) + { + return false; + } + + return true; + } + + if (!await CanAccessOrganizationCiphersAsync(organizationId)) + { + return false; + } + + var userId = _userService.GetProperUserId(User).Value; + var editableCollections = (await _collectionRepository.GetManyByUserIdAsync(userId, true)) + .Where(c => c.OrganizationId == organizationId && !c.ReadOnly) + .ToDictionary(c => c.Id); + + if (collectionIds.Any(c => !editableCollections.ContainsKey(c))) + { + return false; + } + + return true; + } + [HttpPut("{id}/partial")] [HttpPost("{id}/partial")] public async Task PutPartial(Guid id, [FromBody] CipherPartialRequestModel model) @@ -457,6 +590,33 @@ public class CiphersController : Controller model.CollectionIds.Select(c => new Guid(c)), userId, true); } + [HttpPost("bulk-collections")] + public async Task PostBulkCollections([FromBody] CipherBulkUpdateCollectionsRequestModel model) + { + var orgAbility = await _applicationCacheService.GetOrganizationAbilityAsync(model.OrganizationId); + + // Only available for organizations with flexible collections + if (orgAbility is null or { FlexibleCollections: false }) + { + throw new NotFoundException(); + } + + if (!await CanEditCiphersAsync(model.OrganizationId, model.CipherIds) || + !await CanEditItemsInCollections(model.OrganizationId, model.CollectionIds)) + { + throw new NotFoundException(); + } + + if (model.RemoveCollections) + { + await _collectionCipherRepository.RemoveCollectionsForManyCiphersAsync(model.OrganizationId, model.CipherIds, model.CollectionIds); + } + else + { + await _collectionCipherRepository.AddCollectionsForManyCiphersAsync(model.OrganizationId, model.CipherIds, model.CollectionIds); + } + } + [HttpDelete("{id}")] [HttpPost("{id}/delete")] public async Task Delete(Guid id) diff --git a/src/Api/Vault/Models/Request/CipherBulkUpdateCollectionsRequestModel.cs b/src/Api/Vault/Models/Request/CipherBulkUpdateCollectionsRequestModel.cs new file mode 100644 index 000000000..54d67995d --- /dev/null +++ b/src/Api/Vault/Models/Request/CipherBulkUpdateCollectionsRequestModel.cs @@ -0,0 +1,15 @@ +namespace Bit.Api.Vault.Models.Request; + +public class CipherBulkUpdateCollectionsRequestModel +{ + public Guid OrganizationId { get; set; } + + public IEnumerable CipherIds { get; set; } + + public IEnumerable CollectionIds { get; set; } + + /// + /// If true, the collections will be removed from the ciphers. Otherwise, they will be added. + /// + public bool RemoveCollections { get; set; } +} diff --git a/src/Core/Repositories/ICollectionCipherRepository.cs b/src/Core/Repositories/ICollectionCipherRepository.cs index 5bf00c614..aa3881439 100644 --- a/src/Core/Repositories/ICollectionCipherRepository.cs +++ b/src/Core/Repositories/ICollectionCipherRepository.cs @@ -11,4 +11,22 @@ public interface ICollectionCipherRepository Task UpdateCollectionsForAdminAsync(Guid cipherId, Guid organizationId, IEnumerable collectionIds); Task UpdateCollectionsForCiphersAsync(IEnumerable cipherIds, Guid userId, Guid organizationId, IEnumerable collectionIds, bool useFlexibleCollections); + + /// + /// Add the specified collections to the specified ciphers. If a cipher already belongs to a requested collection, + /// no action is taken. + /// + /// + /// This method does not perform any authorization checks. + /// + Task AddCollectionsForManyCiphersAsync(Guid organizationId, IEnumerable cipherIds, IEnumerable collectionIds); + + /// + /// Remove the specified collections from the specified ciphers. If a cipher does not belong to a requested collection, + /// no action is taken. + /// + /// + /// This method does not perform any authorization checks. + /// + Task RemoveCollectionsForManyCiphersAsync(Guid organizationId, IEnumerable cipherIds, IEnumerable collectionIds); } diff --git a/src/Core/Vault/Models/Data/CipherDetails.cs b/src/Core/Vault/Models/Data/CipherDetails.cs index a1b6e7ea0..716b49ca4 100644 --- a/src/Core/Vault/Models/Data/CipherDetails.cs +++ b/src/Core/Vault/Models/Data/CipherDetails.cs @@ -8,6 +8,26 @@ public class CipherDetails : CipherOrganizationDetails public bool Favorite { get; set; } public bool Edit { get; set; } public bool ViewPassword { get; set; } + + public CipherDetails() { } + + public CipherDetails(CipherOrganizationDetails cipher) + { + Id = cipher.Id; + UserId = cipher.UserId; + OrganizationId = cipher.OrganizationId; + Type = cipher.Type; + Data = cipher.Data; + Favorites = cipher.Favorites; + Folders = cipher.Folders; + Attachments = cipher.Attachments; + CreationDate = cipher.CreationDate; + RevisionDate = cipher.RevisionDate; + DeletedDate = cipher.DeletedDate; + Reprompt = cipher.Reprompt; + Key = cipher.Key; + OrganizationUseTotp = cipher.OrganizationUseTotp; + } } public class CipherDetailsWithCollections : CipherDetails diff --git a/src/Infrastructure.Dapper/Repositories/CollectionCipherRepository.cs b/src/Infrastructure.Dapper/Repositories/CollectionCipherRepository.cs index 7d80ee129..754a45faf 100644 --- a/src/Infrastructure.Dapper/Repositories/CollectionCipherRepository.cs +++ b/src/Infrastructure.Dapper/Repositories/CollectionCipherRepository.cs @@ -111,4 +111,28 @@ public class CollectionCipherRepository : BaseRepository, ICollectionCipherRepos commandType: CommandType.StoredProcedure); } } + + public async Task AddCollectionsForManyCiphersAsync(Guid organizationId, IEnumerable cipherIds, + IEnumerable collectionIds) + { + using (var connection = new SqlConnection(ConnectionString)) + { + await connection.ExecuteAsync( + "[dbo].[CollectionCipher_AddCollectionsForManyCiphers]", + new { CipherIds = cipherIds.ToGuidIdArrayTVP(), OrganizationId = organizationId, CollectionIds = collectionIds.ToGuidIdArrayTVP() }, + commandType: CommandType.StoredProcedure); + } + } + + public async Task RemoveCollectionsForManyCiphersAsync(Guid organizationId, IEnumerable cipherIds, + IEnumerable collectionIds) + { + using (var connection = new SqlConnection(ConnectionString)) + { + await connection.ExecuteAsync( + "[dbo].[CollectionCipher_RemoveCollectionsForManyCiphers]", + new { CipherIds = cipherIds.ToGuidIdArrayTVP(), OrganizationId = organizationId, CollectionIds = collectionIds.ToGuidIdArrayTVP() }, + commandType: CommandType.StoredProcedure); + } + } } diff --git a/src/Infrastructure.EntityFramework/Repositories/CollectionCipherRepository.cs b/src/Infrastructure.EntityFramework/Repositories/CollectionCipherRepository.cs index 7ef9b1967..df854dc61 100644 --- a/src/Infrastructure.EntityFramework/Repositories/CollectionCipherRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/CollectionCipherRepository.cs @@ -248,4 +248,47 @@ public class CollectionCipherRepository : BaseEntityFrameworkRepository, ICollec await dbContext.SaveChangesAsync(); } } + + public async Task AddCollectionsForManyCiphersAsync(Guid organizationId, IEnumerable cipherIds, + IEnumerable collectionIds) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + var availableCollections = await (from c in dbContext.Collections + join o in dbContext.Organizations on c.OrganizationId equals o.Id + where o.Id == organizationId && o.Enabled + select c).ToListAsync(); + + var currentCollectionCiphers = await (from cc in dbContext.CollectionCiphers + where cipherIds.Contains(cc.CipherId) + select cc).ToListAsync(); + + var insertData = from collectionId in collectionIds + from cipherId in cipherIds + where + availableCollections.Select(c => c.Id).Contains(collectionId) && + !currentCollectionCiphers.Any(cc => cc.CipherId == cipherId && cc.CollectionId == collectionId) + select new Models.CollectionCipher { CollectionId = collectionId, CipherId = cipherId, }; + + await dbContext.AddRangeAsync(insertData); + await dbContext.UserBumpAccountRevisionDateByOrganizationIdAsync(organizationId); + await dbContext.SaveChangesAsync(); + } + } + + public async Task RemoveCollectionsForManyCiphersAsync(Guid organizationId, IEnumerable cipherIds, + IEnumerable collectionIds) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + var currentCollectionCiphersToBeRemoved = await (from cc in dbContext.CollectionCiphers + where cipherIds.Contains(cc.CipherId) && collectionIds.Contains(cc.CollectionId) + select cc).ToListAsync(); + dbContext.RemoveRange(currentCollectionCiphersToBeRemoved); + await dbContext.UserBumpAccountRevisionDateByOrganizationIdAsync(organizationId); + await dbContext.SaveChangesAsync(); + } + } } diff --git a/src/Sql/Vault/dbo/Stored Procedures/CollectionCipher/CollectionCipher_AddCollectionsForManyCiphers.sql b/src/Sql/Vault/dbo/Stored Procedures/CollectionCipher/CollectionCipher_AddCollectionsForManyCiphers.sql new file mode 100644 index 000000000..229d00436 --- /dev/null +++ b/src/Sql/Vault/dbo/Stored Procedures/CollectionCipher/CollectionCipher_AddCollectionsForManyCiphers.sql @@ -0,0 +1,56 @@ +CREATE PROCEDURE [dbo].[CollectionCipher_AddCollectionsForManyCiphers] + @CipherIds AS [dbo].[GuidIdArray] READONLY, + @OrganizationId UNIQUEIDENTIFIER, + @CollectionIds AS [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + CREATE TABLE #AvailableCollections ( + [Id] UNIQUEIDENTIFIER + ) + + INSERT INTO #AvailableCollections + SELECT + C.[Id] + FROM + [dbo].[Collection] C + INNER JOIN + [dbo].[Organization] O ON O.[Id] = C.[OrganizationId] + WHERE + O.[Id] = @OrganizationId AND O.[Enabled] = 1 + + IF (SELECT COUNT(1) FROM #AvailableCollections) < 1 + BEGIN + -- No collections available + RETURN + END + + ;WITH [SourceCollectionCipherCTE] AS( + SELECT + [Collection].[Id] AS [CollectionId], + [Cipher].[Id] AS [CipherId] + FROM + @CollectionIds AS [Collection] + CROSS JOIN + @CipherIds AS [Cipher] + WHERE + [Collection].[Id] IN (SELECT [Id] FROM #AvailableCollections) + ) + MERGE + [CollectionCipher] AS [Target] + USING + [SourceCollectionCipherCTE] AS [Source] + ON + [Target].[CollectionId] = [Source].[CollectionId] + AND [Target].[CipherId] = [Source].[CipherId] + WHEN NOT MATCHED BY TARGET THEN + INSERT VALUES + ( + [Source].[CollectionId], + [Source].[CipherId] + ) + ; + + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId +END diff --git a/src/Sql/Vault/dbo/Stored Procedures/CollectionCipher/CollectionCipher_RemoveCollectionsFromManyCiphers.sql b/src/Sql/Vault/dbo/Stored Procedures/CollectionCipher/CollectionCipher_RemoveCollectionsFromManyCiphers.sql new file mode 100644 index 000000000..b0b0ffc4f --- /dev/null +++ b/src/Sql/Vault/dbo/Stored Procedures/CollectionCipher/CollectionCipher_RemoveCollectionsFromManyCiphers.sql @@ -0,0 +1,26 @@ +CREATE PROCEDURE [dbo].[CollectionCipher_RemoveCollectionsForManyCiphers] + @CipherIds AS [dbo].[GuidIdArray] READONLY, + @OrganizationId UNIQUEIDENTIFIER, + @CollectionIds AS [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + DECLARE @BatchSize INT = 100 + + WHILE @BatchSize > 0 + BEGIN + BEGIN TRANSACTION CollectionCipher_DeleteMany + DELETE TOP(@BatchSize) + FROM + [dbo].[CollectionCipher] + WHERE + [CipherId] IN (SELECT [Id] FROM @CipherIds) AND + [CollectionId] IN (SELECT [Id] FROM @CollectionIds) + + SET @BatchSize = @@ROWCOUNT + COMMIT TRANSACTION CollectionCipher_DeleteMany + END + + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId +END diff --git a/util/Migrator/DbScripts/2024-03-20_00_BulkCipherCollectionAssignment.sql b/util/Migrator/DbScripts/2024-03-20_00_BulkCipherCollectionAssignment.sql new file mode 100644 index 000000000..899f39f08 --- /dev/null +++ b/util/Migrator/DbScripts/2024-03-20_00_BulkCipherCollectionAssignment.sql @@ -0,0 +1,85 @@ +CREATE OR ALTER PROCEDURE [dbo].[CollectionCipher_AddCollectionsForManyCiphers] + @CipherIds AS [dbo].[GuidIdArray] READONLY, + @OrganizationId UNIQUEIDENTIFIER, + @CollectionIds AS [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + CREATE TABLE #AvailableCollections ( + [Id] UNIQUEIDENTIFIER + ) + + INSERT INTO #AvailableCollections + SELECT + C.[Id] + FROM + [dbo].[Collection] C + INNER JOIN + [dbo].[Organization] O ON O.[Id] = C.[OrganizationId] + WHERE + O.[Id] = @OrganizationId AND O.[Enabled] = 1 + + IF (SELECT COUNT(1) FROM #AvailableCollections) < 1 + BEGIN + -- No collections available + RETURN + END + + ;WITH [SourceCollectionCipherCTE] AS( + SELECT + [Collection].[Id] AS [CollectionId], + [Cipher].[Id] AS [CipherId] + FROM + @CollectionIds AS [Collection] + CROSS JOIN + @CipherIds AS [Cipher] + WHERE + [Collection].[Id] IN (SELECT [Id] FROM #AvailableCollections) + ) + MERGE + [CollectionCipher] AS [Target] + USING + [SourceCollectionCipherCTE] AS [Source] + ON + [Target].[CollectionId] = [Source].[CollectionId] + AND [Target].[CipherId] = [Source].[CipherId] + WHEN NOT MATCHED BY TARGET THEN + INSERT VALUES + ( + [Source].[CollectionId], + [Source].[CipherId] + ) + ; + + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[CollectionCipher_RemoveCollectionsForManyCiphers] + @CipherIds AS [dbo].[GuidIdArray] READONLY, + @OrganizationId UNIQUEIDENTIFIER, + @CollectionIds AS [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + DECLARE @BatchSize INT = 100 + + WHILE @BatchSize > 0 + BEGIN + BEGIN TRANSACTION CollectionCipher_DeleteMany + DELETE TOP(@BatchSize) + FROM + [dbo].[CollectionCipher] + WHERE + [CipherId] IN (SELECT [Id] FROM @CipherIds) AND + [CollectionId] IN (SELECT [Id] FROM @CollectionIds) + + SET @BatchSize = @@ROWCOUNT + COMMIT TRANSACTION CollectionCipher_DeleteMany + END + + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId +END +GO