diff --git a/src/Api/AdminConsole/Authorization/AuthorizationHandlerCollectionExtensions.cs b/src/Api/AdminConsole/Authorization/AuthorizationHandlerCollectionExtensions.cs index 233dc138a61d..5f2713ae8692 100644 --- a/src/Api/AdminConsole/Authorization/AuthorizationHandlerCollectionExtensions.cs +++ b/src/Api/AdminConsole/Authorization/AuthorizationHandlerCollectionExtensions.cs @@ -13,10 +13,10 @@ public static void AddAdminConsoleAuthorizationHandlers(this IServiceCollection services.TryAddEnumerable([ ServiceDescriptor.Scoped(), - ServiceDescriptor.Scoped(), - ServiceDescriptor.Scoped(), - ServiceDescriptor.Scoped(), - ServiceDescriptor.Scoped(), - ]); + ServiceDescriptor.Scoped(), + ServiceDescriptor.Scoped(), + ServiceDescriptor.Scoped(), + ServiceDescriptor.Scoped(), + ]); } } diff --git a/src/Api/AdminConsole/Controllers/BaseAdminConsoleController.cs b/src/Api/AdminConsole/Controllers/BaseAdminConsoleController.cs new file mode 100644 index 000000000000..0b53ccb23a6a --- /dev/null +++ b/src/Api/AdminConsole/Controllers/BaseAdminConsoleController.cs @@ -0,0 +1,53 @@ +using Bit.Core.AdminConsole.Utilities.v2; +using Bit.Core.AdminConsole.Utilities.v2.Results; +using Bit.Core.Models.Api; +using Microsoft.AspNetCore.Mvc; + +namespace Bit.Api.AdminConsole.Controllers; + +public abstract class BaseAdminConsoleController : Controller +{ + protected static IResult Handle(CommandResult commandResult, Func resultSelector) => + commandResult.Match( + error => error switch + { + BadRequestError badRequest => TypedResults.BadRequest(new ErrorResponseModel(badRequest.Message)), + NotFoundError notFound => TypedResults.NotFound(new ErrorResponseModel(notFound.Message)), + InternalError internalError => TypedResults.Json( + new ErrorResponseModel(internalError.Message), + statusCode: StatusCodes.Status500InternalServerError), + _ => TypedResults.Json( + new ErrorResponseModel(error.Message), + statusCode: StatusCodes.Status500InternalServerError + ) + }, + success => Results.Ok(resultSelector(success)) + ); + + protected static IResult Handle(BulkCommandResult commandResult) => + commandResult.Result.Match( + error => error switch + { + NotFoundError notFoundError => TypedResults.NotFound(new ErrorResponseModel(notFoundError.Message)), + _ => TypedResults.BadRequest(new ErrorResponseModel(error.Message)) + }, + _ => TypedResults.NoContent() + ); + + protected static IResult Handle(CommandResult commandResult) => + commandResult.Match( + error => error switch + { + BadRequestError badRequest => TypedResults.BadRequest(new ErrorResponseModel(badRequest.Message)), + NotFoundError notFound => TypedResults.NotFound(new ErrorResponseModel(notFound.Message)), + InternalError internalError => TypedResults.Json( + new ErrorResponseModel(internalError.Message), + statusCode: StatusCodes.Status500InternalServerError), + _ => TypedResults.Json( + new ErrorResponseModel(error.Message), + statusCode: StatusCodes.Status500InternalServerError + ) + }, + _ => TypedResults.NoContent() + ); +} diff --git a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs index 4b9f7e5d7130..8c685f9b6424 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs @@ -11,8 +11,10 @@ using Bit.Api.Vault.AuthorizationHandlers.Collections; using Bit.Core; using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.AccountRecovery; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; @@ -43,7 +45,7 @@ namespace Bit.Api.AdminConsole.Controllers; [Route("organizations/{orgId}/users")] [Authorize("Application")] -public class OrganizationUsersController : Controller +public class OrganizationUsersController : BaseAdminConsoleController { private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationUserRepository _organizationUserRepository; @@ -68,6 +70,8 @@ public class OrganizationUsersController : Controller private readonly IFeatureService _featureService; private readonly IPricingClient _pricingClient; private readonly IResendOrganizationInviteCommand _resendOrganizationInviteCommand; + private readonly IAutomaticallyConfirmOrganizationUserCommand _automaticallyConfirmOrganizationUserCommand; + private readonly TimeProvider _timeProvider; private readonly IConfirmOrganizationUserCommand _confirmOrganizationUserCommand; private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand; private readonly IInitPendingOrganizationCommand _initPendingOrganizationCommand; @@ -101,7 +105,9 @@ public OrganizationUsersController(IOrganizationRepository organizationRepositor IInitPendingOrganizationCommand initPendingOrganizationCommand, IRevokeOrganizationUserCommand revokeOrganizationUserCommand, IResendOrganizationInviteCommand resendOrganizationInviteCommand, - IAdminRecoverAccountCommand adminRecoverAccountCommand) + IAdminRecoverAccountCommand adminRecoverAccountCommand, + IAutomaticallyConfirmOrganizationUserCommand automaticallyConfirmOrganizationUserCommand, + TimeProvider timeProvider) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -126,6 +132,8 @@ public OrganizationUsersController(IOrganizationRepository organizationRepositor _featureService = featureService; _pricingClient = pricingClient; _resendOrganizationInviteCommand = resendOrganizationInviteCommand; + _automaticallyConfirmOrganizationUserCommand = automaticallyConfirmOrganizationUserCommand; + _timeProvider = timeProvider; _confirmOrganizationUserCommand = confirmOrganizationUserCommand; _restoreOrganizationUserCommand = restoreOrganizationUserCommand; _initPendingOrganizationCommand = initPendingOrganizationCommand; @@ -591,14 +599,7 @@ public async Task DeleteAccount(Guid orgId, Guid id) return TypedResults.Unauthorized(); } - var commandResult = await _deleteClaimedOrganizationUserAccountCommand.DeleteUserAsync(orgId, id, currentUserId.Value); - - return commandResult.Result.Match( - error => error is NotFoundError - ? TypedResults.NotFound(new ErrorResponseModel(error.Message)) - : TypedResults.BadRequest(new ErrorResponseModel(error.Message)), - TypedResults.Ok - ); + return Handle(await _deleteClaimedOrganizationUserAccountCommand.DeleteUserAsync(orgId, id, currentUserId.Value)); } [HttpPost("{id}/delete-account")] @@ -738,6 +739,32 @@ public async Task PatchBulkEnableSecretsManagerAsync(Guid orgId, await BulkEnableSecretsManagerAsync(orgId, model); } + [HttpPost("{id}/auto-confirm")] + [Authorize] + [RequireFeature(FeatureFlagKeys.AutomaticConfirmUsers)] + public async Task AutomaticallyConfirmOrganizationUserAsync([FromRoute] Guid orgId, + [FromRoute] Guid id, + [FromBody] OrganizationUserConfirmRequestModel model) + { + var userId = _userService.GetProperUserId(User); + + if (userId is null || userId.Value == Guid.Empty) + { + return TypedResults.Unauthorized(); + } + + return Handle(await _automaticallyConfirmOrganizationUserCommand.AutomaticallyConfirmOrganizationUserAsync( + new AutomaticallyConfirmOrganizationUserRequest + { + OrganizationId = orgId, + OrganizationUserId = id, + Key = model.Key, + DefaultUserCollectionName = model.DefaultUserCollectionName, + PerformedBy = new StandardUser(userId.Value, await _currentContext.OrganizationOwner(orgId)), + PerformedOn = _timeProvider.GetUtcNow() + })); + } + private async Task RestoreOrRevokeUserAsync( Guid orgId, Guid id, diff --git a/src/Core/AdminConsole/Enums/EventType.cs b/src/Core/AdminConsole/Enums/EventType.cs index 8073938fc514..09cda7ca0e2f 100644 --- a/src/Core/AdminConsole/Enums/EventType.cs +++ b/src/Core/AdminConsole/Enums/EventType.cs @@ -60,6 +60,7 @@ public enum EventType : int OrganizationUser_RejectedAuthRequest = 1514, OrganizationUser_Deleted = 1515, // Both user and organization user data were deleted OrganizationUser_Left = 1516, // User voluntarily left the organization + OrganizationUser_AutomaticallyConfirmed = 1517, Organization_Updated = 1600, Organization_PurgedVault = 1601, diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/AutomaticallyConfirmOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/AutomaticallyConfirmOrganizationUserCommand.cs new file mode 100644 index 000000000000..b9aeb170a10d --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/AutomaticallyConfirmOrganizationUserCommand.cs @@ -0,0 +1,210 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; +using Bit.Core.AdminConsole.Utilities.v2.Results; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Models.Data; +using Bit.Core.Platform.Push; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Microsoft.Extensions.Logging; +using OneOf.Types; +using CommandResult = Bit.Core.AdminConsole.Utilities.v2.Results.CommandResult; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser; + +public class AutomaticallyConfirmOrganizationUserCommand(IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository, + IAutomaticallyConfirmOrganizationUsersValidator validator, + IEventService eventService, + IMailService mailService, + IUserRepository userRepository, + IPushRegistrationService pushRegistrationService, + IDeviceRepository deviceRepository, + IPushNotificationService pushNotificationService, + IFeatureService featureService, + IPolicyRequirementQuery policyRequirementQuery, + ICollectionRepository collectionRepository, + ILogger logger) : IAutomaticallyConfirmOrganizationUserCommand +{ + public async Task AutomaticallyConfirmOrganizationUserAsync(AutomaticallyConfirmOrganizationUserRequest request) + { + var requestData = await RetrieveDataAsync(request); + + if (requestData.IsError) + { + return requestData.AsError; + } + + var validatedData = await validator.ValidateAsync(requestData.AsSuccess); + + if (validatedData.IsError) + { + return validatedData.AsError; + } + + var validatedRequest = validatedData.Request; + + var successfulConfirmation = await organizationUserRepository.ConfirmOrganizationUserAsync(validatedRequest.OrganizationUser); + + if (!successfulConfirmation) + { + return new None(); // Operation is idempotent. If this is false, then the user is already confirmed. + } + + return await validatedRequest.ToCommandResultAsync() + .MapAsync(CreateDefaultCollectionsAsync) + .MapAsync(LogOrganizationUserConfirmedEventAsync) + .MapAsync(SendConfirmedOrganizationUserEmailAsync) + .MapAsync(DeleteDeviceRegistrationAsync) + .MapAsync(PushSyncOrganizationKeysAsync) + .ToResultAsync(); + } + + private async Task> CreateDefaultCollectionsAsync( + AutomaticallyConfirmOrganizationUserValidationRequest request) + { + try + { + if (!featureService.IsEnabled(FeatureFlagKeys.CreateDefaultLocation) + || string.IsNullOrWhiteSpace(request.DefaultUserCollectionName) + || !(await policyRequirementQuery.GetAsync(request.UserId)) + .RequiresDefaultCollectionOnConfirm(request.Organization.Id)) + { + return request; + } + + await collectionRepository.CreateAsync( + new Collection + { + OrganizationId = request.Organization.Id, + Name = request.DefaultUserCollectionName, + Type = CollectionType.DefaultUserCollection + }, + groups: null, + [new CollectionAccessSelection + { + Id = request.OrganizationUser.Id, + Manage = true + }]); + + return request; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to create default collection for user."); + return new CommandResult(new FailedToCreateDefaultCollection()); + } + } + + private async Task> PushSyncOrganizationKeysAsync(AutomaticallyConfirmOrganizationUserValidationRequest request) + { + try + { + await pushNotificationService.PushSyncOrgKeysAsync(request.UserId); + return request; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to push organization keys."); + return new FailedToPushOrganizationSyncKeys(); + } + } + + private async Task> LogOrganizationUserConfirmedEventAsync( + AutomaticallyConfirmOrganizationUserValidationRequest request) + { + try + { + await eventService.LogOrganizationUserEventAsync(request.OrganizationUser, + EventType.OrganizationUser_AutomaticallyConfirmed, + request.PerformedOn.UtcDateTime); + return request; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to log OrganizationUser_AutomaticallyConfirmed event."); + return new FailedToWriteToEventLog(); + } + } + + private async Task> SendConfirmedOrganizationUserEmailAsync( + AutomaticallyConfirmOrganizationUserValidationRequest request) + { + try + { + var user = await userRepository.GetByIdAsync(request.UserId); + + if (user is null) return new UserNotFoundError(); + + await mailService.SendOrganizationConfirmedEmailAsync(request.Organization.Name, + user.Email, + request.OrganizationUser.AccessSecretsManager); + + return request; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to send OrganizationUserConfirmed."); + return new FailedToSendConfirmedUserEmail(); + } + } + + private async Task> DeleteDeviceRegistrationAsync( + AutomaticallyConfirmOrganizationUserValidationRequest request) + { + try + { + var devices = (await deviceRepository.GetManyByUserIdAsync(request.UserId)) + .Where(d => !string.IsNullOrWhiteSpace(d.PushToken)) + .Select(d => d.Id.ToString()); + + await pushRegistrationService.DeleteUserRegistrationOrganizationAsync(devices, request.Organization.Id.ToString()); + return request; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to delete device registration."); + return new FailedToDeleteDeviceRegistration(); + } + } + + private async Task> RetrieveDataAsync( + AutomaticallyConfirmOrganizationUserRequest request) + { + var organizationUser = await GetOrganizationUserAsync(request.OrganizationUserId); + + if (organizationUser.IsError) return organizationUser.AsError; + + var organization = await GetOrganizationAsync(request.OrganizationId); + + if (organization.IsError) return organization.AsError; + + return new AutomaticallyConfirmOrganizationUserValidationRequest + { + OrganizationUser = organizationUser.AsSuccess, + Organization = organization.AsSuccess, + PerformedBy = request.PerformedBy, + Key = request.Key, + DefaultUserCollectionName = request.DefaultUserCollectionName, + PerformedOn = request.PerformedOn + }; + } + + private async Task> GetOrganizationUserAsync(Guid organizationUserId) + { + var organizationUser = await organizationUserRepository.GetByIdAsync(organizationUserId); + + return organizationUser is { UserId: not null } ? organizationUser : new UserNotFoundError(); + } + + private async Task> GetOrganizationAsync(Guid organizationId) + { + var organization = await organizationRepository.GetByIdAsync(organizationId); + + return organization is not null ? organization : new OrganizationNotFound(); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/AutomaticallyConfirmOrganizationUserRequest.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/AutomaticallyConfirmOrganizationUserRequest.cs new file mode 100644 index 000000000000..fbd55af936b2 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/AutomaticallyConfirmOrganizationUserRequest.cs @@ -0,0 +1,33 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Models.Data; +using Bit.Core.Entities; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser; + +public record AutomaticallyConfirmOrganizationUserRequest +{ + public required Guid OrganizationUserId { get; init; } + public required Guid OrganizationId { get; init; } + public required string Key { get; init; } + + public required string DefaultUserCollectionName { get; init; } + public required IActingUser PerformedBy { get; init; } + public required DateTimeOffset PerformedOn { get; init; } +} + +public record AutomaticallyConfirmOrganizationUserValidationRequest +{ + public required OrganizationUser OrganizationUser { get; init; } + + public Guid UserId => OrganizationUser.UserId ?? throw new ArgumentNullException(nameof(OrganizationUser.UserId)); + + public required Organization Organization { get; init; } + + public required IActingUser PerformedBy { get; init; } + public required DateTimeOffset PerformedOn { get; init; } + + public required string Key { get; init; } + + public required string DefaultUserCollectionName { get; init; } + +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/AutomaticallyConfirmOrganizationUsersValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/AutomaticallyConfirmOrganizationUsersValidator.cs new file mode 100644 index 000000000000..8c529bfcfb29 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/AutomaticallyConfirmOrganizationUsersValidator.cs @@ -0,0 +1,120 @@ +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; +using Bit.Core.AdminConsole.Services; +using Bit.Core.AdminConsole.Utilities.v2.Validation; +using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; +using Bit.Core.Billing.Enums; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Bit.Core.Services; +using static Bit.Core.AdminConsole.Utilities.v2.Validation.ValidationResultHelpers; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser; + +public class AutomaticallyConfirmOrganizationUsersValidator( + IOrganizationUserRepository organizationUserRepository, + IFeatureService featureService, + ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, + IPolicyService policyService, + IPolicyRequirementQuery policyRequirementQuery + ) : IAutomaticallyConfirmOrganizationUsersValidator +{ + public async Task> ValidateAsync( + AutomaticallyConfirmOrganizationUserValidationRequest request) + { + var orgUser = request.OrganizationUser; + + if (orgUser.Status != OrganizationUserStatusType.Accepted) + { + return Invalid(request, new UserIsNotAccepted()); + } + + if (orgUser.OrganizationId != request.Organization.Id) + { + return Invalid(request, new OrganizationUserIdIsInvalid()); + } + + return await OrganizationUserOwnershipValidationAsync(request) + .ThenAsync(OrganizationUserConformsToOrganizationPoliciesAsync); + } + + private async Task> OrganizationUserOwnershipValidationAsync( + AutomaticallyConfirmOrganizationUserValidationRequest request) => + request.Organization.PlanType is not PlanType.Free + || await organizationUserRepository.GetCountByFreeOrganizationAdminUserAsync(request.UserId) == 0 + ? Valid(request) + : Invalid(request, new UserToConfirmIsAnAdminOrOwnerOfAnotherFreeOrganization()); + + private async Task> + OrganizationUserConformsToOrganizationPoliciesAsync(AutomaticallyConfirmOrganizationUserValidationRequest request) + { + return await OrganizationUserConformsToTwoFactorRequiredPolicyAsync(request) + .ThenAsync(OrganizationUserConformsToSingleOrgPolicyAsync); + } + + private async Task> OrganizationUserConformsToTwoFactorRequiredPolicyAsync( + AutomaticallyConfirmOrganizationUserValidationRequest request) + { + if ((await twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync([request.UserId])) + .Any(x => x.userId == request.UserId && x.twoFactorIsEnabled)) + { + return Valid(request); + } + + if (!featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements)) + { + return (await policyService.GetPoliciesApplicableToUserAsync(request.UserId, + PolicyType.TwoFactorAuthentication)) + .Any(p => p.OrganizationId == request.Organization.Id) + ? Invalid(request, new UserDoesNotHaveTwoFactorEnabled()) + : Valid(request); + } + + return (await policyRequirementQuery.GetAsync(request.UserId)) + .IsTwoFactorRequiredForOrganization(request.Organization.Id) + ? Invalid(request, new UserDoesNotHaveTwoFactorEnabled()) + : Valid(request); + } + + private async Task> OrganizationUserConformsToSingleOrgPolicyAsync( + AutomaticallyConfirmOrganizationUserValidationRequest request) + { + var allOrganizationUsersForUser = await organizationUserRepository.GetManyByUserAsync(request.UserId); + + if (allOrganizationUsersForUser.Count == 1) + { + return Valid(request); + } + + if (!featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements)) + { + var organizationsWithSingleOrgPoliciesForUser = + await policyService.GetPoliciesApplicableToUserAsync(request.UserId, PolicyType.SingleOrg); + + if (organizationsWithSingleOrgPoliciesForUser.Any(ou => ou.OrganizationId == request.Organization.Id)) + { + return Invalid(request, new OrganizationEnforcesSingleOrgPolicy()); + } + + if (organizationsWithSingleOrgPoliciesForUser.Any(ou => ou.OrganizationId != request.Organization.Id)) + { + return Invalid(request, new OtherOrganizationEnforcesSingleOrgPolicy()); + } + } + + var policyRequirement = await policyRequirementQuery.GetAsync(request.UserId); + + if (policyRequirement.IsSingleOrgEnabledForThisOrganization(request.Organization.Id)) + { + return Invalid(request, new OrganizationEnforcesSingleOrgPolicy()); + } + + if (policyRequirement.IsSingleOrgEnabledForOrganizationsOtherThan(request.Organization.Id)) + { + return Invalid(request, new OtherOrganizationEnforcesSingleOrgPolicy()); + } + + return Valid(request); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/Errors.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/Errors.cs new file mode 100644 index 000000000000..5132219e7be7 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/Errors.cs @@ -0,0 +1,16 @@ +using Bit.Core.AdminConsole.Utilities.v2; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser; + +public record OrganizationNotFound() : NotFoundError("Invalid organization"); +public record FailedToWriteToEventLog() : InternalError("Failed to write to event log"); +public record FailedToSendConfirmedUserEmail() : InternalError("Failed to send confirmed user email"); +public record FailedToDeleteDeviceRegistration() : InternalError("Failed to delete device registration"); +public record FailedToPushOrganizationSyncKeys() : InternalError("Failed to push organization sync keys"); +public record UserIsNotAccepted() : BadRequestError("Cannot confirm user that has not accepted the invitation."); +public record OrganizationUserIdIsInvalid() : BadRequestError("Invalid organization user id."); +public record UserToConfirmIsAnAdminOrOwnerOfAnotherFreeOrganization() : BadRequestError("User to confirm is an admin or owner of another free organization."); +public record UserDoesNotHaveTwoFactorEnabled() : BadRequestError("User does not have two-step login enabled."); +public record OrganizationEnforcesSingleOrgPolicy() : BadRequestError("Cannot confirm this member to the organization until they leave or remove all other organizations"); +public record OtherOrganizationEnforcesSingleOrgPolicy() : BadRequestError("Cannot confirm this member to the organization because they are in another organization which forbids it."); +public record FailedToCreateDefaultCollection() : InternalError("Failed to create default collection for user"); diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/IAutomaticallyConfirmOrganizationUsersValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/IAutomaticallyConfirmOrganizationUsersValidator.cs new file mode 100644 index 000000000000..544b65b53f10 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/IAutomaticallyConfirmOrganizationUsersValidator.cs @@ -0,0 +1,9 @@ +using Bit.Core.AdminConsole.Utilities.v2.Validation; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser; + +public interface IAutomaticallyConfirmOrganizationUsersValidator +{ + Task> ValidateAsync( + AutomaticallyConfirmOrganizationUserValidationRequest request); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/DeleteClaimedOrganizationUserAccountCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/DeleteClaimedOrganizationUserAccountCommand.cs index 87c24c3ab4e6..c5c423f2bbad 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/DeleteClaimedOrganizationUserAccountCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/DeleteClaimedOrganizationUserAccountCommand.cs @@ -1,4 +1,6 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.Utilities.v2.Results; +using Bit.Core.AdminConsole.Utilities.v2.Validation; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/DeleteClaimedOrganizationUserAccountValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/DeleteClaimedOrganizationUserAccountValidator.cs index 315d45ea69a4..71eff3ae6916 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/DeleteClaimedOrganizationUserAccountValidator.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/DeleteClaimedOrganizationUserAccountValidator.cs @@ -1,8 +1,9 @@ using Bit.Core.AdminConsole.Repositories; +using Bit.Core.AdminConsole.Utilities.v2.Validation; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Repositories; -using static Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount.ValidationResultHelpers; +using static Bit.Core.AdminConsole.Utilities.v2.Validation.ValidationResultHelpers; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount; diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/Errors.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/Errors.cs index 6c8f7ee00c5f..a76104cc8870 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/Errors.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/Errors.cs @@ -1,15 +1,6 @@ -namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount; +using Bit.Core.AdminConsole.Utilities.v2; -/// -/// A strongly typed error containing a reason that an action failed. -/// This is used for business logic validation and other expected errors, not exceptions. -/// -public abstract record Error(string Message); -/// -/// An type that maps to a NotFoundResult at the api layer. -/// -/// -public abstract record NotFoundError(string Message) : Error(Message); +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount; public record UserNotFoundError() : NotFoundError("Invalid user."); public record UserNotClaimedError() : Error("Member is not claimed by the organization."); diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/IDeleteClaimedOrganizationUserAccountCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/IDeleteClaimedOrganizationUserAccountCommand.cs index 983a3a4f21bf..408d3e8bcde0 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/IDeleteClaimedOrganizationUserAccountCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/IDeleteClaimedOrganizationUserAccountCommand.cs @@ -1,4 +1,6 @@ -namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount; +using Bit.Core.AdminConsole.Utilities.v2.Results; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount; public interface IDeleteClaimedOrganizationUserAccountCommand { diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/IDeleteClaimedOrganizationUserAccountValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/IDeleteClaimedOrganizationUserAccountValidator.cs index f1a2c71b1b29..05e97e896a40 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/IDeleteClaimedOrganizationUserAccountValidator.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/IDeleteClaimedOrganizationUserAccountValidator.cs @@ -1,4 +1,6 @@ -namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount; +using Bit.Core.AdminConsole.Utilities.v2.Validation; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount; public interface IDeleteClaimedOrganizationUserAccountValidator { diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IAutomaticallyConfirmOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IAutomaticallyConfirmOrganizationUserCommand.cs new file mode 100644 index 000000000000..5b8703b28fb5 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IAutomaticallyConfirmOrganizationUserCommand.cs @@ -0,0 +1,27 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser; +using Bit.Core.AdminConsole.Utilities.v2.Results; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; + +/// +/// Command to automatically confirm an organization user. +/// +/// +/// The auto-confirm feature enables eligible client apps to confirm OrganizationUsers +/// automatically via push notifications, eliminating the need for manual administrator +/// intervention. Client apps receive a push notification, perform the required key exchange, +/// and submit an auto-confirm request to the server. This command processes those +/// client-initiated requests and should only be used in that specific context. +/// +public interface IAutomaticallyConfirmOrganizationUserCommand +{ + /// + /// Automatically confirms the organization user based on the provided request data. + /// + /// The request containing necessary information to confirm the organization user. + /// + /// The result of the command. If there was an error, the result will contain a typed error describing the problem + /// that occurred. + /// + Task AutomaticallyConfirmOrganizationUserAsync(AutomaticallyConfirmOrganizationUserRequest request); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/SingleOrganizationPolicyRequirement.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/SingleOrganizationPolicyRequirement.cs new file mode 100644 index 000000000000..d1e1efafd996 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/SingleOrganizationPolicyRequirement.cs @@ -0,0 +1,21 @@ +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; + +public class SingleOrganizationPolicyRequirement(IEnumerable policyDetails) : IPolicyRequirement +{ + public bool IsSingleOrgEnabledForThisOrganization(Guid organizationId) => + policyDetails.Any(p => p.OrganizationId == organizationId); + + public bool IsSingleOrgEnabledForOrganizationsOtherThan(Guid organizationId) => + policyDetails.Any(p => p.OrganizationId != organizationId); +} + +public class SingleOrganizationPolicyRequirementFactory : BasePolicyRequirementFactory +{ + public override PolicyType PolicyType => PolicyType.SingleOrg; + + public override SingleOrganizationPolicyRequirement Create(IEnumerable policyDetails) => + new(policyDetails); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs index f3dbc83706b7..8dbe2ef77d0f 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs @@ -64,5 +64,6 @@ private static void AddPolicyRequirements(this IServiceCollection services) services.AddScoped, RequireSsoPolicyRequirementFactory>(); services.AddScoped, RequireTwoFactorPolicyRequirementFactory>(); services.AddScoped, MasterPasswordPolicyRequirementFactory>(); + services.AddScoped, SingleOrganizationPolicyRequirementFactory>(); } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SingleOrgPolicyValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SingleOrgPolicyValidator.cs index c0378bf5f965..9e301a54633b 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SingleOrgPolicyValidator.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SingleOrgPolicyValidator.cs @@ -29,8 +29,6 @@ public class SingleOrgPolicyValidator : IPolicyValidator, IPolicyValidationEvent private readonly IOrganizationRepository _organizationRepository; private readonly ISsoConfigRepository _ssoConfigRepository; private readonly ICurrentContext _currentContext; - private readonly IFeatureService _featureService; - private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; private readonly IOrganizationHasVerifiedDomainsQuery _organizationHasVerifiedDomainsQuery; private readonly IRevokeNonCompliantOrganizationUserCommand _revokeNonCompliantOrganizationUserCommand; @@ -40,8 +38,6 @@ public SingleOrgPolicyValidator( IOrganizationRepository organizationRepository, ISsoConfigRepository ssoConfigRepository, ICurrentContext currentContext, - IFeatureService featureService, - IRemoveOrganizationUserCommand removeOrganizationUserCommand, IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery, IRevokeNonCompliantOrganizationUserCommand revokeNonCompliantOrganizationUserCommand) { @@ -50,8 +46,6 @@ public SingleOrgPolicyValidator( _organizationRepository = organizationRepository; _ssoConfigRepository = ssoConfigRepository; _currentContext = currentContext; - _featureService = featureService; - _removeOrganizationUserCommand = removeOrganizationUserCommand; _organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery; _revokeNonCompliantOrganizationUserCommand = revokeNonCompliantOrganizationUserCommand; } diff --git a/src/Core/AdminConsole/Utilities/v2/Errors.cs b/src/Core/AdminConsole/Utilities/v2/Errors.cs new file mode 100644 index 000000000000..c1c66b2630e5 --- /dev/null +++ b/src/Core/AdminConsole/Utilities/v2/Errors.cs @@ -0,0 +1,15 @@ +namespace Bit.Core.AdminConsole.Utilities.v2; + +/// +/// A strongly typed error containing a reason that an action failed. +/// This is used for business logic validation and other expected errors, not exceptions. +/// +public abstract record Error(string Message); +/// +/// An type that maps to a NotFoundResult at the api layer. +/// +/// +public abstract record NotFoundError(string Message) : Error(Message); + +public abstract record BadRequestError(string Message) : Error(Message); +public abstract record InternalError(string Message) : Error(Message); diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/CommandResult.cs b/src/Core/AdminConsole/Utilities/v2/Results/CommandResult.cs similarity index 66% rename from src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/CommandResult.cs rename to src/Core/AdminConsole/Utilities/v2/Results/CommandResult.cs index fbb00a908ae5..b705700f43d4 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/CommandResult.cs +++ b/src/Core/AdminConsole/Utilities/v2/Results/CommandResult.cs @@ -1,7 +1,7 @@ using OneOf; using OneOf.Types; -namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount; +namespace Bit.Core.AdminConsole.Utilities.v2.Results; /// /// Represents the result of a command. @@ -40,3 +40,27 @@ public record BulkCommandResult(Guid Id, CommandResult Result); /// public record BulkCommandResult(Guid Id, CommandResult Result); +public static class CommandResultFunctions +{ + public static Task> ToCommandResultAsync(this T value) + { + return Task.FromResult>(value); + } + + public static async Task> MapAsync( + this Task> inputTask, + Func>> next) + { + var input = await inputTask; + if (input.IsError) return input.AsError; + return await next(input.AsSuccess); + } + + public static async Task ToResultAsync( + this Task> inputTask) + { + var input = await inputTask; + if (input.IsError) return input.AsError; + return new None(); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/ValidationResult.cs b/src/Core/AdminConsole/Utilities/v2/Validation/ValidationResult.cs similarity index 61% rename from src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/ValidationResult.cs rename to src/Core/AdminConsole/Utilities/v2/Validation/ValidationResult.cs index c84a0aeda12a..7feec0ca2390 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/ValidationResult.cs +++ b/src/Core/AdminConsole/Utilities/v2/Validation/ValidationResult.cs @@ -1,7 +1,7 @@ using OneOf; using OneOf.Types; -namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount; +namespace Bit.Core.AdminConsole.Utilities.v2.Validation; /// /// Represents the result of validating a request. @@ -38,4 +38,20 @@ public static List ValidRequests(this IEnumerable> res .Where(r => r.IsValid) .Select(r => r.Request) .ToList(); + + /// + /// Chains the execution of another asynchronous validation step to the result of an initial validation. + /// + /// The task representing the initial . + /// A function that defines the next asynchronous validation step to execute if the initial validation is valid. + /// The type of the request being validated. + /// A task representing the result of the chained validation step. + public static async Task> ThenAsync( + this Task> inputTask, + Func>> next) + { + var input = await inputTask; + if (input.IsError) return Invalid(input.Request, input.AsError); + return await next(input.Request); + } } diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index 8cfd0a8df1d7..91504b0b9bef 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -14,6 +14,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; @@ -135,6 +136,8 @@ private static void AddOrganizationUserCommands(this IServiceCollection services services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/PolicyRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/PolicyRepository.cs index 1cca7a9bbb58..894fb255beb3 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/PolicyRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/PolicyRepository.cs @@ -217,7 +217,7 @@ where p.Enabled UserId = u.Id }).ToListAsync(); - // Combine results with provder lookup + // Combine results with the provider lookup var allResults = acceptedUsers.Concat(invitedUsers) .Select(item => new OrganizationPolicyDetails { diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerTests.cs index 7c61a88bd8ce..ad0c674d8223 100644 --- a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerTests.cs @@ -170,7 +170,7 @@ public async Task DeleteAccount_Success() var httpResponse = await _client.DeleteAsync($"organizations/{_organization.Id}/users/{orgUserToDelete.Id}/delete-account"); - Assert.Equal(HttpStatusCode.OK, httpResponse.StatusCode); + Assert.Equal(HttpStatusCode.NoContent, httpResponse.StatusCode); Assert.Null(await userRepository.GetByIdAsync(orgUserToDelete.UserId.Value)); Assert.Null(await organizationUserRepository.GetByIdAsync(orgUserToDelete.Id)); } @@ -474,4 +474,117 @@ private async Task VerifyMultipleUsersConfirmedAsync(List<(OrganizationUser orgU Assert.Equal(acceptedOrganizationUsers[i].key, confirmedUser.Key); } } + + private static (ApiApplicationFactory factory, HttpClient client, LoginHelper loginHelper) GetAutoConfirmTestFactoryAsync() + { + var localFactory = new ApiApplicationFactory(); + localFactory.SubstituteService(featureService => + { + featureService + .IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers) + .Returns(true); + }); + var localClient = localFactory.CreateClient(); + var localLoginHelper = new LoginHelper(localFactory, localClient); + + return (localFactory, localClient, localLoginHelper); + } + + [Fact] + public async Task AutoConfirm_WhenAutoConfirmFlagIsDisabled_ThenShouldReturnNotFound() + { + var testKey = $"test-key-{Guid.NewGuid()}"; + var defaultCollectionName = _mockEncryptedString; + + await _loginHelper.LoginAsync(_ownerEmail); + + var (userEmail, organizationUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, + _organization.Id, OrganizationUserType.User); + + var result = await _client.PostAsJsonAsync($"organizations/{_organization.Id}/users/{organizationUser.Id}/auto-confirm", + new OrganizationUserConfirmRequestModel + { + Key = testKey, + DefaultUserCollectionName = defaultCollectionName + }); + + Assert.Equal(HttpStatusCode.NotFound, result.StatusCode); + } + + [Fact] + public async Task AutoConfirm_WhenUserCannotManageOtherUsers_ThenShouldReturnForbidden() + { + var (factory, client, loginHelper) = GetAutoConfirmTestFactoryAsync(); + + var ownerEmail = $"org-user-integration-test-{Guid.NewGuid()}@bitwarden.com"; + await factory.LoginWithNewAccount(ownerEmail); + + var (organization, _) = await OrganizationTestHelpers.SignUpAsync(factory, plan: PlanType.EnterpriseAnnually2023, + ownerEmail: ownerEmail, passwordManagerSeats: 5, paymentMethod: PaymentMethodType.Card); + + var testKey = $"test-key-{Guid.NewGuid()}"; + var defaultCollectionName = _mockEncryptedString; + + await loginHelper.LoginAsync(ownerEmail); + + var userToConfirmEmail = $"org-user-to-confirm-{Guid.NewGuid()}@bitwarden.com"; + await factory.LoginWithNewAccount(userToConfirmEmail); + + await loginHelper.LoginAsync(userToConfirmEmail); + var organizationUser = await OrganizationTestHelpers.CreateUserAsync( + factory, + organization.Id, + userToConfirmEmail, + OrganizationUserType.User, + false, + new Permissions(), + OrganizationUserStatusType.Accepted); + + var result = await client.PostAsJsonAsync($"organizations/{organization.Id}/users/{organizationUser.Id}/auto-confirm", + new OrganizationUserConfirmRequestModel + { + Key = testKey, + DefaultUserCollectionName = defaultCollectionName + }); + + Assert.Equal(HttpStatusCode.Forbidden, result.StatusCode); + } + + [Fact] + public async Task AutoConfirm_WhenOwnerInvitesValidUser_ThenShouldReturnNoContent() + { + var (factory, client, loginHelper) = GetAutoConfirmTestFactoryAsync(); + + var ownerEmail = $"org-user-integration-test-{Guid.NewGuid()}@bitwarden.com"; + await factory.LoginWithNewAccount(ownerEmail); + + var (organization, _) = await OrganizationTestHelpers.SignUpAsync(factory, plan: PlanType.EnterpriseAnnually2023, + ownerEmail: ownerEmail, passwordManagerSeats: 5, paymentMethod: PaymentMethodType.Card); + + var testKey = $"test-key-{Guid.NewGuid()}"; + var defaultCollectionName = _mockEncryptedString; + + await loginHelper.LoginAsync(ownerEmail); + + var userToConfirmEmail = $"org-user-to-confirm-{Guid.NewGuid()}@bitwarden.com"; + await factory.LoginWithNewAccount(userToConfirmEmail); + + var organizationUser = await OrganizationTestHelpers.CreateUserAsync( + factory, + organization.Id, + userToConfirmEmail, + OrganizationUserType.User, + false, + new Permissions(), + OrganizationUserStatusType.Accepted); + + var result = await client.PostAsJsonAsync($"organizations/{organization.Id}/users/{organizationUser.Id}/auto-confirm", + new OrganizationUserConfirmRequestModel + { + Key = testKey, + DefaultUserCollectionName = defaultCollectionName + }); + + Assert.Equal(HttpStatusCode.NoContent, result.StatusCode); + } } diff --git a/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs b/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs index c23ebff73686..3e40e4df53e9 100644 --- a/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs +++ b/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs @@ -192,6 +192,25 @@ public static async Task EnableOrganizationDataOwnershipPolicyAsync( await policyRepository.CreateAsync(policy); } + /// + /// Enables the Organization Auto Confirm policy for the specified organization. + /// + public static async Task EnableOrganizationAutoConfirmPolicyAsync( + WebApplicationFactoryBase factory, + Guid organizationId) where T : class + { + var policyRepository = factory.GetService(); + + var policy = new Policy + { + OrganizationId = organizationId, + Type = PolicyType.AutomaticUserConfirmation, + Enabled = true + }; + + await policyRepository.CreateAsync(policy); + } + /// /// Creates a user account without a Master Password and adds them as a member to the specified organization. /// diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs index 5875cda05a46..ae1400122353 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs @@ -9,10 +9,12 @@ using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.AccountRecovery; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Repositories; +using Bit.Core.AdminConsole.Utilities.v2.Results; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Repositories; using Bit.Core.Context; @@ -33,9 +35,11 @@ using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc.ModelBinding; using NSubstitute; +using OneOf.Types; using Xunit; namespace Bit.Api.Test.AdminConsole.Controllers; @@ -476,7 +480,7 @@ public async Task PutResetPassword_WithFeatureFlagDisabled_WhenOrgUserTypeIsNull var result = await sutProvider.Sut.PutResetPassword(orgId, orgUserId, model); - Assert.IsType(result); + Assert.IsType(result); } [Theory] @@ -506,7 +510,7 @@ public async Task PutResetPassword_WithFeatureFlagEnabled_WhenOrganizationUserNo var result = await sutProvider.Sut.PutResetPassword(orgId, orgUserId, model); - Assert.IsType(result); + Assert.IsType(result); } [Theory] @@ -521,7 +525,7 @@ public async Task PutResetPassword_WithFeatureFlagEnabled_WhenOrganizationIdMism var result = await sutProvider.Sut.PutResetPassword(orgId, orgUserId, model); - Assert.IsType(result); + Assert.IsType(result); } [Theory] @@ -594,4 +598,190 @@ public async Task PutResetPassword_WithFeatureFlagEnabled_WhenRecoverAccountFail Assert.IsType>(result); } + + [Theory] + [BitAutoData] + public async Task AutomaticallyConfirmOrganizationUserAsync_UserIdNull_ReturnsUnauthorized( + Guid orgId, + Guid orgUserId, + OrganizationUserConfirmRequestModel model, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers) + .Returns(true); + + sutProvider.GetDependency() + .GetProperUserId(Arg.Any()) + .Returns((Guid?)null); + + // Act + var result = await sutProvider.Sut.AutomaticallyConfirmOrganizationUserAsync(orgId, orgUserId, model); + + // Assert + Assert.IsType(result); + } + + [Theory] + [BitAutoData] + public async Task AutomaticallyConfirmOrganizationUserAsync_UserIdEmpty_ReturnsUnauthorized( + Guid orgId, + Guid orgUserId, + OrganizationUserConfirmRequestModel model, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers) + .Returns(true); + + sutProvider.GetDependency() + .GetProperUserId(Arg.Any()) + .Returns(Guid.Empty); + + // Act + var result = await sutProvider.Sut.AutomaticallyConfirmOrganizationUserAsync(orgId, orgUserId, model); + + // Assert + Assert.IsType(result); + } + + [Theory] + [BitAutoData] + public async Task AutomaticallyConfirmOrganizationUserAsync_Success_ReturnsOk( + Guid orgId, + Guid orgUserId, + Guid userId, + OrganizationUserConfirmRequestModel model, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers) + .Returns(true); + + sutProvider.GetDependency() + .GetProperUserId(Arg.Any()) + .Returns(userId); + + sutProvider.GetDependency() + .OrganizationOwner(orgId) + .Returns(true); + + sutProvider.GetDependency() + .AutomaticallyConfirmOrganizationUserAsync(Arg.Any()) + .Returns(new CommandResult(new None())); + + // Act + var result = await sutProvider.Sut.AutomaticallyConfirmOrganizationUserAsync(orgId, orgUserId, model); + + // Assert + Assert.IsType(result); + } + + [Theory] + [BitAutoData] + public async Task AutomaticallyConfirmOrganizationUserAsync_NotFoundError_ReturnsNotFound( + Guid orgId, + Guid orgUserId, + Guid userId, + OrganizationUserConfirmRequestModel model, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers) + .Returns(true); + + sutProvider.GetDependency() + .GetProperUserId(Arg.Any()) + .Returns(userId); + + sutProvider.GetDependency() + .OrganizationOwner(orgId) + .Returns(false); + + var notFoundError = new OrganizationNotFound(); + sutProvider.GetDependency() + .AutomaticallyConfirmOrganizationUserAsync(Arg.Any()) + .Returns(new CommandResult(notFoundError)); + + // Act + var result = await sutProvider.Sut.AutomaticallyConfirmOrganizationUserAsync(orgId, orgUserId, model); + + // Assert + var notFoundResult = Assert.IsType>(result); + Assert.Equal(notFoundError.Message, notFoundResult.Value.Message); + } + + [Theory] + [BitAutoData] + public async Task AutomaticallyConfirmOrganizationUserAsync_BadRequestError_ReturnsBadRequest( + Guid orgId, + Guid orgUserId, + Guid userId, + OrganizationUserConfirmRequestModel model, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers) + .Returns(true); + + sutProvider.GetDependency() + .GetProperUserId(Arg.Any()) + .Returns(userId); + + sutProvider.GetDependency() + .OrganizationOwner(orgId) + .Returns(true); + + var badRequestError = new UserIsNotAccepted(); + sutProvider.GetDependency() + .AutomaticallyConfirmOrganizationUserAsync(Arg.Any()) + .Returns(new CommandResult(badRequestError)); + + // Act + var result = await sutProvider.Sut.AutomaticallyConfirmOrganizationUserAsync(orgId, orgUserId, model); + + // Assert + var badRequestResult = Assert.IsType>(result); + Assert.Equal(badRequestError.Message, badRequestResult.Value.Message); + } + + [Theory] + [BitAutoData] + public async Task AutomaticallyConfirmOrganizationUserAsync_InternalError_ReturnsProblem( + Guid orgId, + Guid orgUserId, + Guid userId, + OrganizationUserConfirmRequestModel model, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers) + .Returns(true); + + sutProvider.GetDependency() + .GetProperUserId(Arg.Any()) + .Returns(userId); + + sutProvider.GetDependency() + .OrganizationOwner(orgId) + .Returns(true); + + var internalError = new FailedToWriteToEventLog(); + sutProvider.GetDependency() + .AutomaticallyConfirmOrganizationUserAsync(Arg.Any()) + .Returns(new CommandResult(internalError)); + + // Act + var result = await sutProvider.Sut.AutomaticallyConfirmOrganizationUserAsync(orgId, orgUserId, model); + + // Assert + var problemResult = Assert.IsType>(result); + Assert.Equal(StatusCodes.Status500InternalServerError, problemResult.StatusCode); + } } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUsers/AutomaticallyConfirmOrganizationUsersValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUsers/AutomaticallyConfirmOrganizationUsersValidatorTests.cs new file mode 100644 index 000000000000..50dd61697496 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUsers/AutomaticallyConfirmOrganizationUsersValidatorTests.cs @@ -0,0 +1,471 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; +using Bit.Core.AdminConsole.Services; +using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; +using Bit.Core.Billing.Enums; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Test.AutoFixture.OrganizationFixtures; +using Bit.Core.Test.AutoFixture.OrganizationUserFixtures; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUsers; + +[SutProviderCustomize] +public class AutomaticallyConfirmOrganizationUsersValidatorTests +{ + [Theory] + [BitAutoData] + public async Task ValidateAsync_WithValidAcceptedUser_ReturnsValidResult( + SutProvider sutProvider, + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser organizationUser, + Guid userId) + { + // Arrange + organizationUser.UserId = userId; + organizationUser.OrganizationId = organization.Id; + organization.PlanType = PlanType.EnterpriseAnnually; + + var request = new AutomaticallyConfirmOrganizationUserValidationRequest + { + OrganizationUser = organizationUser, + Organization = organization, + PerformedBy = Substitute.For(), + PerformedOn = DateTimeOffset.UtcNow, + Key = "test-key", + DefaultUserCollectionName = "test-collection" + }; + + sutProvider.GetDependency() + .GetCountByFreeOrganizationAdminUserAsync(userId) + .Returns(0); + + sutProvider.GetDependency() + .TwoFactorIsEnabledAsync(Arg.Any>()) + .Returns([(userId, true)]); + + sutProvider.GetDependency() + .GetManyByUserAsync(userId) + .Returns([organizationUser]); + + // Act + var result = await sutProvider.Sut.ValidateAsync(request); + + // Assert + Assert.True(result.IsValid); + Assert.Equal(request, result.Request); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_WithInvitedUser_ReturnsUserIsNotAcceptedError( + SutProvider sutProvider, + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Invited)] OrganizationUser organizationUser, + Guid userId) + { + // Arrange + organizationUser.UserId = userId; + organizationUser.OrganizationId = organization.Id; + + var request = new AutomaticallyConfirmOrganizationUserValidationRequest + { + OrganizationUser = organizationUser, + Organization = organization, + PerformedBy = Substitute.For(), + PerformedOn = DateTimeOffset.UtcNow, + Key = "test-key", + DefaultUserCollectionName = "test-collection" + }; + + // Act + var result = await sutProvider.Sut.ValidateAsync(request); + + // Assert + Assert.True(result.IsError); + Assert.IsType(result.AsError); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_WithMismatchedOrganizationId_ReturnsOrganizationUserIdIsInvalidError( + SutProvider sutProvider, + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser organizationUser, + Guid userId) + { + // Arrange + organizationUser.UserId = userId; + organizationUser.OrganizationId = Guid.NewGuid(); // Different from organization.Id + + var request = new AutomaticallyConfirmOrganizationUserValidationRequest + { + OrganizationUser = organizationUser, + Organization = organization, + PerformedBy = Substitute.For(), + PerformedOn = DateTimeOffset.UtcNow, + Key = "test-key", + DefaultUserCollectionName = "test-collection" + }; + + // Act + var result = await sutProvider.Sut.ValidateAsync(request); + + // Assert + Assert.True(result.IsError); + Assert.IsType(result.AsError); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_FreeOrgUserIsAdminOfAnotherFreeOrg_ReturnsError( + SutProvider sutProvider, + [OrganizationCustomize(PlanType = PlanType.Free)] Organization organization, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser organizationUser, + Guid userId) + { + // Arrange + organizationUser.UserId = userId; + organizationUser.OrganizationId = organization.Id; + + var request = new AutomaticallyConfirmOrganizationUserValidationRequest + { + OrganizationUser = organizationUser, + Organization = organization, + PerformedBy = Substitute.For(), + PerformedOn = DateTimeOffset.UtcNow, + Key = "test-key", + DefaultUserCollectionName = "test-collection" + }; + + sutProvider.GetDependency() + .GetCountByFreeOrganizationAdminUserAsync(userId) + .Returns(1); // User is admin/owner of another free org + + // Act + var result = await sutProvider.Sut.ValidateAsync(request); + + // Assert + Assert.True(result.IsError); + Assert.IsType(result.AsError); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_UserWithout2FA_And2FARequired_ReturnsError( + SutProvider sutProvider, + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser organizationUser, + Guid userId) + { + // Arrange + organizationUser.UserId = userId; + organizationUser.OrganizationId = organization.Id; + + var request = new AutomaticallyConfirmOrganizationUserValidationRequest + { + OrganizationUser = organizationUser, + Organization = organization, + PerformedBy = Substitute.For(), + PerformedOn = DateTimeOffset.UtcNow, + Key = "test-key", + DefaultUserCollectionName = "test-collection" + }; + + var twoFactorPolicyDetails = new OrganizationUserPolicyDetails + { + OrganizationId = organization.Id, + PolicyType = PolicyType.TwoFactorAuthentication, + PolicyEnabled = true + }; + + sutProvider.GetDependency() + .GetCountByFreeOrganizationAdminUserAsync(userId) + .Returns(0); + + sutProvider.GetDependency() + .TwoFactorIsEnabledAsync(Arg.Any>()) + .Returns([(userId, false)]); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PolicyRequirements) + .Returns(false); + + sutProvider.GetDependency() + .GetPoliciesApplicableToUserAsync(userId, PolicyType.TwoFactorAuthentication) + .Returns([twoFactorPolicyDetails]); + + // Act + var result = await sutProvider.Sut.ValidateAsync(request); + + // Assert + Assert.True(result.IsError); + Assert.IsType(result.AsError); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_UserWith2FA_ReturnsValidResult( + SutProvider sutProvider, + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser organizationUser, + Guid userId) + { + // Arrange + organizationUser.UserId = userId; + organizationUser.OrganizationId = organization.Id; + + var request = new AutomaticallyConfirmOrganizationUserValidationRequest + { + OrganizationUser = organizationUser, + Organization = organization, + PerformedBy = Substitute.For(), + PerformedOn = DateTimeOffset.UtcNow, + Key = "test-key", + DefaultUserCollectionName = "test-collection" + }; + + sutProvider.GetDependency() + .GetCountByFreeOrganizationAdminUserAsync(userId) + .Returns(0); + + sutProvider.GetDependency() + .TwoFactorIsEnabledAsync(Arg.Any>()) + .Returns([(userId, true)]); + + sutProvider.GetDependency() + .GetManyByUserAsync(userId) + .Returns([organizationUser]); + + // Act + var result = await sutProvider.Sut.ValidateAsync(request); + + // Assert + Assert.True(result.IsValid); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_UserInMultipleOrgs_WithSingleOrgPolicyOnThisOrg_ReturnsError( + SutProvider sutProvider, + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser organizationUser, + OrganizationUser otherOrgUser, + Guid userId) + { + // Arrange + organizationUser.UserId = userId; + organizationUser.OrganizationId = organization.Id; + + var request = new AutomaticallyConfirmOrganizationUserValidationRequest + { + OrganizationUser = organizationUser, + Organization = organization, + PerformedBy = Substitute.For(), + PerformedOn = DateTimeOffset.UtcNow, + Key = "test-key", + DefaultUserCollectionName = "test-collection" + }; + + var singleOrgPolicyDetails = new OrganizationUserPolicyDetails + { + OrganizationId = organization.Id, + PolicyType = PolicyType.SingleOrg, + PolicyEnabled = true + }; + + sutProvider.GetDependency() + .GetCountByFreeOrganizationAdminUserAsync(userId) + .Returns(0); + + sutProvider.GetDependency() + .TwoFactorIsEnabledAsync(Arg.Any>()) + .Returns([(userId, true)]); + + sutProvider.GetDependency() + .GetManyByUserAsync(userId) + .Returns([organizationUser, otherOrgUser]); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PolicyRequirements) + .Returns(false); + + sutProvider.GetDependency() + .GetPoliciesApplicableToUserAsync(userId, PolicyType.SingleOrg) + .Returns([singleOrgPolicyDetails]); + + // Act + var result = await sutProvider.Sut.ValidateAsync(request); + + // Assert + Assert.True(result.IsError); + Assert.IsType(result.AsError); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_UserInMultipleOrgs_WithSingleOrgPolicyOnOtherOrg_ReturnsError( + SutProvider sutProvider, + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser organizationUser, + OrganizationUser otherOrgUser, + Guid userId) + { + // Arrange + organizationUser.UserId = userId; + organizationUser.OrganizationId = organization.Id; + + var request = new AutomaticallyConfirmOrganizationUserValidationRequest + { + OrganizationUser = organizationUser, + Organization = organization, + PerformedBy = Substitute.For(), + PerformedOn = DateTimeOffset.UtcNow, + Key = "test-key", + DefaultUserCollectionName = "test-collection" + }; + + var singleOrgPolicyDetails = new OrganizationUserPolicyDetails + { + OrganizationId = Guid.NewGuid(), // Different org + PolicyType = PolicyType.SingleOrg, + PolicyEnabled = true + }; + + sutProvider.GetDependency() + .GetCountByFreeOrganizationAdminUserAsync(userId) + .Returns(0); + + sutProvider.GetDependency() + .TwoFactorIsEnabledAsync(Arg.Any>()) + .Returns([(userId, true)]); + + sutProvider.GetDependency() + .GetManyByUserAsync(userId) + .Returns([organizationUser, otherOrgUser]); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PolicyRequirements) + .Returns(false); + + sutProvider.GetDependency() + .GetPoliciesApplicableToUserAsync(userId, PolicyType.SingleOrg) + .Returns([singleOrgPolicyDetails]); + + // Act + var result = await sutProvider.Sut.ValidateAsync(request); + + // Assert + Assert.True(result.IsError); + Assert.IsType(result.AsError); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_UserInSingleOrg_ReturnsValidResult( + SutProvider sutProvider, + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser organizationUser, + Guid userId) + { + // Arrange + organizationUser.UserId = userId; + organizationUser.OrganizationId = organization.Id; + + var request = new AutomaticallyConfirmOrganizationUserValidationRequest + { + OrganizationUser = organizationUser, + Organization = organization, + PerformedBy = Substitute.For(), + PerformedOn = DateTimeOffset.UtcNow, + Key = "test-key", + DefaultUserCollectionName = "test-collection" + }; + + sutProvider.GetDependency() + .GetCountByFreeOrganizationAdminUserAsync(userId) + .Returns(0); + + sutProvider.GetDependency() + .TwoFactorIsEnabledAsync(Arg.Any>()) + .Returns([(userId, true)]); + + sutProvider.GetDependency() + .GetManyByUserAsync(userId) + .Returns([organizationUser]); // Single org + + // Act + var result = await sutProvider.Sut.ValidateAsync(request); + + // Assert + Assert.True(result.IsValid); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_UserInMultipleOrgs_WithNoSingleOrgPolicy_ReturnsValidResult( + SutProvider sutProvider, + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser organizationUser, + OrganizationUser otherOrgUser, + Guid userId) + { + // Arrange + organizationUser.UserId = userId; + organizationUser.OrganizationId = organization.Id; + + var request = new AutomaticallyConfirmOrganizationUserValidationRequest + { + OrganizationUser = organizationUser, + Organization = organization, + PerformedBy = Substitute.For(), + PerformedOn = DateTimeOffset.UtcNow, + Key = "test-key", + DefaultUserCollectionName = "test-collection" + }; + + sutProvider.GetDependency() + .GetCountByFreeOrganizationAdminUserAsync(userId) + .Returns(0); + + sutProvider.GetDependency() + .TwoFactorIsEnabledAsync(Arg.Any>()) + .Returns([(userId, true)]); + + sutProvider.GetDependency() + .GetManyByUserAsync(userId) + .Returns([organizationUser, otherOrgUser]); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PolicyRequirements) + .Returns(false); + + sutProvider.GetDependency() + .GetPoliciesApplicableToUserAsync(userId, PolicyType.SingleOrg) + .Returns([]); + + // Create a real instance with no policies + var policyRequirement = new SingleOrganizationPolicyRequirement([]); + + sutProvider.GetDependency() + .GetAsync(userId) + .Returns(policyRequirement); + + // Act + var result = await sutProvider.Sut.ValidateAsync(request); + + // Assert + Assert.True(result.IsValid); + } +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountCommandTests.cs index c223520a04d1..dfb1b35be065 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountCommandTests.cs @@ -1,5 +1,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.Utilities.v2; +using Bit.Core.AdminConsole.Utilities.v2.Validation; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions;