Skip to content

Commit c591997

Browse files
authored
[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
1 parent fb5db40 commit c591997

File tree

8 files changed

+565
-8
lines changed

8 files changed

+565
-8
lines changed

src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteManagedOrganizationUserAccountCommand.cs

Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
2+
using Bit.Core.AdminConsole.Repositories;
23
using Bit.Core.Context;
34
using Bit.Core.Entities;
45
using Bit.Core.Enums;
56
using Bit.Core.Exceptions;
67
using Bit.Core.Repositories;
78
using Bit.Core.Services;
9+
using Bit.Core.Tools.Enums;
10+
using Bit.Core.Tools.Models.Business;
11+
using Bit.Core.Tools.Services;
812

913
#nullable enable
1014

@@ -19,15 +23,22 @@ public class DeleteManagedOrganizationUserAccountCommand : IDeleteManagedOrganiz
1923
private readonly IUserRepository _userRepository;
2024
private readonly ICurrentContext _currentContext;
2125
private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery;
22-
26+
private readonly IReferenceEventService _referenceEventService;
27+
private readonly IPushNotificationService _pushService;
28+
private readonly IOrganizationRepository _organizationRepository;
29+
private readonly IProviderUserRepository _providerUserRepository;
2330
public DeleteManagedOrganizationUserAccountCommand(
2431
IUserService userService,
2532
IEventService eventService,
2633
IGetOrganizationUsersManagementStatusQuery getOrganizationUsersManagementStatusQuery,
2734
IOrganizationUserRepository organizationUserRepository,
2835
IUserRepository userRepository,
2936
ICurrentContext currentContext,
30-
IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery)
37+
IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery,
38+
IReferenceEventService referenceEventService,
39+
IPushNotificationService pushService,
40+
IOrganizationRepository organizationRepository,
41+
IProviderUserRepository providerUserRepository)
3142
{
3243
_userService = userService;
3344
_eventService = eventService;
@@ -36,6 +47,10 @@ public DeleteManagedOrganizationUserAccountCommand(
3647
_userRepository = userRepository;
3748
_currentContext = currentContext;
3849
_hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery;
50+
_referenceEventService = referenceEventService;
51+
_pushService = pushService;
52+
_organizationRepository = organizationRepository;
53+
_providerUserRepository = providerUserRepository;
3954
}
4055

4156
public async Task DeleteUserAsync(Guid organizationId, Guid organizationUserId, Guid? deletingUserId)
@@ -89,7 +104,8 @@ public async Task DeleteUserAsync(Guid organizationId, Guid organizationUserId,
89104
throw new NotFoundException("Member not found.");
90105
}
91106

92-
await _userService.DeleteAsync(user);
107+
await ValidateUserMembershipAndPremiumAsync(user);
108+
93109
results.Add((orgUserId, string.Empty));
94110
}
95111
catch (Exception ex)
@@ -98,6 +114,15 @@ public async Task DeleteUserAsync(Guid organizationId, Guid organizationUserId,
98114
}
99115
}
100116

117+
var orgUserResultsToDelete = results.Where(result => string.IsNullOrEmpty(result.ErrorMessage));
118+
var orgUsersToDelete = orgUsers.Where(orgUser => orgUserResultsToDelete.Any(result => orgUser.Id == result.OrganizationUserId));
119+
var usersToDelete = users.Where(user => orgUsersToDelete.Any(orgUser => orgUser.UserId == user.Id));
120+
121+
if (usersToDelete.Any())
122+
{
123+
await DeleteManyAsync(usersToDelete);
124+
}
125+
101126
await LogDeletedOrganizationUsersAsync(orgUsers, results);
102127

