1
0
mirror of https://github.com/bitwarden/server.git synced 2025-06-30 23:52:50 -05:00

[PM-10316] Add Command to Remove User and Delete Data for Organization-Managed Users (#4726)

* Add HasVerifiedDomainsAsync method to IOrganizationDomainService

* Add GetManagedUserIdsByOrganizationIdAsync method to IOrganizationUserRepository and the corresponding queries

* Fix case on the sproc OrganizationUser_ReadManagedIdsByOrganizationId parameter

* Update the EF query to use the Email from the User table

* dotnet format

* Fix IOrganizationDomainService.HasVerifiedDomainsAsync by checking that domains have been Verified and add unit tests

* Rename IOrganizationUserRepository.GetManagedUserIdsByOrganizationAsync

* Fix domain queries

* Add OrganizationUserRepository integration tests

* Add summary to IOrganizationDomainService.HasVerifiedDomainsAsync

* chore: Rename IOrganizationUserRepository.GetManagedUserIdsByOrganizationAsync to GetManyIdsManagedByOrganizationIdAsync

* Add IsManagedByAnyOrganizationAsync method to IUserRepository

* Add integration tests for UserRepository.IsManagedByAnyOrganizationAsync

* Refactor to IUserService.IsManagedByAnyOrganizationAsync and IOrganizationService.GetUsersOrganizationManagementStatusAsync

* chore: Refactor IsManagedByAnyOrganizationAsync method in UserService

* Refactor IOrganizationService.GetUsersOrganizationManagementStatusAsync to return IDictionary<Guid, bool>

* Extract IOrganizationService.GetUsersOrganizationManagementStatusAsync into a query

* Update comments in OrganizationDomainService to use proper capitalization

* Move OrganizationDomainService to AdminConsole ownership and update namespace

* feat: Add support for organization domains in enterprise plans

* feat: Add HasOrganizationDomains property to OrganizationAbility class

* refactor: Update GetOrganizationUsersManagementStatusQuery to use IApplicationCacheService

* Remove HasOrganizationDomains and use UseSso to check if Organization can have Verified Domains

* Refactor UserService.IsManagedByAnyOrganizationAsync to simply check the UseSso flag

* Add new event types for organization user deletion and voluntary departure

* Add DeleteManagedOrganizationUserAccountCommand to remove user and delete account

* Refactor DeleteManagedOrganizationUserAccountCommand to use orgUser.Id instead of orgUser.UserId.Value

* Add DeleteManagedOrganizationUserAccountCommandTests

* Remove duplicate sql migration script

* Update DeleteManagedOrganizationUserAccountCommand methods to cover all existing checks on OrganizationService

* Add unit tests for all user checks

* Refactor DeleteManagedOrganizationUserAccountCommand

* Set nullable enable annotation on DeleteManagedOrganizationUserAccountCommand

* Fix possible null reference

* Refactor DeleteManagedOrganizationUserAccountCommand.cs for improved event logging

* Use UserRepository.GetByIdAsync instead of UserService.GetUserByIdAsync

* Refactor DeleteManagedOrganizationUserAccountCommand.cs for improved error messages

* Refactor DeleteManagedOrganizationUserAccountCommand.cs for improved event logging, error handling and reduce database calls

* Rename unit tests to correctly describe expected outcome
This commit is contained in:
Rui Tomé
2024-09-25 11:02:17 +01:00
committed by GitHub
parent 42f6112c55
commit 6514b342fc
5 changed files with 675 additions and 1 deletions

View File

@ -46,7 +46,7 @@ public enum EventType : int
OrganizationUser_Invited = 1500,
OrganizationUser_Confirmed = 1501,
OrganizationUser_Updated = 1502,
OrganizationUser_Removed = 1503,
OrganizationUser_Removed = 1503, // Organization user data was deleted
OrganizationUser_UpdatedGroups = 1504,
OrganizationUser_UnlinkedSso = 1505,
OrganizationUser_ResetPassword_Enroll = 1506,
@ -58,6 +58,8 @@ public enum EventType : int
OrganizationUser_Restored = 1512,
OrganizationUser_ApprovedAuthRequest = 1513,
OrganizationUser_RejectedAuthRequest = 1514,
OrganizationUser_Deleted = 1515, // Both user and organization user data were deleted
OrganizationUser_Left = 1516, // User voluntarily left the organization
Organization_Updated = 1600,
Organization_PurgedVault = 1601,

View File

@ -0,0 +1,160 @@
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
#nullable enable
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers;
public class DeleteManagedOrganizationUserAccountCommand : IDeleteManagedOrganizationUserAccountCommand
{
private readonly IUserService _userService;
private readonly IEventService _eventService;
private readonly IGetOrganizationUsersManagementStatusQuery _getOrganizationUsersManagementStatusQuery;
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IUserRepository _userRepository;
private readonly ICurrentContext _currentContext;
private readonly IOrganizationService _organizationService;
public DeleteManagedOrganizationUserAccountCommand(
IUserService userService,
IEventService eventService,
IGetOrganizationUsersManagementStatusQuery getOrganizationUsersManagementStatusQuery,
IOrganizationUserRepository organizationUserRepository,
IUserRepository userRepository,
ICurrentContext currentContext,
IOrganizationService organizationService)
{
_userService = userService;
_eventService = eventService;
_getOrganizationUsersManagementStatusQuery = getOrganizationUsersManagementStatusQuery;
_organizationUserRepository = organizationUserRepository;
_userRepository = userRepository;
_currentContext = currentContext;
_organizationService = organizationService;
}
public async Task DeleteUserAsync(Guid organizationId, Guid organizationUserId, Guid? deletingUserId)
{
var organizationUser = await _organizationUserRepository.GetByIdAsync(organizationUserId);
if (organizationUser == null || organizationUser.OrganizationId != organizationId)
{
throw new NotFoundException("Member not found.");
}
var managementStatus = await _getOrganizationUsersManagementStatusQuery.GetUsersOrganizationManagementStatusAsync(organizationId, new[] { organizationUserId });
var hasOtherConfirmedOwners = await _organizationService.HasConfirmedOwnersExceptAsync(organizationId, new[] { organizationUserId }, includeProvider: true);
await ValidateDeleteUserAsync(organizationId, organizationUser, deletingUserId, managementStatus, hasOtherConfirmedOwners);
var user = await _userRepository.GetByIdAsync(organizationUser.UserId!.Value);
if (user == null)
{
throw new NotFoundException("Member not found.");
}
await _userService.DeleteAsync(user);
await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Deleted);
}
public async Task<IEnumerable<(Guid OrganizationUserId, string? ErrorMessage)>> DeleteManyUsersAsync(Guid organizationId, IEnumerable<Guid> orgUserIds, Guid? deletingUserId)
{
var orgUsers = await _organizationUserRepository.GetManyAsync(orgUserIds);
var userIds = orgUsers.Where(ou => ou.UserId.HasValue).Select(ou => ou.UserId!.Value).ToList();
var users = await _userRepository.GetManyAsync(userIds);
var managementStatus = await _getOrganizationUsersManagementStatusQuery.GetUsersOrganizationManagementStatusAsync(organizationId, orgUserIds);
var hasOtherConfirmedOwners = await _organizationService.HasConfirmedOwnersExceptAsync(organizationId, orgUserIds, includeProvider: true);
var results = new List<(Guid OrganizationUserId, string? ErrorMessage)>();
foreach (var orgUserId in orgUserIds)
{
try
{
var orgUser = orgUsers.FirstOrDefault(ou => ou.Id == orgUserId);
if (orgUser == null || orgUser.OrganizationId != organizationId)
{
throw new NotFoundException("Member not found.");
}
await ValidateDeleteUserAsync(organizationId, orgUser, deletingUserId, managementStatus, hasOtherConfirmedOwners);
var user = users.FirstOrDefault(u => u.Id == orgUser.UserId);
if (user == null)
{
throw new NotFoundException("Member not found.");
}
await _userService.DeleteAsync(user);
results.Add((orgUserId, string.Empty));
}
catch (Exception ex)
{
results.Add((orgUserId, ex.Message));
}
}
await LogDeletedOrganizationUsersAsync(orgUsers, results);
return results;
}
private async Task ValidateDeleteUserAsync(Guid organizationId, OrganizationUser orgUser, Guid? deletingUserId, IDictionary<Guid, bool> managementStatus, bool hasOtherConfirmedOwners)
{
if (!orgUser.UserId.HasValue || orgUser.Status == OrganizationUserStatusType.Invited)
{
throw new BadRequestException("You cannot delete a member with Invited status.");
}
if (deletingUserId.HasValue && orgUser.UserId.Value == deletingUserId.Value)
{
throw new BadRequestException("You cannot delete yourself.");
}
if (orgUser.Type == OrganizationUserType.Owner)
{
if (deletingUserId.HasValue && !await _currentContext.OrganizationOwner(organizationId))
{
throw new BadRequestException("Only owners can delete other owners.");
}
if (!hasOtherConfirmedOwners)
{
throw new BadRequestException("Organization must have at least one confirmed owner.");
}
}
if (!managementStatus.TryGetValue(orgUser.Id, out var isManaged) || !isManaged)
{
throw new BadRequestException("Member is not managed by the organization.");
}
}
private async Task LogDeletedOrganizationUsersAsync(
IEnumerable<OrganizationUser> orgUsers,
IEnumerable<(Guid OrgUserId, string? ErrorMessage)> results)
{
var eventDate = DateTime.UtcNow;
var events = new List<(OrganizationUser OrgUser, EventType Event, DateTime? EventDate)>();
foreach (var (orgUserId, errorMessage) in results)
{
var orgUser = orgUsers.FirstOrDefault(ou => ou.Id == orgUserId);
// If the user was not found or there was an error, we skip logging the event
if (orgUser == null || !string.IsNullOrEmpty(errorMessage))
{
continue;
}
events.Add((orgUser, EventType.OrganizationUser_Deleted, eventDate));
}
if (events.Any())
{
await _eventService.LogOrganizationUserEventsAsync(events);
}
}
}

