From c591997d0136a6126308444068cfa89f604c531f Mon Sep 17 00:00:00 2001 From: Brandon Treston Date: Fri, 6 Dec 2024 14:40:47 -0500 Subject: [PATCH] [PM-13013] add delete many async method to i user repository and i user service for bulk user deletion (#5035) * Add DeleteManyAsync method and stored procedure * Add DeleteManyAsync and tests * removed stored procedure, refactor User_DeleteById to accept multiple Ids * add sproc, refactor tests * revert existing sproc * add bulk delete to IUserService * fix sproc * fix and add tests * add migration script, fix test * Add feature flag * add feature flag to tests for deleteManyAsync * enable nullable, delete only user that pass validation * revert changes to DeleteAsync * Cleanup whitespace * remove redundant feature flag * fix tests * move DeleteManyAsync from UserService into DeleteManagedOrganizationUserAccountCommand * refactor validation, remove unneeded tasks * refactor tests, remove unused service --- ...teManagedOrganizationUserAccountCommand.cs | 86 +++++++++- src/Core/Repositories/IUserRepository.cs | 1 + .../Repositories/UserRepository.cs | 12 ++ .../Repositories/UserRepository.cs | 47 ++++++ .../Stored Procedures/User_DeleteByIds.sql | 158 ++++++++++++++++++ ...agedOrganizationUserAccountCommandTests.cs | 12 +- .../Auth/Repositories/UserRepositoryTests.cs | 99 +++++++++++ .../2024-11-22_00_UserDeleteByIds.sql | 158 ++++++++++++++++++ 8 files changed, 565 insertions(+), 8 deletions(-) create mode 100644 src/Sql/dbo/Stored Procedures/User_DeleteByIds.sql create mode 100644 test/Infrastructure.IntegrationTest/Auth/Repositories/UserRepositoryTests.cs create mode 100644 util/Migrator/DbScripts/2024-11-22_00_UserDeleteByIds.sql diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteManagedOrganizationUserAccountCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteManagedOrganizationUserAccountCommand.cs index 0bcd16cee..cb7e2a625 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteManagedOrganizationUserAccountCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteManagedOrganizationUserAccountCommand.cs @@ -1,10 +1,14 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.Repositories; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.Services; +using Bit.Core.Tools.Enums; +using Bit.Core.Tools.Models.Business; +using Bit.Core.Tools.Services; #nullable enable @@ -19,7 +23,10 @@ public class DeleteManagedOrganizationUserAccountCommand : IDeleteManagedOrganiz private readonly IUserRepository _userRepository; private readonly ICurrentContext _currentContext; private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery; - + private readonly IReferenceEventService _referenceEventService; + private readonly IPushNotificationService _pushService; + private readonly IOrganizationRepository _organizationRepository; + private readonly IProviderUserRepository _providerUserRepository; public DeleteManagedOrganizationUserAccountCommand( IUserService userService, IEventService eventService, @@ -27,7 +34,11 @@ public class DeleteManagedOrganizationUserAccountCommand : IDeleteManagedOrganiz IOrganizationUserRepository organizationUserRepository, IUserRepository userRepository, ICurrentContext currentContext, - IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery) + IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery, + IReferenceEventService referenceEventService, + IPushNotificationService pushService, + IOrganizationRepository organizationRepository, + IProviderUserRepository providerUserRepository) { _userService = userService; _eventService = eventService; @@ -36,6 +47,10 @@ public class DeleteManagedOrganizationUserAccountCommand : IDeleteManagedOrganiz _userRepository = userRepository; _currentContext = currentContext; _hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery; + _referenceEventService = referenceEventService; + _pushService = pushService; + _organizationRepository = organizationRepository; + _providerUserRepository = providerUserRepository; } public async Task DeleteUserAsync(Guid organizationId, Guid organizationUserId, Guid? deletingUserId) @@ -89,7 +104,8 @@ public class DeleteManagedOrganizationUserAccountCommand : IDeleteManagedOrganiz throw new NotFoundException("Member not found."); } - await _userService.DeleteAsync(user); + await ValidateUserMembershipAndPremiumAsync(user); + results.Add((orgUserId, string.Empty)); } catch (Exception ex) @@ -98,6 +114,15 @@ public class DeleteManagedOrganizationUserAccountCommand : IDeleteManagedOrganiz } } + var orgUserResultsToDelete = results.Where(result => string.IsNullOrEmpty(result.ErrorMessage)); + var orgUsersToDelete = orgUsers.Where(orgUser => orgUserResultsToDelete.Any(result => orgUser.Id == result.OrganizationUserId)); + var usersToDelete = users.Where(user => orgUsersToDelete.Any(orgUser => orgUser.UserId == user.Id)); + + if (usersToDelete.Any()) + { + await DeleteManyAsync(usersToDelete); + } + await LogDeletedOrganizationUsersAsync(orgUsers, results); return results; @@ -158,4 +183,59 @@ public class DeleteManagedOrganizationUserAccountCommand : IDeleteManagedOrganiz await _eventService.LogOrganizationUserEventsAsync(events); } } + private async Task DeleteManyAsync(IEnumerable users) + { + + await _userRepository.DeleteManyAsync(users); + foreach (var user in users) + { + await _referenceEventService.RaiseEventAsync( + new ReferenceEvent(ReferenceEventType.DeleteAccount, user, _currentContext)); + await _pushService.PushLogOutAsync(user.Id); + } + + } + + private async Task ValidateUserMembershipAndPremiumAsync(User user) + { + // Check if user is the only owner of any organizations. + var onlyOwnerCount = await _organizationUserRepository.GetCountByOnlyOwnerAsync(user.Id); + if (onlyOwnerCount > 0) + { + throw new BadRequestException("Cannot delete this user because it is the sole owner of at least one organization. Please delete these organizations or upgrade another user."); + } + + var orgs = await _organizationUserRepository.GetManyDetailsByUserAsync(user.Id, OrganizationUserStatusType.Confirmed); + if (orgs.Count == 1) + { + var org = await _organizationRepository.GetByIdAsync(orgs.First().OrganizationId); + if (org != null && (!org.Enabled || string.IsNullOrWhiteSpace(org.GatewaySubscriptionId))) + { + var orgCount = await _organizationUserRepository.GetCountByOrganizationIdAsync(org.Id); + if (orgCount <= 1) + { + await _organizationRepository.DeleteAsync(org); + } + else + { + throw new BadRequestException("Cannot delete this user because it is the sole owner of at least one organization. Please delete these organizations or upgrade another user."); + } + } + } + + var onlyOwnerProviderCount = await _providerUserRepository.GetCountByOnlyOwnerAsync(user.Id); + if (onlyOwnerProviderCount > 0) + { + throw new BadRequestException("Cannot delete this user because it is the sole owner of at least one provider. Please delete these providers or upgrade another user."); + } + + if (!string.IsNullOrWhiteSpace(user.GatewaySubscriptionId)) + { + try + { + await _userService.CancelPremiumAsync(user); + } + catch (GatewayException) { } + } + } } diff --git a/src/Core/Repositories/IUserRepository.cs b/src/Core/Repositories/IUserRepository.cs index 22e2ec1a0..040e6e1f4 100644 --- a/src/Core/Repositories/IUserRepository.cs +++ b/src/Core/Repositories/IUserRepository.cs @@ -32,4 +32,5 @@ public interface IUserRepository : IRepository /// Registered database calls to update re-encrypted data. Task UpdateUserKeyAndEncryptedDataAsync(User user, IEnumerable updateDataActions); + Task DeleteManyAsync(IEnumerable users); } diff --git a/src/Infrastructure.Dapper/Repositories/UserRepository.cs b/src/Infrastructure.Dapper/Repositories/UserRepository.cs index 9e613fdf0..227a7c03e 100644 --- a/src/Infrastructure.Dapper/Repositories/UserRepository.cs +++ b/src/Infrastructure.Dapper/Repositories/UserRepository.cs @@ -172,6 +172,18 @@ public class UserRepository : Repository, IUserRepository commandTimeout: 180); } } + public async Task DeleteManyAsync(IEnumerable users) + { + var ids = users.Select(user => user.Id); + using (var connection = new SqlConnection(ConnectionString)) + { + await connection.ExecuteAsync( + $"[{Schema}].[{Table}_DeleteByIds]", + new { Ids = JsonSerializer.Serialize(ids) }, + commandType: CommandType.StoredProcedure, + commandTimeout: 180); + } + } public async Task UpdateStorageAsync(Guid id) { diff --git a/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs b/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs index d234d2545..cbfefb648 100644 --- a/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs @@ -261,6 +261,53 @@ public class UserRepository : Repository, IUserR var mappedUser = Mapper.Map(user); dbContext.Users.Remove(mappedUser); + await transaction.CommitAsync(); + await dbContext.SaveChangesAsync(); + } + } + + public async Task DeleteManyAsync(IEnumerable users) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + + var transaction = await dbContext.Database.BeginTransactionAsync(); + + var targetIds = users.Select(u => u.Id).ToList(); + + await dbContext.WebAuthnCredentials.Where(wa => targetIds.Contains(wa.UserId)).ExecuteDeleteAsync(); + await dbContext.Ciphers.Where(c => targetIds.Contains(c.UserId ?? default)).ExecuteDeleteAsync(); + await dbContext.Folders.Where(f => targetIds.Contains(f.UserId)).ExecuteDeleteAsync(); + await dbContext.AuthRequests.Where(a => targetIds.Contains(a.UserId)).ExecuteDeleteAsync(); + await dbContext.Devices.Where(d => targetIds.Contains(d.UserId)).ExecuteDeleteAsync(); + var collectionUsers = from cu in dbContext.CollectionUsers + join ou in dbContext.OrganizationUsers on cu.OrganizationUserId equals ou.Id + where targetIds.Contains(ou.UserId ?? default) + select cu; + dbContext.CollectionUsers.RemoveRange(collectionUsers); + var groupUsers = from gu in dbContext.GroupUsers + join ou in dbContext.OrganizationUsers on gu.OrganizationUserId equals ou.Id + where targetIds.Contains(ou.UserId ?? default) + select gu; + dbContext.GroupUsers.RemoveRange(groupUsers); + await dbContext.UserProjectAccessPolicy.Where(ap => targetIds.Contains(ap.OrganizationUser.UserId ?? default)).ExecuteDeleteAsync(); + await dbContext.UserServiceAccountAccessPolicy.Where(ap => targetIds.Contains(ap.OrganizationUser.UserId ?? default)).ExecuteDeleteAsync(); + await dbContext.OrganizationUsers.Where(ou => targetIds.Contains(ou.UserId ?? default)).ExecuteDeleteAsync(); + await dbContext.ProviderUsers.Where(pu => targetIds.Contains(pu.UserId ?? default)).ExecuteDeleteAsync(); + await dbContext.SsoUsers.Where(su => targetIds.Contains(su.UserId)).ExecuteDeleteAsync(); + await dbContext.EmergencyAccesses.Where(ea => targetIds.Contains(ea.GrantorId) || targetIds.Contains(ea.GranteeId ?? default)).ExecuteDeleteAsync(); + await dbContext.Sends.Where(s => targetIds.Contains(s.UserId ?? default)).ExecuteDeleteAsync(); + await dbContext.NotificationStatuses.Where(ns => targetIds.Contains(ns.UserId)).ExecuteDeleteAsync(); + await dbContext.Notifications.Where(n => targetIds.Contains(n.UserId ?? default)).ExecuteDeleteAsync(); + + foreach (var u in users) + { + var mappedUser = Mapper.Map(u); + dbContext.Users.Remove(mappedUser); + } + + await transaction.CommitAsync(); await dbContext.SaveChangesAsync(); } diff --git a/src/Sql/dbo/Stored Procedures/User_DeleteByIds.sql b/src/Sql/dbo/Stored Procedures/User_DeleteByIds.sql new file mode 100644 index 000000000..97ab955f8 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/User_DeleteByIds.sql @@ -0,0 +1,158 @@ +CREATE PROCEDURE [dbo].[User_DeleteByIds] + @Ids NVARCHAR(MAX) +WITH RECOMPILE +AS +BEGIN + SET NOCOUNT ON + -- Declare a table variable to hold the parsed JSON data + DECLARE @ParsedIds TABLE (Id UNIQUEIDENTIFIER); + + -- Parse the JSON input into the table variable + INSERT INTO @ParsedIds (Id) + SELECT value + FROM OPENJSON(@Ids); + + -- Check if the input table is empty + IF (SELECT COUNT(1) FROM @ParsedIds) < 1 + BEGIN + RETURN(-1); + END + + DECLARE @BatchSize INT = 100 + + -- Delete ciphers + WHILE @BatchSize > 0 + BEGIN + BEGIN TRANSACTION User_DeleteById_Ciphers + + DELETE TOP(@BatchSize) + FROM + [dbo].[Cipher] + WHERE + [UserId] IN (SELECT * FROM @ParsedIds) + + SET @BatchSize = @@ROWCOUNT + + COMMIT TRANSACTION User_DeleteById_Ciphers + END + + BEGIN TRANSACTION User_DeleteById + + -- Delete WebAuthnCredentials + DELETE + FROM + [dbo].[WebAuthnCredential] + WHERE + [UserId] IN (SELECT * FROM @ParsedIds) + + -- Delete folders + DELETE + FROM + [dbo].[Folder] + WHERE + [UserId] IN (SELECT * FROM @ParsedIds) + + -- Delete AuthRequest, must be before Device + DELETE + FROM + [dbo].[AuthRequest] + WHERE + [UserId] IN (SELECT * FROM @ParsedIds) + + -- Delete devices + DELETE + FROM + [dbo].[Device] + WHERE + [UserId] IN (SELECT * FROM @ParsedIds) + + -- Delete collection users + DELETE + CU + FROM + [dbo].[CollectionUser] CU + INNER JOIN + [dbo].[OrganizationUser] OU ON OU.[Id] = CU.[OrganizationUserId] + WHERE + OU.[UserId] IN (SELECT * FROM @ParsedIds) + + -- Delete group users + DELETE + GU + FROM + [dbo].[GroupUser] GU + INNER JOIN + [dbo].[OrganizationUser] OU ON OU.[Id] = GU.[OrganizationUserId] + WHERE + OU.[UserId] IN (SELECT * FROM @ParsedIds) + + -- Delete AccessPolicy + DELETE + AP + FROM + [dbo].[AccessPolicy] AP + INNER JOIN + [dbo].[OrganizationUser] OU ON OU.[Id] = AP.[OrganizationUserId] + WHERE + [UserId] IN (SELECT * FROM @ParsedIds) + + -- Delete organization users + DELETE + FROM + [dbo].[OrganizationUser] + WHERE + [UserId] IN (SELECT * FROM @ParsedIds) + + -- Delete provider users + DELETE + FROM + [dbo].[ProviderUser] + WHERE + [UserId] IN (SELECT * FROM @ParsedIds) + + -- Delete SSO Users + DELETE + FROM + [dbo].[SsoUser] + WHERE + [UserId] IN (SELECT * FROM @ParsedIds) + + -- Delete Emergency Accesses + DELETE + FROM + [dbo].[EmergencyAccess] + WHERE + [GrantorId] IN (SELECT * FROM @ParsedIds) + OR + [GranteeId] IN (SELECT * FROM @ParsedIds) + + -- Delete Sends + DELETE + FROM + [dbo].[Send] + WHERE + [UserId] IN (SELECT * FROM @ParsedIds) + + -- Delete Notification Status + DELETE + FROM + [dbo].[NotificationStatus] + WHERE + [UserId] IN (SELECT * FROM @ParsedIds) + + -- Delete Notification + DELETE + FROM + [dbo].[Notification] + WHERE + [UserId] IN (SELECT * FROM @ParsedIds) + + -- Finally, delete the user + DELETE + FROM + [dbo].[User] + WHERE + [Id] IN (SELECT * FROM @ParsedIds) + + COMMIT TRANSACTION User_DeleteById +END diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteManagedOrganizationUserAccountCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteManagedOrganizationUserAccountCommandTests.cs index 81e83d745..b21ae5459 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteManagedOrganizationUserAccountCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteManagedOrganizationUserAccountCommandTests.cs @@ -258,14 +258,15 @@ public class DeleteManagedOrganizationUserAccountCommandTests .Returns(new Dictionary { { orgUser1.Id, true }, { orgUser2.Id, true } }); // Act - var results = await sutProvider.Sut.DeleteManyUsersAsync(organizationId, new[] { orgUser1.Id, orgUser2.Id }, null); + var userIds = new[] { orgUser1.Id, orgUser2.Id }; + var results = await sutProvider.Sut.DeleteManyUsersAsync(organizationId, userIds, null); // Assert Assert.Equal(2, results.Count()); Assert.All(results, r => Assert.Empty(r.Item2)); - await sutProvider.GetDependency().Received(1).DeleteAsync(user1); - await sutProvider.GetDependency().Received(1).DeleteAsync(user2); + await sutProvider.GetDependency().Received(1).GetManyAsync(userIds); + await sutProvider.GetDependency().Received(1).DeleteManyAsync(Arg.Is>(users => users.Any(u => u.Id == user1.Id) && users.Any(u => u.Id == user2.Id))); await sutProvider.GetDependency().Received(1).LogOrganizationUserEventsAsync( Arg.Is>(events => events.Count(e => e.Item1.Id == orgUser1.Id && e.Item2 == EventType.OrganizationUser_Deleted) == 1 @@ -286,7 +287,9 @@ public class DeleteManagedOrganizationUserAccountCommandTests Assert.Single(result); Assert.Equal(orgUserId, result.First().Item1); Assert.Contains("Member not found.", result.First().Item2); - await sutProvider.GetDependency().Received(0).DeleteAsync(Arg.Any()); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DeleteManyAsync(default); await sutProvider.GetDependency().Received(0) .LogOrganizationUserEventsAsync(Arg.Any>()); } @@ -484,7 +487,6 @@ public class DeleteManagedOrganizationUserAccountCommandTests Assert.Equal("You cannot delete a member with Invited status.", results.First(r => r.Item1 == orgUser2.Id).Item2); Assert.Equal("Member is not managed by the organization.", results.First(r => r.Item1 == orgUser3.Id).Item2); - await sutProvider.GetDependency().Received(1).DeleteAsync(user1); await sutProvider.GetDependency().Received(1).LogOrganizationUserEventsAsync( Arg.Is>(events => events.Count(e => e.Item1.Id == orgUser1.Id && e.Item2 == EventType.OrganizationUser_Deleted) == 1)); diff --git a/test/Infrastructure.IntegrationTest/Auth/Repositories/UserRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Auth/Repositories/UserRepositoryTests.cs new file mode 100644 index 000000000..d4606ae63 --- /dev/null +++ b/test/Infrastructure.IntegrationTest/Auth/Repositories/UserRepositoryTests.cs @@ -0,0 +1,99 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Xunit; + +namespace Bit.Infrastructure.IntegrationTest.Repositories; + +public class UserRepositoryTests +{ + [DatabaseTheory, DatabaseData] + public async Task DeleteAsync_Works(IUserRepository userRepository) + { + var user = await userRepository.CreateAsync(new User + { + Name = "Test User", + Email = $"test+{Guid.NewGuid()}@example.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + await userRepository.DeleteAsync(user); + + var deletedUser = await userRepository.GetByIdAsync(user.Id); + Assert.Null(deletedUser); + } + + [DatabaseTheory, DatabaseData] + public async Task DeleteManyAsync_Works(IUserRepository userRepository, IOrganizationUserRepository organizationUserRepository, IOrganizationRepository organizationRepository) + { + var user1 = await userRepository.CreateAsync(new User + { + Name = "Test User 1", + Email = $"test+{Guid.NewGuid()}@email.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + var user2 = await userRepository.CreateAsync(new User + { + Name = "Test User 2", + Email = $"test+{Guid.NewGuid()}@email.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + var user3 = await userRepository.CreateAsync(new User + { + Name = "Test User 3", + Email = $"test+{Guid.NewGuid()}@email.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + var organization = await organizationRepository.CreateAsync(new Organization + { + Name = "Test Org", + BillingEmail = user3.Email, // TODO: EF does not enfore this being NOT NULL + Plan = "Test", // TODO: EF does not enforce this being NOT NULl + }); + + await organizationUserRepository.CreateAsync(new OrganizationUser + { + OrganizationId = organization.Id, + UserId = user1.Id, + Status = OrganizationUserStatusType.Confirmed, + }); + + await organizationUserRepository.CreateAsync(new OrganizationUser + { + OrganizationId = organization.Id, + UserId = user3.Id, + Status = OrganizationUserStatusType.Confirmed, + }); + + await userRepository.DeleteManyAsync(new List + { + user1, + user2 + }); + + var deletedUser1 = await userRepository.GetByIdAsync(user1.Id); + var deletedUser2 = await userRepository.GetByIdAsync(user2.Id); + var notDeletedUser3 = await userRepository.GetByIdAsync(user3.Id); + + var orgUser1Deleted = await organizationUserRepository.GetByIdAsync(user1.Id); + + var notDeletedOrgUsers = await organizationUserRepository.GetManyByUserAsync(user3.Id); + + Assert.Null(deletedUser1); + Assert.Null(deletedUser2); + Assert.NotNull(notDeletedUser3); + + Assert.Null(orgUser1Deleted); + Assert.NotNull(notDeletedOrgUsers); + Assert.True(notDeletedOrgUsers.Count > 0); + } + +} diff --git a/util/Migrator/DbScripts/2024-11-22_00_UserDeleteByIds.sql b/util/Migrator/DbScripts/2024-11-22_00_UserDeleteByIds.sql new file mode 100644 index 000000000..244151143 --- /dev/null +++ b/util/Migrator/DbScripts/2024-11-22_00_UserDeleteByIds.sql @@ -0,0 +1,158 @@ +CREATE OR ALTER PROCEDURE [dbo].[User_DeleteByIds] + @Ids NVARCHAR(MAX) +WITH RECOMPILE +AS +BEGIN + SET NOCOUNT ON + -- Declare a table variable to hold the parsed JSON data + DECLARE @ParsedIds TABLE (Id UNIQUEIDENTIFIER); + + -- Parse the JSON input into the table variable + INSERT INTO @ParsedIds (Id) + SELECT value + FROM OPENJSON(@Ids); + + -- Check if the input table is empty + IF (SELECT COUNT(1) FROM @ParsedIds) < 1 + BEGIN + RETURN(-1); + END + + DECLARE @BatchSize INT = 100 + + -- Delete ciphers + WHILE @BatchSize > 0 + BEGIN + BEGIN TRANSACTION User_DeleteById_Ciphers + + DELETE TOP(@BatchSize) + FROM + [dbo].[Cipher] + WHERE + [UserId] IN (SELECT * FROM @ParsedIds) + + SET @BatchSize = @@ROWCOUNT + + COMMIT TRANSACTION User_DeleteById_Ciphers + END + + BEGIN TRANSACTION User_DeleteById + + -- Delete WebAuthnCredentials + DELETE + FROM + [dbo].[WebAuthnCredential] + WHERE + [UserId] IN (SELECT * FROM @ParsedIds) + + -- Delete folders + DELETE + FROM + [dbo].[Folder] + WHERE + [UserId] IN (SELECT * FROM @ParsedIds) + + -- Delete AuthRequest, must be before Device + DELETE + FROM + [dbo].[AuthRequest] + WHERE + [UserId] IN (SELECT * FROM @ParsedIds) + + -- Delete devices + DELETE + FROM + [dbo].[Device] + WHERE + [UserId] IN (SELECT * FROM @ParsedIds) + + -- Delete collection users + DELETE + CU + FROM + [dbo].[CollectionUser] CU + INNER JOIN + [dbo].[OrganizationUser] OU ON OU.[Id] = CU.[OrganizationUserId] + WHERE + OU.[UserId] IN (SELECT * FROM @ParsedIds) + + -- Delete group users + DELETE + GU + FROM + [dbo].[GroupUser] GU + INNER JOIN + [dbo].[OrganizationUser] OU ON OU.[Id] = GU.[OrganizationUserId] + WHERE + OU.[UserId] IN (SELECT * FROM @ParsedIds) + + -- Delete AccessPolicy + DELETE + AP + FROM + [dbo].[AccessPolicy] AP + INNER JOIN + [dbo].[OrganizationUser] OU ON OU.[Id] = AP.[OrganizationUserId] + WHERE + [UserId] IN (SELECT * FROM @ParsedIds) + + -- Delete organization users + DELETE + FROM + [dbo].[OrganizationUser] + WHERE + [UserId] IN (SELECT * FROM @ParsedIds) + + -- Delete provider users + DELETE + FROM + [dbo].[ProviderUser] + WHERE + [UserId] IN (SELECT * FROM @ParsedIds) + + -- Delete SSO Users + DELETE + FROM + [dbo].[SsoUser] + WHERE + [UserId] IN (SELECT * FROM @ParsedIds) + + -- Delete Emergency Accesses + DELETE + FROM + [dbo].[EmergencyAccess] + WHERE + [GrantorId] IN (SELECT * FROM @ParsedIds) + OR + [GranteeId] IN (SELECT * FROM @ParsedIds) + + -- Delete Sends + DELETE + FROM + [dbo].[Send] + WHERE + [UserId] IN (SELECT * FROM @ParsedIds) + + -- Delete Notification Status + DELETE + FROM + [dbo].[NotificationStatus] + WHERE + [UserId] IN (SELECT * FROM @ParsedIds) + + -- Delete Notification + DELETE + FROM + [dbo].[Notification] + WHERE + [UserId] IN (SELECT * FROM @ParsedIds) + + -- Finally, delete the user + DELETE + FROM + [dbo].[User] + WHERE + [Id] IN (SELECT * FROM @ParsedIds) + + COMMIT TRANSACTION User_DeleteById +END