103128
return results;
@@ -158,4 +183,59 @@ private async Task LogDeletedOrganizationUsersAsync(
158183
await _eventService.LogOrganizationUserEventsAsync(events);
159184
}
160185
}
186+
private async Task DeleteManyAsync(IEnumerable<User> users)
187+
{
188+
189+
await _userRepository.DeleteManyAsync(users);
190+
foreach (var user in users)
191+
{
192+
await _referenceEventService.RaiseEventAsync(
193+
new ReferenceEvent(ReferenceEventType.DeleteAccount, user, _currentContext));
194+
await _pushService.PushLogOutAsync(user.Id);
195+
}
196+
197+
}
198+
199+
private async Task ValidateUserMembershipAndPremiumAsync(User user)
200+
{
201+
// Check if user is the only owner of any organizations.
202+
var onlyOwnerCount = await _organizationUserRepository.GetCountByOnlyOwnerAsync(user.Id);
203+
if (onlyOwnerCount > 0)
204+
{
205+
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.");
206+
}
207+
208+
var orgs = await _organizationUserRepository.GetManyDetailsByUserAsync(user.Id, OrganizationUserStatusType.Confirmed);
209+
if (orgs.Count == 1)
210+
{
211+
var org = await _organizationRepository.GetByIdAsync(orgs.First().OrganizationId);
212+
if (org != null && (!org.Enabled || string.IsNullOrWhiteSpace(org.GatewaySubscriptionId)))
213+
{
214+
var orgCount = await _organizationUserRepository.GetCountByOrganizationIdAsync(org.Id);
215+
if (orgCount <= 1)
216+
{
217+
await _organizationRepository.DeleteAsync(org);
218+
}
219+
else
220+
{
221+
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.");
222+
}
223+
}
224+
}
225+
226+
var onlyOwnerProviderCount = await _providerUserRepository.GetCountByOnlyOwnerAsync(user.Id);
227+
if (onlyOwnerProviderCount > 0)
228+
{
229+
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.");
230+
}
231+
232+
if (!string.IsNullOrWhiteSpace(user.GatewaySubscriptionId))
233+
{
234+
try
235+
{
236+
await _userService.CancelPremiumAsync(user);
237+
}
238+
catch (GatewayException) { }
239+
}
240+
}
161241
}

src/Core/Repositories/IUserRepository.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,5 @@ public interface IUserRepository : IRepository<User, Guid>
3232
/// <param name="updateDataActions">Registered database calls to update re-encrypted data.</param>
3333
Task UpdateUserKeyAndEncryptedDataAsync(User user,
3434
IEnumerable<UpdateEncryptedDataForKeyRotation> updateDataActions);
35+
Task DeleteManyAsync(IEnumerable<User> users);
3536
}

src/Infrastructure.Dapper/Repositories/UserRepository.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,18 @@ await connection.ExecuteAsync(
172172
commandTimeout: 180);
173173
}
174174
}
175+
public async Task DeleteManyAsync(IEnumerable<User> users)
176+
{
177+
var ids = users.Select(user => user.Id);
178+
using (var connection = new SqlConnection(ConnectionString))
179+
{
180+
await connection.ExecuteAsync(
181+
$"[{Schema}].[{Table}_DeleteByIds]",
182+
new { Ids = JsonSerializer.Serialize(ids) },
183+
commandType: CommandType.StoredProcedure,
184+
commandTimeout: 180);
185+
}
186+
}
175187