View File

@ -0,0 +1,19 @@
#nullable enable
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
public interface IDeleteManagedOrganizationUserAccountCommand
{
/// <summary>
/// Removes a user from an organization and deletes all of their associated user data.
/// </summary>
Task DeleteUserAsync(Guid organizationId, Guid organizationUserId, Guid? deletingUserId);
/// <summary>
/// Removes multiple users from an organization and deletes all of their associated user data.
/// </summary>
/// <returns>
/// An error message for each user that could not be removed, otherwise null.
/// </returns>
Task<IEnumerable<(Guid OrganizationUserId, string? ErrorMessage)>> DeleteManyUsersAsync(Guid organizationId, IEnumerable<Guid> orgUserIds, Guid? deletingUserId);
}

View File

@ -91,6 +91,7 @@ public static class OrganizationServiceCollectionExtensions
services.AddScoped<IRemoveOrganizationUserCommand, RemoveOrganizationUserCommand>();
services.AddScoped<IUpdateOrganizationUserCommand, UpdateOrganizationUserCommand>();
services.AddScoped<IUpdateOrganizationUserGroupsCommand, UpdateOrganizationUserGroupsCommand>();
services.AddScoped<IDeleteManagedOrganizationUserAccountCommand, DeleteManagedOrganizationUserAccountCommand>();
}
private static void AddOrganizationApiKeyCommandsQueries(this IServiceCollection services)