1
0
mirror of https://github.com/bitwarden/server.git synced 2025-02-11 01:01:36 +01:00

[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
This commit is contained in:
Brandon Treston 2024-12-06 14:40:47 -05:00 committed by GitHub
parent fb5db40f4c
commit c591997d01
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 565 additions and 8 deletions

View File

@ -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<User> 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) { }
}
}
}

View File

@ -32,4 +32,5 @@ public interface IUserRepository : IRepository<User, Guid>
/// <param name="updateDataActions">Registered database calls to update re-encrypted data.</param>
Task UpdateUserKeyAndEncryptedDataAsync(User user,
IEnumerable<UpdateEncryptedDataForKeyRotation> updateDataActions);
Task DeleteManyAsync(IEnumerable<User> users);
}

View File

@ -172,6 +172,18 @@ public class UserRepository : Repository<User, Guid>, IUserRepository
commandTimeout: 180);
}
}
public async Task DeleteManyAsync(IEnumerable<User> 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)
{

View File

@ -261,6 +261,53 @@ public class UserRepository : Repository<Core.Entities.User, User, Guid>, IUserR
var mappedUser = Mapper.Map<User>(user);
dbContext.Users.Remove(mappedUser);
await transaction.CommitAsync();
await dbContext.SaveChangesAsync();
}
}
public async Task DeleteManyAsync(IEnumerable<Core.Entities.User> 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<User>(u);
dbContext.Users.Remove(mappedUser);
}
await transaction.CommitAsync();
await dbContext.SaveChangesAsync();
}

View File

@ -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

View File

@ -258,14 +258,15 @@ public class DeleteManagedOrganizationUserAccountCommandTests
.Returns(new Dictionary<Guid, bool> { { 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<IUserService>().Received(1).DeleteAsync(user1);
await sutProvider.GetDependency<IUserService>().Received(1).DeleteAsync(user2);
await sutProvider.GetDependency<IOrganizationUserRepository>().Received(1).GetManyAsync(userIds);
await sutProvider.GetDependency<IUserRepository>().Received(1).DeleteManyAsync(Arg.Is<IEnumerable<User>>(users => users.Any(u => u.Id == user1.Id) && users.Any(u => u.Id == user2.Id)));
await sutProvider.GetDependency<IEventService>().Received(1).LogOrganizationUserEventsAsync(
Arg.Is<IEnumerable<(OrganizationUser, EventType, DateTime?)>>(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<IUserService>().Received(0).DeleteAsync(Arg.Any<User>());
await sutProvider.GetDependency<IUserRepository>()
.DidNotReceiveWithAnyArgs()
.DeleteManyAsync(default);
await sutProvider.GetDependency<IEventService>().Received(0)
.LogOrganizationUserEventsAsync(Arg.Any<IEnumerable<(OrganizationUser, EventType, DateTime?)>>());
}
@ -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<IUserService>().Received(1).DeleteAsync(user1);
await sutProvider.GetDependency<IEventService>().Received(1).LogOrganizationUserEventsAsync(
Arg.Is<IEnumerable<(OrganizationUser, EventType, DateTime?)>>(events =>
events.Count(e => e.Item1.Id == orgUser1.Id && e.Item2 == EventType.OrganizationUser_Deleted) == 1));

View File

@ -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<User>
{
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);
}
}

View File

@ -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