From 229478adaec74fea2173c98740bef0812d49be67 Mon Sep 17 00:00:00 2001 From: Addison Beck Date: Wed, 22 Jul 2020 11:38:53 -0500 Subject: [PATCH] Feature.web.534.allow multi select in org vault (#830) * Set up API methods for bulk admin delete --- src/Api/Controllers/CiphersController.cs | 42 +++++++++++++- .../Models/Api/Request/CipherRequestModel.cs | 1 + src/Core/Repositories/ICipherRepository.cs | 2 + .../SqlServer/CipherRepository.cs | 22 ++++++++ src/Core/Services/ICipherService.cs | 4 +- .../Services/Implementations/CipherService.cs | 36 +++++++++--- .../Cipher_DeleteByIdsOrganizationId.sql | 19 +++++++ .../Cipher_SoftDeleteByIdsOrganizationId.sql | 22 ++++++++ ...0-07-21_00_BulkDeleteCiphersAsOrgAdmin.sql | 55 +++++++++++++++++++ 9 files changed, 191 insertions(+), 12 deletions(-) create mode 100644 src/Sql/dbo/Stored Procedures/Cipher_DeleteByIdsOrganizationId.sql create mode 100644 src/Sql/dbo/Stored Procedures/Cipher_SoftDeleteByIdsOrganizationId.sql create mode 100644 util/Migrator/DbScripts/2020-07-21_00_BulkDeleteCiphersAsOrgAdmin.sql diff --git a/src/Api/Controllers/CiphersController.cs b/src/Api/Controllers/CiphersController.cs index ff21749f77..98d3cf998c 100644 --- a/src/Api/Controllers/CiphersController.cs +++ b/src/Api/Controllers/CiphersController.cs @@ -360,6 +360,26 @@ namespace Bit.Api.Controllers await _cipherService.DeleteManyAsync(model.Ids.Select(i => new Guid(i)), userId); } + [HttpDelete("admin")] + [HttpPost("delete-admin")] + public async Task DeleteManyAdmin([FromBody]CipherBulkDeleteRequestModel model) + { + if (!_globalSettings.SelfHosted && model.Ids.Count() > 500) + { + throw new BadRequestException("You can only delete up to 500 items at a time. " + + "Consider using the \"Purge Vault\" option instead."); + } + + if (model == null || string.IsNullOrWhiteSpace(model.OrganizationId) || + !_currentContext.OrganizationAdmin(new Guid(model.OrganizationId))) + { + throw new NotFoundException(); + } + + var userId = _userService.GetProperUserId(User).Value; + await _cipherService.DeleteManyAsync(model.Ids.Select(i => new Guid(i)), userId, new Guid(model.OrganizationId), true); + } + [HttpPut("{id}/delete")] public async Task PutDelete(string id) { @@ -387,17 +407,35 @@ namespace Bit.Api.Controllers } [HttpPut("delete")] - public async Task PutDeleteMany([FromBody]CipherBulkRestoreRequestModel model) + public async Task PutDeleteMany([FromBody]CipherBulkDeleteRequestModel model) { if (!_globalSettings.SelfHosted && model.Ids.Count() > 500) { - throw new BadRequestException("You can only restore up to 500 items at a time."); + throw new BadRequestException("You can only delete up to 500 items at a time."); } var userId = _userService.GetProperUserId(User).Value; await _cipherService.SoftDeleteManyAsync(model.Ids.Select(i => new Guid(i)), userId); } + [HttpPut("delete-admin")] + public async Task PutDeleteManyAdmin([FromBody]CipherBulkDeleteRequestModel model) + { + if (!_globalSettings.SelfHosted && model.Ids.Count() > 500) + { + throw new BadRequestException("You can only delete up to 500 items at a time."); + } + + if (model == null || string.IsNullOrWhiteSpace(model.OrganizationId) || + !_currentContext.OrganizationAdmin(new Guid(model.OrganizationId))) + { + throw new NotFoundException(); + } + + var userId = _userService.GetProperUserId(User).Value; + await _cipherService.SoftDeleteManyAsync(model.Ids.Select(i => new Guid(i)), userId, new Guid(model.OrganizationId), true); + } + [HttpPut("{id}/restore")] public async Task PutRestore(string id) { diff --git a/src/Core/Models/Api/Request/CipherRequestModel.cs b/src/Core/Models/Api/Request/CipherRequestModel.cs index 5ffa9c0a26..38b93a2db4 100644 --- a/src/Core/Models/Api/Request/CipherRequestModel.cs +++ b/src/Core/Models/Api/Request/CipherRequestModel.cs @@ -204,6 +204,7 @@ namespace Bit.Core.Models.Api { [Required] public IEnumerable Ids { get; set; } + public string OrganizationId { get; set; } } public class CipherBulkRestoreRequestModel diff --git a/src/Core/Repositories/ICipherRepository.cs b/src/Core/Repositories/ICipherRepository.cs index 6f004118fb..afb0fdc6f4 100644 --- a/src/Core/Repositories/ICipherRepository.cs +++ b/src/Core/Repositories/ICipherRepository.cs @@ -24,6 +24,7 @@ namespace Bit.Core.Repositories Task UpdateAttachmentAsync(CipherAttachment attachment); Task DeleteAttachmentAsync(Guid cipherId, string attachmentId); Task DeleteAsync(IEnumerable ids, Guid userId); + Task DeleteByIdsOrganizationIdAsync(IEnumerable ids, Guid organizationId); Task MoveAsync(IEnumerable ids, Guid? folderId, Guid userId); Task DeleteByUserIdAsync(Guid userId); Task DeleteByOrganizationIdAsync(Guid organizationId); @@ -33,6 +34,7 @@ namespace Bit.Core.Repositories Task CreateAsync(IEnumerable ciphers, IEnumerable collections, IEnumerable collectionCiphers); Task SoftDeleteAsync(IEnumerable ids, Guid userId); + Task SoftDeleteByIdsOrganizationIdAsync(IEnumerable ids, Guid organizationId); Task RestoreAsync(IEnumerable ids, Guid userId); } } diff --git a/src/Core/Repositories/SqlServer/CipherRepository.cs b/src/Core/Repositories/SqlServer/CipherRepository.cs index 1f9d52d079..2ff6d22f43 100644 --- a/src/Core/Repositories/SqlServer/CipherRepository.cs +++ b/src/Core/Repositories/SqlServer/CipherRepository.cs @@ -226,6 +226,28 @@ namespace Bit.Core.Repositories.SqlServer } } + public async Task DeleteByIdsOrganizationIdAsync(IEnumerable ids, Guid organizationId) + { + using (var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.ExecuteAsync( + $"[{Schema}].[Cipher_DeleteByIdsOrganizationId]", + new { Ids = ids.ToGuidIdArrayTVP(), OrganizationId = organizationId }, + commandType: CommandType.StoredProcedure); + } + } + + public async Task SoftDeleteByIdsOrganizationIdAsync(IEnumerable ids, Guid organizationId) + { + using (var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.ExecuteAsync( + $"[{Schema}].[Cipher_SoftDeleteByIdsOrganizationId]", + new { Ids = ids.ToGuidIdArrayTVP(), OrganizationId = organizationId }, + commandType: CommandType.StoredProcedure); + } + } + public async Task MoveAsync(IEnumerable ids, Guid? folderId, Guid userId) { using (var connection = new SqlConnection(ConnectionString)) diff --git a/src/Core/Services/ICipherService.cs b/src/Core/Services/ICipherService.cs index 0090f34e38..ebf749eb11 100644 --- a/src/Core/Services/ICipherService.cs +++ b/src/Core/Services/ICipherService.cs @@ -18,7 +18,7 @@ namespace Bit.Core.Services Task CreateAttachmentShareAsync(Cipher cipher, Stream stream, long requestLength, string attachmentId, Guid organizationShareId); Task DeleteAsync(Cipher cipher, Guid deletingUserId, bool orgAdmin = false); - Task DeleteManyAsync(IEnumerable cipherIds, Guid deletingUserId); + Task DeleteManyAsync(IEnumerable cipherIds, Guid deletingUserId, Guid? organizationId = null, bool orgAdmin = false); Task DeleteAttachmentAsync(Cipher cipher, string attachmentId, Guid deletingUserId, bool orgAdmin = false); Task PurgeAsync(Guid organizationId); Task MoveManyAsync(IEnumerable cipherIds, Guid? destinationFolderId, Guid movingUserId); @@ -34,7 +34,7 @@ namespace Bit.Core.Services Task ImportCiphersAsync(List collections, List ciphers, IEnumerable> collectionRelationships, Guid importingUserId); Task SoftDeleteAsync(Cipher cipher, Guid deletingUserId, bool orgAdmin = false); - Task SoftDeleteManyAsync(IEnumerable cipherIds, Guid deletingUserId); + Task SoftDeleteManyAsync(IEnumerable cipherIds, Guid deletingUserId, Guid? organizationId = null, bool orgAdmin = false); Task RestoreAsync(Cipher cipher, Guid restoringUserId, bool orgAdmin = false); Task RestoreManyAsync(IEnumerable cipherIds, Guid restoringUserId); } diff --git a/src/Core/Services/Implementations/CipherService.cs b/src/Core/Services/Implementations/CipherService.cs index d04425e426..6529b2e7a6 100644 --- a/src/Core/Services/Implementations/CipherService.cs +++ b/src/Core/Services/Implementations/CipherService.cs @@ -288,13 +288,23 @@ namespace Bit.Core.Services await _pushService.PushSyncCipherDeleteAsync(cipher); } - public async Task DeleteManyAsync(IEnumerable cipherIds, Guid deletingUserId) + public async Task DeleteManyAsync(IEnumerable cipherIds, Guid deletingUserId, Guid? organizationId = null, bool orgAdmin = false) { var cipherIdsSet = new HashSet(cipherIds); - var ciphers = await _cipherRepository.GetManyByUserIdAsync(deletingUserId); - var deletingCiphers = ciphers.Where(c => cipherIdsSet.Contains(c.Id) && c.Edit); + var deletingCiphers = new List(); - await _cipherRepository.DeleteAsync(cipherIds, deletingUserId); + if (orgAdmin && organizationId.HasValue) + { + var ciphers = await _cipherRepository.GetManyByOrganizationIdAsync(organizationId.Value); + deletingCiphers = ciphers.Where(c => cipherIdsSet.Contains(c.Id)).ToList(); + await _cipherRepository.DeleteByIdsOrganizationIdAsync(deletingCiphers.Select(c => c.Id), organizationId.Value); + } + else + { + var ciphers = await _cipherRepository.GetManyByUserIdAsync(deletingUserId); + deletingCiphers = ciphers.Where(c => cipherIdsSet.Contains(c.Id) && c.Edit).Select(x => (Cipher)x).ToList(); + await _cipherRepository.DeleteAsync(deletingCiphers.Select(c => c.Id), deletingUserId); + } var events = deletingCiphers.Select(c => new Tuple(c, EventType.Cipher_Deleted, null)); @@ -693,13 +703,23 @@ namespace Bit.Core.Services await _pushService.PushSyncCipherUpdateAsync(cipher, null); } - public async Task SoftDeleteManyAsync(IEnumerable cipherIds, Guid deletingUserId) + public async Task SoftDeleteManyAsync(IEnumerable cipherIds, Guid deletingUserId, Guid? organizationId, bool orgAdmin) { var cipherIdsSet = new HashSet(cipherIds); - var ciphers = await _cipherRepository.GetManyByUserIdAsync(deletingUserId); - var deletingCiphers = ciphers.Where(c => cipherIdsSet.Contains(c.Id) && c.Edit); + var deletingCiphers = new List(); - await _cipherRepository.SoftDeleteAsync(cipherIds, deletingUserId); + if (orgAdmin && organizationId.HasValue) + { + var ciphers = await _cipherRepository.GetManyByOrganizationIdAsync(organizationId.Value); + deletingCiphers = ciphers.Where(c => cipherIdsSet.Contains(c.Id)).ToList(); + await _cipherRepository.SoftDeleteByIdsOrganizationIdAsync(deletingCiphers.Select(c => c.Id), organizationId.Value); + } + else + { + var ciphers = await _cipherRepository.GetManyByUserIdAsync(deletingUserId); + deletingCiphers = ciphers.Where(c => cipherIdsSet.Contains(c.Id) && c.Edit).Select(x => (Cipher)x).ToList(); + await _cipherRepository.SoftDeleteAsync(deletingCiphers.Select(c => c.Id), deletingUserId); + } var events = deletingCiphers.Select(c => new Tuple(c, EventType.Cipher_SoftDeleted, null)); diff --git a/src/Sql/dbo/Stored Procedures/Cipher_DeleteByIdsOrganizationId.sql b/src/Sql/dbo/Stored Procedures/Cipher_DeleteByIdsOrganizationId.sql new file mode 100644 index 0000000000..b159b2dcf9 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/Cipher_DeleteByIdsOrganizationId.sql @@ -0,0 +1,19 @@ +CREATE PROCEDURE [dbo].[Cipher_DeleteByIdsOrganizationId] + @Ids AS [dbo].[GuidIdArray] READONLY, + @OrganizationId AS UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + -- Delete ciphers + DELETE + FROM + [dbo].[Cipher] + WHERE + [Id] IN (SELECT * FROM @Ids) + AND OrganizationId = @OrganizationId + + -- Cleanup organization + EXEC [dbo].[Organization_UpdateStorage] @OrganizationId + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId +END \ No newline at end of file diff --git a/src/Sql/dbo/Stored Procedures/Cipher_SoftDeleteByIdsOrganizationId.sql b/src/Sql/dbo/Stored Procedures/Cipher_SoftDeleteByIdsOrganizationId.sql new file mode 100644 index 0000000000..7ed769a7dd --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/Cipher_SoftDeleteByIdsOrganizationId.sql @@ -0,0 +1,22 @@ +CREATE PROCEDURE [dbo].[Cipher_SoftDeleteByIdsOrganizationId] + @Ids AS [dbo].[GuidIdArray] READONLY, + @OrganizationId AS UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + -- Delete ciphers + DECLARE @UtcNow DATETIME2(7) = GETUTCDATE(); + UPDATE + [dbo].[Cipher] + SET + [DeletedDate] = @UtcNow, + [RevisionDate] = @UtcNow + WHERE + [Id] IN (SELECT * FROM @Ids) + AND OrganizationId = @OrganizationId + + -- Cleanup organization + EXEC [dbo].[Organization_UpdateStorage] @OrganizationId + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId +END \ No newline at end of file diff --git a/util/Migrator/DbScripts/2020-07-21_00_BulkDeleteCiphersAsOrgAdmin.sql b/util/Migrator/DbScripts/2020-07-21_00_BulkDeleteCiphersAsOrgAdmin.sql new file mode 100644 index 0000000000..a7ec8d4f22 --- /dev/null +++ b/util/Migrator/DbScripts/2020-07-21_00_BulkDeleteCiphersAsOrgAdmin.sql @@ -0,0 +1,55 @@ +IF OBJECT_ID('[dbo].[Cipher_DeleteByIdsOrganizationId]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[Cipher_DeleteByIdsOrganizationId]; +END +GO + +IF OBJECT_ID('[dbo].[Cipher_SoftDeleteByIdsOrganizationId]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[Cipher_SoftDeleteByIdsOrganizationId]; +END +GO + +CREATE PROCEDURE [dbo].[Cipher_DeleteByIdsOrganizationId] + @Ids AS [dbo].[GuidIdArray] READONLY, + @OrganizationId AS UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + -- Delete ciphers + DELETE + FROM + [dbo].[Cipher] + WHERE + [Id] IN (SELECT * FROM @Ids) + AND OrganizationId = @OrganizationId + + -- Cleanup organization + EXEC [dbo].[Organization_UpdateStorage] @OrganizationId + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId +END +GO + +CREATE PROCEDURE [dbo].[Cipher_SoftDeleteByIdsOrganizationId] + @Ids AS [dbo].[GuidIdArray] READONLY, + @OrganizationId AS UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + -- Delete ciphers + DECLARE @UtcNow DATETIME2(7) = GETUTCDATE(); + UPDATE + [dbo].[Cipher] + SET + [DeletedDate] = @UtcNow, + [RevisionDate] = @UtcNow + WHERE + [Id] IN (SELECT * FROM @Ids) + AND OrganizationId = @OrganizationId + + -- Cleanup organization + EXEC [dbo].[Organization_UpdateStorage] @OrganizationId + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId +END