mirror of
https://github.com/bitwarden/server
synced 2025-12-24 20:23:21 +00:00
[PM-15621] Refactor delete claimed user command (#6221)
- create vNext command - restructure command to simplify logic - move validation to a separate class - implement result types using OneOf library and demo their use here
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
using OneOf;
|
||||
using OneOf.Types;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the result of a command.
|
||||
/// This is a <see cref="OneOf{Error, T}"/> type that contains an Error if the command execution failed, or the result of the command if it succeeded.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the successful result. If there is no successful result (void), use <see cref="BulkCommandResult"/>.</typeparam>
|
||||
|
||||
public class CommandResult<T>(OneOf<Error, T> result) : OneOfBase<Error, T>(result)
|
||||
{
|
||||
public bool IsError => IsT0;
|
||||
public bool IsSuccess => IsT1;
|
||||
public Error AsError => AsT0;
|
||||
public T AsSuccess => AsT1;
|
||||
|
||||
public static implicit operator CommandResult<T>(T value) => new(value);
|
||||
public static implicit operator CommandResult<T>(Error error) => new(error);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents the result of a command where successful execution returns no value (void).
|
||||
/// See <see cref="CommandResult{T}"/> for more information.
|
||||
/// </summary>
|
||||
public class CommandResult(OneOf<Error, None> result) : CommandResult<None>(result)
|
||||
{
|
||||
public static implicit operator CommandResult(None none) => new(none);
|
||||
public static implicit operator CommandResult(Error error) => new(error);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A wrapper for <see cref="CommandResult{T}"/> with an ID, to identify the result in bulk operations.
|
||||
/// </summary>
|
||||
public record BulkCommandResult<T>(Guid Id, CommandResult<T> Result);
|
||||
|
||||
/// <summary>
|
||||
/// A wrapper for <see cref="CommandResult"/> with an ID, to identify the result in bulk operations.
|
||||
/// </summary>
|
||||
public record BulkCommandResult(Guid Id, CommandResult Result);
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Platform.Push;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OneOf.Types;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext;
|
||||
|
||||
public class DeleteClaimedOrganizationUserAccountCommandvNext(
|
||||
IUserService userService,
|
||||
IEventService eventService,
|
||||
IGetOrganizationUsersClaimedStatusQuery getOrganizationUsersClaimedStatusQuery,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IUserRepository userRepository,
|
||||
IPushNotificationService pushService,
|
||||
ILogger<DeleteClaimedOrganizationUserAccountCommandvNext> logger,
|
||||
IDeleteClaimedOrganizationUserAccountValidatorvNext deleteClaimedOrganizationUserAccountValidatorvNext)
|
||||
: IDeleteClaimedOrganizationUserAccountCommandvNext
|
||||
{
|
||||
public async Task<BulkCommandResult> DeleteUserAsync(Guid organizationId, Guid organizationUserId, Guid deletingUserId)
|
||||
{
|
||||
var result = await DeleteManyUsersAsync(organizationId, [organizationUserId], deletingUserId);
|
||||
return result.Single();
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<BulkCommandResult>> DeleteManyUsersAsync(Guid organizationId, IEnumerable<Guid> orgUserIds, Guid deletingUserId)
|
||||
{
|
||||
orgUserIds = orgUserIds.ToList();
|
||||
var orgUsers = await organizationUserRepository.GetManyAsync(orgUserIds);
|
||||
var users = await GetUsersAsync(orgUsers);
|
||||
var claimedStatuses = await getOrganizationUsersClaimedStatusQuery.GetUsersOrganizationClaimedStatusAsync(organizationId, orgUserIds);
|
||||
|
||||
var internalRequests = CreateInternalRequests(organizationId, deletingUserId, orgUserIds, orgUsers, users, claimedStatuses);
|
||||
var validationResults = (await deleteClaimedOrganizationUserAccountValidatorvNext.ValidateAsync(internalRequests)).ToList();
|
||||
|
||||
var validRequests = validationResults.ValidRequests();
|
||||
await CancelPremiumsAsync(validRequests);
|
||||
await HandleUserDeletionsAsync(validRequests);
|
||||
await LogDeletedOrganizationUsersAsync(validRequests);
|
||||
|
||||
return validationResults.Select(v => v.Match(
|
||||
error => new BulkCommandResult(v.Request.OrganizationUserId, error),
|
||||
_ => new BulkCommandResult(v.Request.OrganizationUserId, new None())
|
||||
));
|
||||
}
|
||||
|
||||
private static IEnumerable<DeleteUserValidationRequest> CreateInternalRequests(
|
||||
Guid organizationId,
|
||||
Guid deletingUserId,
|
||||
IEnumerable<Guid> orgUserIds,
|
||||
ICollection<OrganizationUser> orgUsers,
|
||||
IEnumerable<User> users,
|
||||
IDictionary<Guid, bool> claimedStatuses)
|
||||
{
|
||||
foreach (var orgUserId in orgUserIds)
|
||||
{
|
||||
var orgUser = orgUsers.FirstOrDefault(orgUser => orgUser.Id == orgUserId);
|
||||
var user = users.FirstOrDefault(user => user.Id == orgUser?.UserId);
|
||||
claimedStatuses.TryGetValue(orgUserId, out var isClaimed);
|
||||
|
||||
yield return new DeleteUserValidationRequest
|
||||
{
|
||||
User = user,
|
||||
OrganizationUserId = orgUserId,
|
||||
OrganizationUser = orgUser,
|
||||
IsClaimed = isClaimed,
|
||||
OrganizationId = organizationId,
|
||||
DeletingUserId = deletingUserId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IEnumerable<User>> GetUsersAsync(ICollection<OrganizationUser> orgUsers)
|
||||
{
|
||||
var userIds = orgUsers
|
||||
.Where(orgUser => orgUser.UserId.HasValue)
|
||||
.Select(orgUser => orgUser.UserId!.Value)
|
||||
.ToList();
|
||||
|
||||
return await userRepository.GetManyAsync(userIds);
|
||||
}
|
||||
|
||||
private async Task LogDeletedOrganizationUsersAsync(IEnumerable<DeleteUserValidationRequest> requests)
|
||||
{
|
||||
var eventDate = DateTime.UtcNow;
|
||||
|
||||
var events = requests
|
||||
.Select(request => (request.OrganizationUser!, EventType.OrganizationUser_Deleted, (DateTime?)eventDate))
|
||||
.ToList();
|
||||
|
||||
if (events.Count != 0)
|
||||
{
|
||||
await eventService.LogOrganizationUserEventsAsync(events);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleUserDeletionsAsync(IEnumerable<DeleteUserValidationRequest> requests)
|
||||
{
|
||||
var users = requests
|
||||
.Select(request => request.User!)
|
||||
.ToList();
|
||||
|
||||
if (users.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await userRepository.DeleteManyAsync(users);
|
||||
|
||||
foreach (var user in users)
|
||||
{
|
||||
await pushService.PushLogOutAsync(user.Id);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CancelPremiumsAsync(IEnumerable<DeleteUserValidationRequest> requests)
|
||||
{
|
||||
var users = requests.Select(request => request.User!);
|
||||
|
||||
foreach (var user in users)
|
||||
{
|
||||
try
|
||||
{
|
||||
await userService.CancelPremiumAsync(user);
|
||||
}
|
||||
catch (GatewayException exception)
|
||||
{
|
||||
logger.LogWarning(exception, "Failed to cancel premium subscription for {userId}.", user.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Repositories;
|
||||
using static Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext.ValidationResultHelpers;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext;
|
||||
|
||||
public class DeleteClaimedOrganizationUserAccountValidatorvNext(
|
||||
ICurrentContext currentContext,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IProviderUserRepository providerUserRepository) : IDeleteClaimedOrganizationUserAccountValidatorvNext
|
||||
{
|
||||
public async Task<IEnumerable<ValidationResult<DeleteUserValidationRequest>>> ValidateAsync(IEnumerable<DeleteUserValidationRequest> requests)
|
||||
{
|
||||
var tasks = requests.Select(ValidateAsync);
|
||||
var results = await Task.WhenAll(tasks);
|
||||
return results;
|
||||
}
|
||||
|
||||
private async Task<ValidationResult<DeleteUserValidationRequest>> ValidateAsync(DeleteUserValidationRequest request)
|
||||
{
|
||||
// Ensure user exists
|
||||
if (request.User == null || request.OrganizationUser == null)
|
||||
{
|
||||
return Invalid(request, new UserNotFoundError());
|
||||
}
|
||||
|
||||
// Cannot delete invited users
|
||||
if (request.OrganizationUser.Status == OrganizationUserStatusType.Invited)
|
||||
{
|
||||
return Invalid(request, new InvalidUserStatusError());
|
||||
}
|
||||
|
||||
// Cannot delete yourself
|
||||
if (request.OrganizationUser.UserId == request.DeletingUserId)
|
||||
{
|
||||
return Invalid(request, new CannotDeleteYourselfError());
|
||||
}
|
||||
|
||||
// Can only delete a claimed user
|
||||
if (!request.IsClaimed)
|
||||
{
|
||||
return Invalid(request, new UserNotClaimedError());
|
||||
}
|
||||
|
||||
// Cannot delete an owner unless you are an owner or provider
|
||||
if (request.OrganizationUser.Type == OrganizationUserType.Owner &&
|
||||
!await currentContext.OrganizationOwner(request.OrganizationId))
|
||||
{
|
||||
return Invalid(request, new CannotDeleteOwnersError());
|
||||
}
|
||||
|
||||
// Cannot delete a user who is the sole owner of an organization
|
||||
var onlyOwnerCount = await organizationUserRepository.GetCountByOnlyOwnerAsync(request.User.Id);
|
||||
if (onlyOwnerCount > 0)
|
||||
{
|
||||
return Invalid(request, new SoleOwnerError());
|
||||
}
|
||||
|
||||
// Cannot delete a user who is the sole member of a provider
|
||||
var onlyOwnerProviderCount = await providerUserRepository.GetCountByOnlyOwnerAsync(request.User.Id);
|
||||
if (onlyOwnerProviderCount > 0)
|
||||
{
|
||||
return Invalid(request, new SoleProviderError());
|
||||
}
|
||||
|
||||
// Custom users cannot delete admins
|
||||
if (request.OrganizationUser.Type == OrganizationUserType.Admin && await currentContext.OrganizationCustom(request.OrganizationId))
|
||||
{
|
||||
return Invalid(request, new CannotDeleteAdminsError());
|
||||
}
|
||||
|
||||
return Valid(request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using Bit.Core.Entities;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext;
|
||||
|
||||
public class DeleteUserValidationRequest
|
||||
{
|
||||
public Guid OrganizationId { get; init; }
|
||||
public Guid OrganizationUserId { get; init; }
|
||||
public OrganizationUser? OrganizationUser { get; init; }
|
||||
public User? User { get; init; }
|
||||
public Guid DeletingUserId { get; init; }
|
||||
public bool IsClaimed { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext;
|
||||
|
||||
/// <summary>
|
||||
/// A strongly typed error containing a reason that an action failed.
|
||||
/// This is used for business logic validation and other expected errors, not exceptions.
|
||||
/// </summary>
|
||||
public abstract record Error(string Message);
|
||||
/// <summary>
|
||||
/// An <see cref="Error"/> type that maps to a NotFoundResult at the api layer.
|
||||
/// </summary>
|
||||
/// <param name="Message"></param>
|
||||
public abstract record NotFoundError(string Message) : Error(Message);
|
||||
|
||||
public record UserNotFoundError() : NotFoundError("Invalid user.");
|
||||
public record UserNotClaimedError() : Error("Member is not claimed by the organization.");
|
||||
public record InvalidUserStatusError() : Error("You cannot delete a member with Invited status.");
|
||||
public record CannotDeleteYourselfError() : Error("You cannot delete yourself.");
|
||||
public record CannotDeleteOwnersError() : Error("Only owners can delete other owners.");
|
||||
public record SoleOwnerError() : Error("Cannot delete this user because it is the sole owner of at least one organization. Please delete these organizations or upgrade another user.");
|
||||
public record SoleProviderError() : Error("Cannot delete this user because it is the sole owner of at least one provider. Please delete these providers or upgrade another user.");
|
||||
public record CannotDeleteAdminsError() : Error("Custom users can not delete admins.");
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext;
|
||||
|
||||
public interface IDeleteClaimedOrganizationUserAccountCommandvNext
|
||||
{
|
||||
/// <summary>
|
||||
/// Removes a user from an organization and deletes all of their associated user data.
|
||||
/// </summary>
|
||||
Task<BulkCommandResult> 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<BulkCommandResult>> DeleteManyUsersAsync(Guid organizationId, IEnumerable<Guid> orgUserIds, Guid deletingUserId);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext;
|
||||
|
||||
public interface IDeleteClaimedOrganizationUserAccountValidatorvNext
|
||||
{
|
||||
Task<IEnumerable<ValidationResult<DeleteUserValidationRequest>>> ValidateAsync(IEnumerable<DeleteUserValidationRequest> requests);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using OneOf;
|
||||
using OneOf.Types;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the result of validating a request.
|
||||
/// This is for use within the Core layer, e.g. validating a command request.
|
||||
/// </summary>
|
||||
/// <param name="request">The request that has been validated.</param>
|
||||
/// <param name="error">A <see cref="OneOf{Error, None}"/> type that contains an Error if validation failed.</param>
|
||||
/// <typeparam name="TRequest">The request type.</typeparam>
|
||||
public class ValidationResult<TRequest>(TRequest request, OneOf<Error, None> error) : OneOfBase<Error, None>(error)
|
||||
{
|
||||
public TRequest Request { get; } = request;
|
||||
|
||||
public bool IsError => IsT0;
|
||||
public bool IsValid => IsT1;
|
||||
public Error AsError => AsT0;
|
||||
}
|
||||
|
||||
public static class ValidationResultHelpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a successful <see cref="ValidationResult{TRequest}"/> with no error set.
|
||||
/// </summary>
|
||||
public static ValidationResult<T> Valid<T>(T request) => new(request, new None());
|
||||
/// <summary>
|
||||
/// Creates a failed <see cref="ValidationResult{TRequest}"/> with the specified error.
|
||||
/// </summary>
|
||||
public static ValidationResult<T> Invalid<T>(T request, Error error) => new(request, error);
|
||||
|
||||
/// <summary>
|
||||
/// Extracts successfully validated requests from a sequence of <see cref="ValidationResult{TRequest}"/>.
|
||||
/// </summary>
|
||||
public static List<T> ValidRequests<T>(this IEnumerable<ValidationResult<T>> results) =>
|
||||
results
|
||||
.Where(r => r.IsValid)
|
||||
.Select(r => r.Request)
|
||||
.ToList();
|
||||
}
|
||||
Reference in New Issue
Block a user