176188
public async Task UpdateStorageAsync(Guid id)
177189
{

src/Infrastructure.EntityFramework/Repositories/UserRepository.cs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,53 @@ join ou in dbContext.OrganizationUsers on gu.OrganizationUserId equals ou.Id
261261
var mappedUser = Mapper.Map<User>(user);
262262
dbContext.Users.Remove(mappedUser);
263263

264+
await transaction.CommitAsync();
265+
await dbContext.SaveChangesAsync();
266+
}
267+
}
268+
269+
public async Task DeleteManyAsync(IEnumerable<Core.Entities.User> users)
270+
{
271+
using (var scope = ServiceScopeFactory.CreateScope())
272+
{
273+
var dbContext = GetDatabaseContext(scope);
274+
275+
var transaction = await dbContext.Database.BeginTransactionAsync();
276+
277+
var targetIds = users.Select(u => u.Id).ToList();
278+
279+
await dbContext.WebAuthnCredentials.Where(wa => targetIds.Contains(wa.UserId)).ExecuteDeleteAsync();
280+
await dbContext.Ciphers.Where(c => targetIds.Contains(c.UserId ?? default)).ExecuteDeleteAsync();
281+
await dbContext.Folders.Where(f => targetIds.Contains(f.UserId)).ExecuteDeleteAsync();
282+
await dbContext.AuthRequests.Where(a => targetIds.Contains(a.UserId)).ExecuteDeleteAsync();
283+
await dbContext.Devices.Where(d => targetIds.Contains(d.UserId)).ExecuteDeleteAsync();
284+
var collectionUsers = from cu in dbContext.CollectionUsers
285+
join ou in dbContext.OrganizationUsers on cu.OrganizationUserId equals ou.Id
286+
where targetIds.Contains(ou.UserId ?? default)
287+
select cu;
288+
dbContext.CollectionUsers.RemoveRange(collectionUsers);
289+
var groupUsers = from gu in dbContext.GroupUsers
290+
join ou in dbContext.OrganizationUsers on gu.OrganizationUserId equals ou.Id
291+
where targetIds.Contains(ou.UserId ?? default)
292+
select gu;
293+
dbContext.GroupUsers.RemoveRange(groupUsers);
294+
await dbContext.UserProjectAccessPolicy.Where(ap => targetIds.Contains(ap.OrganizationUser.UserId ?? default)).ExecuteDeleteAsync();
295+
await dbContext.UserServiceAccountAccessPolicy.Where(ap => targetIds.Contains(ap.OrganizationUser.UserId ?? default)).ExecuteDeleteAsync();
296+
await dbContext.OrganizationUsers.Where(ou => targetIds.Contains(ou.UserId ?? default)).ExecuteDeleteAsync();
297+
await dbContext.ProviderUsers.Where(pu => targetIds.Contains(pu.UserId ?? default)).ExecuteDeleteAsync();
298+
await dbContext.SsoUsers.Where(su => targetIds.Contains(su.UserId)).ExecuteDeleteAsync();
299+
await dbContext.EmergencyAccesses.Where(ea => targetIds.Contains(ea.GrantorId) || targetIds.Contains(ea.GranteeId ?? default)).ExecuteDeleteAsync();
300+
await dbContext.Sends.Where(s => targetIds.Contains(s.UserId ?? default)).ExecuteDeleteAsync();
301+
await dbContext.NotificationStatuses.Where(ns => targetIds.Contains(ns.UserId)).ExecuteDeleteAsync();
302+
await dbContext.Notifications.Where(n => targetIds.Contains(n.UserId ?? default)).ExecuteDeleteAsync();
303+
304+
foreach (var u in users)
305+
{
306+
var mappedUser = Mapper.Map<User>(u);
307+
dbContext.Users.Remove(mappedUser);
308+
}
309+
310+
264311
await transaction.CommitAsync();
265312
await dbContext.SaveChangesAsync();
266313
}
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
CREATE PROCEDURE [dbo].[User_DeleteByIds]
2+
@Ids NVARCHAR(MAX)
3+
WITH RECOMPILE
4+
AS
5+
BEGIN
6+
SET NOCOUNT ON
7+
-- Declare a table variable to hold the parsed JSON data
8+
DECLARE @ParsedIds TABLE (Id UNIQUEIDENTIFIER);
9+
10+
-- Parse the JSON input into the table variable
11+
INSERT INTO @ParsedIds (Id)
12+
SELECT value
13+
FROM OPENJSON(@Ids);
14+
15+
-- Check if the input table is empty
16+
IF (SELECT COUNT(1) FROM @ParsedIds) < 1
17+
BEGIN
18+
RETURN(-1);
19+
END
20+
21+
DECLARE @BatchSize INT = 100
22+
23+
-- Delete ciphers
24+
WHILE @BatchSize > 0
25+
BEGIN
26+
BEGIN TRANSACTION User_DeleteById_Ciphers
27+
28+
DELETE TOP(@BatchSize)
29+
FROM
30+
[dbo].[Cipher]
31+
WHERE
32+
[UserId] IN (SELECT * FROM @ParsedIds)
33+
34+
SET @BatchSize = @@ROWCOUNT
35+
36+
COMMIT TRANSACTION User_DeleteById_Ciphers
37+
END
38+
39+
BEGIN TRANSACTION User_DeleteById
40+
41+
-- Delete WebAuthnCredentials
42+
DELETE
43+
FROM
44+
[dbo].[WebAuthnCredential]
45+
WHERE
46+
[UserId] IN (SELECT * FROM @ParsedIds)
47+
48+
-- Delete folders
49+
DELETE
50+
FROM
51+
[dbo].[Folder]
52+
WHERE
53+
[UserId] IN (SELECT * FROM @ParsedIds)
54+
55+
-- Delete AuthRequest, must be before Device
56+
DELETE
57+
FROM
58+
[dbo].[AuthRequest]
59+
WHERE
60+
[UserId] IN (SELECT * FROM @ParsedIds)
61+
62+
-- Delete devices
63+
DELETE
64+
FROM
65+
[dbo].[Device]
66+
WHERE
67+
[UserId] IN (SELECT * FROM @ParsedIds)
68+
69+
-- Delete collection users
70+
DELETE
71+
CU
72+
FROM
73+
[dbo].[CollectionUser] CU
74+
INNER JOIN
75+
[dbo].[OrganizationUser] OU ON OU.[Id] = CU.[OrganizationUserId]
76+
WHERE
77+
OU.[UserId] IN (SELECT * FROM @ParsedIds)
78+
79+
-- Delete group users
80+
DELETE
81+
GU
82+
FROM
83+
[dbo].[GroupUser] GU
84+
INNER JOIN
85+
[dbo].[OrganizationUser] OU ON OU.[Id] = GU.[OrganizationUserId]
86+
WHERE
87+
OU.[UserId] IN (SELECT * FROM @ParsedIds)
88+
89+
-- Delete AccessPolicy
90+
DELETE
91+
AP
92+
FROM
93+
[dbo].[AccessPolicy] AP
94+
INNER JOIN
95+
[dbo].[OrganizationUser] OU ON OU.[Id] = AP.[OrganizationUserId]
96+
WHERE
97+
[UserId] IN (SELECT * FROM @ParsedIds)
98+
99+
-- Delete organization users
100+
DELETE
101+
FROM
102+
[dbo].[OrganizationUser]
103+
WHERE
104+
[UserId] IN (SELECT * FROM @ParsedIds)
105+
106+
-- Delete provider users
107+
DELETE
108+
FROM
109+
[dbo].[ProviderUser]
110+
WHERE
111+
[UserId] IN (SELECT * FROM @ParsedIds)
112+
113+
-- Delete SSO Users
114+
DELETE
115+
FROM
116+
[dbo].[SsoUser]
117+
WHERE
118+
[UserId] IN (SELECT * FROM @ParsedIds)
119+
120+
-- Delete Emergency Accesses
121+
DELETE
122+
FROM
123+
[dbo].[EmergencyAccess]
124+
WHERE
125+
[GrantorId] IN (SELECT * FROM @ParsedIds)
126+
OR
127+
[GranteeId] IN (SELECT * FROM @ParsedIds)
128+
129+
-- Delete Sends
130+
DELETE
131+
FROM
132+
[dbo].[Send]
133+
WHERE
134+
[UserId] IN (SELECT * FROM @ParsedIds)
135+
136+
-- Delete Notification Status
137+
DELETE
138+
FROM
139+
[dbo].[NotificationStatus]
140+
WHERE
141+
[UserId] IN (SELECT * FROM @ParsedIds)
142+
143+
-- Delete Notification
144+
DELETE
145+
FROM
146+
[dbo].[Notification]
147+
WHERE
148+
[UserId] IN (SELECT * FROM @ParsedIds)
149+
150+
-- Finally, delete the user
151+
DELETE
152+
FROM
153+
[dbo].[User]
154+
WHERE
155+
[Id] IN (SELECT * FROM @ParsedIds)
156+
157+
COMMIT TRANSACTION User_DeleteById
158+
END

0 commit comments

Comments
 (0)